refactor: archive GUI clients; keep minimal TUI with hooks/API
ci / tcc-check (push) Successful in 1m8s
ci / linux (aarch64, 0) (push) Failing after 1h0m3s
ci / linux (x86, 1) (push) Failing after 1h0m3s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 50s

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:
WHC
2026-10-30 22:49:23 +08:00
co-authored by Cursor
parent ea38247161
commit 5538ed3412
184 changed files with 14098 additions and 1858 deletions
+248
View File
@@ -0,0 +1,248 @@
cmake_minimum_required(VERSION 3.16)
project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0)
# The Qt front end (app/) embeds the C engine in-process: it links libcstcore
# and never opens a port. Two targets:
#
# cst-app-selftest pure C smoke test of the embedding path (no Qt), so the
# C side is still guarded on machines without Qt Quick
# cst-agent-app the GUI (Qt 6 Quick + QML), built when the Qt modules
# are there
#
# Standalone build (builds the engine from ../ too):
# cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug && cmake --build build-app -j
# Inside the repo build (the parent defines the cstcore target, shared with the CLI):
# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app
#
# Desktop install (the window, its launcher entry and the icon; the CLI has
# its own installer) -- default.nix installs exactly this component:
# cmake --install build --component app --prefix ~/.local
#
# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix:
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
option(CST_APP_GUI "build the Qt GUI (needs Qt6 Quick, Qml, QuickControls2)" ON)
option(CST_APP_SELFTEST "build the C embedding smoke test" ON)
# --- the engine ----------------------------------------------------------
# Used as a subproject (../CMakeLists.txt already defined cstcore, engine
# compiled once for CLI and app) or built here from the sources next door.
if(NOT TARGET cstcore)
set(CST_APP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..")
include_directories("${CST_APP_ROOT}/include" "${CST_APP_ROOT}/vendor/cjson")
file(GLOB CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/*.c"
"${CST_APP_ROOT}/src/tools/*.c"
"${CST_APP_ROOT}/vendor/cjson/cJSON.c")
list(REMOVE_ITEM CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/main.c"
"${CST_APP_ROOT}/src/tui.c"
"${CST_APP_ROOT}/src/tui_stub.c"
"${CST_APP_ROOT}/src/textsel.c")
# The HTTP front end is not part of an in-process app: no server, no port.
list(REMOVE_ITEM CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/net.c"
"${CST_APP_ROOT}/src/http_srv.c"
"${CST_APP_ROOT}/src/webui.c")
# web_md.c stays in: the window renders markdown with it (AgentCore::
# renderMarkdown) and the md_to_html tool needs it. Only the HTTP server
# is left out.
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
include_directories("${CST_APP_ROOT}/.gen")
endif()
add_library(cstcore STATIC ${CST_APP_CORE_SRC})
target_compile_definitions(cstcore PRIVATE CST_NO_WEBUI=1)
find_package(CURL REQUIRED)
find_package(Threads REQUIRED)
target_link_libraries(cstcore PUBLIC ${CURL_LIBRARIES} Threads::Threads m)
message(STATUS "app: building the engine from ${CST_APP_ROOT}")
else()
message(STATUS "app: linking the cstcore target of the parent build")
endif()
# --- the C smoke test ----------------------------------------------------
if(CST_APP_SELFTEST)
add_executable(cst-app-selftest tools/selftest.c)
target_link_libraries(cst-app-selftest PRIVATE cstcore)
enable_testing()
add_test(NAME cst-app-selftest COMMAND cst-app-selftest)
endif()
# --- the GUI -------------------------------------------------------------
if(CST_APP_GUI)
# Android's platform file talks to the platform through QtAndroidPrivate
# (the POST_NOTIFICATIONS probe), whose include path comes with CorePrivate.
if(ANDROID)
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test
CorePrivate QUIET)
else()
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test QUIET)
endif()
if(NOT Qt6_FOUND)
message(WARNING
"app: Qt 6 Quick/Qml not found, skipping cst-agent-app "
"(the C selftest still builds; install e.g. qt6.qtdeclarative / "
"qt6.qtquickcontrols2 and reconfigure)")
else()
qt_standard_project_setup()
# Qt 6.5+ policies: keep the module resource at :/qt/qml/<URI>/ (what
# main.cpp loads) and let CMake generate the qmldir of qml/ for us.
qt_policy(SET QTP0001 NEW)
qt_policy(SET QTP0004 NEW)
# The front end's platform tools: one file per platform, the same split
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
# "app:android"), Windows its own (PowerShell popup, owner "app:windows");
# the rest use the Linux file.
if(ANDROID)
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
elseif(WIN32)
set(CST_APP_PLATFORM_SRC src/platform/windows/NotifyTool.cpp)
else()
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
endif()
qt_add_executable(cst-agent-app
src/main.cpp
src/core/AgentCore.cpp
src/core/ToolProvider.cpp
src/core/PdfTools.cpp
src/ui/UiTest.cpp
src/ui/ThemeIcons.cpp
src/ui/SessionImages.cpp
src/ui/BackKey.cpp
${CST_APP_PLATFORM_SRC})
target_include_directories(cst-agent-app PRIVATE
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
target_compile_definitions(cst-agent-app PRIVATE CST_APP_VERSION="${PROJECT_VERSION}")
if(CST_APP_QML_IMPORT)
target_compile_definitions(cst-agent-app PRIVATE
CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}")
endif()
target_link_libraries(cst-agent-app PRIVATE
Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Test cstcore)
if(ANDROID)
# app/src/platform/android/AndroidTools.cpp talks to the platform
# through QtAndroidPrivate (permissions) -- a private header, hence the
# private module for its include path.
target_link_libraries(cst-agent-app PRIVATE Qt6::CorePrivate)
endif()
if(WIN32)
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
# A desktop install. The icon goes where the theme layout says, so the
# entry's Icon= name finds it, and the entry's file name is the app_id Qt
# sets (QGuiApplication::applicationName) -- rename one and the launcher
# loses the icon. All of it in the "app" component: the CLI keeps coming
# from scripts/bootstrap_linux.sh, and two `cst-agent` in PATH is how a
# version nobody meant to run gets run.
if(NOT ANDROID)
include(GNUInstallDirs)
install(TARGETS cst-agent-app
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT app)
install(FILES linux/cst-agent-app.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications COMPONENT app)
install(FILES linux/cst-agent-app.svg
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps
COMPONENT app)
endif()
# --- Android (APK) -----------------------------------------------------
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF,
# -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
# nothing listens on a port). The manifest, icons and strings live in
# app/android/, which androiddeployqt copies next to its own gradle
# project. minSdk 24 is the engine's own API_LEVEL (scripts/
# build_third_party_android.sh builds curl/mbedTLS for android-24).
if(ANDROID)
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/../include/cst_version.h"
_cst_ver_lines REGEX "#define CST_VERSION_(MAJOR|MINOR|PATCH) ")
foreach(_line ${_cst_ver_lines})
if(_line MATCHES "CST_VERSION_MAJOR ([0-9]+)")
set(_cst_vmaj "${CMAKE_MATCH_1}")
elseif(_line MATCHES "CST_VERSION_MINOR ([0-9]+)")
set(_cst_vmin "${CMAKE_MATCH_1}")
elseif(_line MATCHES "CST_VERSION_PATCH ([0-9]+)")
set(_cst_vpat "${CMAKE_MATCH_1}")
endif()
endforeach()
set_target_properties(cst-agent-app PROPERTIES
QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android"
QT_ANDROID_MIN_SDK_VERSION 24
QT_ANDROID_TARGET_SDK_VERSION 35
QT_ANDROID_VERSION_NAME "${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}"
QT_ANDROID_VERSION_CODE 1
QT_ANDROID_ABIS "${ANDROID_ABI}")
message(STATUS "app: Android package "
"${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}, ABI ${ANDROID_ABI}, "
"minSdk 24, targetSdk 35")
# Signing: androiddeployqt signs the APK itself and refuses a signed
# gradle output ("Could not copy unsigned APK"), so the keystore goes
# here, not into gradle. The path, alias and passwords arrive through
# the QT_ANDROID_KEYSTORE_* environment variables;
# scripts/build_apk_android.sh points them at the standard Android debug
# key it creates on first use, and CST_ANDROID_KEYSTORE overrides it.
if(DEFINED ENV{QT_ANDROID_KEYSTORE_PATH} AND NOT "$ENV{QT_ANDROID_KEYSTORE_PATH}" STREQUAL "")
set(QT_ANDROID_SIGN_APK TRUE)
message(STATUS "app: signing the APK with "
"$ENV{QT_ANDROID_KEYSTORE_PATH} ($ENV{QT_ANDROID_KEYSTORE_ALIAS})")
else()
message(STATUS "app: QT_ANDROID_KEYSTORE_PATH unset -- the APK stays "
"unsigned and 'adb install' will refuse it (INSTALL_PARSE_FAILED_NO_CERTIFICATES)")
endif()
endif()
# Theme is read by every component, so it is a QML singleton (one instance,
# no object needed in the tree).
set_source_files_properties(qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
# Keep the module flat in the resource tree (:qt/qml/CstAgent/Main.qml).
# Sources live in qml/, but a module whose files sit in a subdirectory of
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
# requires absolute URLs" on every start).
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog TaskPanel
ThemeIcon Theme ImageStrip)
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
endforeach()
qt_add_qml_module(cst-agent-app
URI CstAgent
VERSION 1.0
QML_FILES
qml/Main.qml
qml/ChatView.qml
qml/Sidebar.qml
qml/SessionRow.qml
qml/MessageCard.qml
qml/ToolCard.qml
qml/ThinkBlock.qml
qml/NoteLine.qml
qml/TodoPanel.qml
qml/Composer.qml
qml/TaskPanel.qml
qml/ConsentDialog.qml
qml/AskDialog.qml
qml/SettingsDialog.qml
qml/ThemeIcon.qml
qml/ImageStrip.qml
qml/Theme.qml)
# The icons the window asks for, compiled in (app/icons, Breeze's scalable
# names copied at 24@3x). The desktop finds them in the platform's icon
# theme and only falls back to these (ThemeIcons.cpp); a machine without
# any theme -- Android -- has nothing else, and before this the QML glyph
# fallback was all that showed, which the phone's fonts cannot render.
set(CST_APP_ICONS
go-down mail-attachment application-menu settings-configure edit-copy
edit-delete arrow-down arrow-right list-add task-complete box
document-edit text-x-generic folder-open edit-find code-context
internet-services format-list-unordered help-hint)
set(_cst_icon_files "")
foreach(_icon ${CST_APP_ICONS})
list(APPEND _cst_icon_files "${CMAKE_CURRENT_SOURCE_DIR}/icons/${_icon}.svg")
endforeach()
qt_add_resources(cst-agent-app "cst_icons"
PREFIX "/cst-icons"
BASE "${CMAKE_CURRENT_SOURCE_DIR}/icons"
FILES ${_cst_icon_files})
endif()
endif()
+10
View File
@@ -0,0 +1,10 @@
# Deprecated: Qt front end (archived)
This directory is the **legacy Qt 6 / QML** Hermit GUI (`cst-agent-app`),
formerly at `app/`. It is **frozen** under `archive/qt-app`.
Hermit’s product surface is the CLI/TUI + `libcstcore` + `--web` (`cst-webui/1`).
Another archived desktop shell is `archive/desktop-tauri` (Tauri 2 + React).
Do not add features here. Experimental rebuild: `-DCST_BUILD_APP=ON` from the
repo root (see root `archive/README.md`).
+467
View File
@@ -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
+113
View File
@@ -0,0 +1,113 @@
<?xml version="1.0"?>
<!-- The APK's manifest (QT_ANDROID_PACKAGE_SOURCE_DIR in app/CMakeLists.txt):
androiddeployqt copies this file and res/ into its gradle project and
fills in the INSERT_* placeholders below (version code and name, library
name, app arguments). An XML comment may not contain a double hyphen, so
the placeholder syntax is only spelled out in the attributes themselves.
The SDK levels are not here on purpose: they come from
QT_ANDROID_MIN_SDK_VERSION and QT_ANDROID_TARGET_SDK_VERSION in
app/CMakeLists.txt, and androiddeployqt writes them into the generated
gradle.properties. The app name and icon come from res/ next to this
file (values/strings.xml, drawable/ic_launcher.xml).
WHAT THIS APP IS: the engine runs in this process (no server, no port) and
writes everything (workspace, session store, settings, credentials) under
the app's own data directory, which main.cpp points the engine at (see
androidRoot() there).
Permissions: INTERNET for the providers the user configures,
POST_NOTIFICATIONS for the app's notification_send tool (Android 13+
asks at runtime; the tool stays hidden until it is granted), the
foreground-service permissions with WAKE_LOCK for the service that keeps
a turn going while the phone is in a pocket (CstService.java), and
MANAGE_EXTERNAL_STORAGE ("all files access", Android 11+) so the engine
can read a path in shared storage that a task names rather than picks:
Android only offers the Settings screen for it, so main.cpp opens that
once and everything still works without it: what the system picker (SAF)
hands over is copied into the app's own directory (AttachFile.java). No
cleartext traffic: every provider URL is https. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="xyz.whcself.cstagent"
android:installLocation="auto"
android:versionCode="-- %%INSERT_VERSION_CODE%% --"
android:versionName="-- %%INSERT_VERSION_NAME%% --">
<!-- %%INSERT_PERMISSIONS -->
<!-- %%INSERT_FEATURES -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- "All files access": without it the engine can only read the app's own
directory, so a task that names a file in shared storage cannot be
answered. Android 11+ has no runtime dialog for it, only the Settings
screen (AttachFile.java opens it once). Files the user picks work
either way: their content:// grant is copied into the app first. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
android:normalScreens="true"
android:smallScreens="true" />
<application
android:name="org.qtproject.qt.android.bindings.QtApplication"
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:allowBackup="true"
android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="false">
<activity
android:name="org.qtproject.qt.android.bindings.QtActivity"
android:label="@string/app_name"
android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|keyboard|keyboardHidden|navigation|density|mcc|mnc"
android:launchMode="singleTop"
android:screenOrientation="unspecified"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="-- %%INSERT_APP_LIB_NAME%% --" />
<meta-data
android:name="android.app.arguments"
android:value="-- %%INSERT_APP_ARGUMENTS%% --" />
</activity>
<!-- The share tool hands a file out of the workspace, and a file
crossing an app boundary has to travel as a content:// uri. This
is Qt's own provider (androidx.core.content.FileProvider, paths
from res/xml/qtprovider_paths.xml): its files-path is the app's
files directory, which is where the workspace lives. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.qtprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/qtprovider_paths" />
</provider>
<!-- A turn can outlive the screen: while one is running the front end
starts this service (AgentCore::send -> app::holdForeground), which
makes the process a foreground one, so Android neither freezes nor
kills it in the background, and takes a partial wake lock so the
CPU keeps going with the phone in a pocket. dataSync is the type
that fits "the app is finishing some work". -->
<service
android:name="xyz.whcself.cstagent.CstService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher icon: the terminal prompt the app is about ("&gt;_"), the window's
own colors (bg #12161a, accent #5cc0f0, faint #8d959c). A vector drawable
works from API 24 up; a proper adaptive icon (mipmap-anydpi-v26 + a PNG
fallback) is polish for later. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#12161a"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5cc0f0"
android:strokeWidth="7"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M32,38 L52,54 L32,70" />
<path
android:fillColor="#00000000"
android:strokeColor="#8d959c"
android:strokeWidth="7"
android:strokeLineCap="round"
android:pathData="M58,74 L78,74" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The name under the launcher icon. -->
<resources>
<string name="app_name">CST-agent</string>
</resources>
@@ -0,0 +1,189 @@
package xyz.whcself.cstagent;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.OpenableColumns;
import android.provider.Settings;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
/* What the user picks in the system file chooser comes back as a content://
* URI, and the engine opens files by path -- so the bytes are copied into the
* app's own directory first. That copy needs no permission at all (the grant
* travels with the URI), which is what makes picking work on a phone that
* grants nothing else.
*
* The "all files access" below is a different thing: it is what lets the
* engine read /sdcard by path afterwards (a model that opens a file the user
* names rather than picks). Android 11+ has no runtime dialog for it -- only
* the Settings screen -- so it is a request, not a demand: everything works
* without it, one file at a time.
*
* Qt swallows a Java exception (it comes back as a null object), so every
* method catches its own and answers with a reason instead.
*/
public final class AttachFile {
private AttachFile() {}
private static final long MAX_BYTES = 256L * 1024 * 1024;
public static boolean isContent(String s) {
return s != null && s.startsWith("content:");
}
/* content://<uri> -> the path of a copy inside destDir; "" when the string
* is already a path; "error: ..." when the copy failed. */
public static String materialize(Context ctx, String pathOrUri, String destDir) {
if (pathOrUri == null || pathOrUri.isEmpty())
return "error: no file was picked";
if (!isContent(pathOrUri))
return "";
try {
if (ctx == null)
return "error: no Android context";
final Uri uri = Uri.parse(pathOrUri);
String name = displayName(ctx, uri);
if (name == null || name.trim().isEmpty())
name = uri.getLastPathSegment();
name = sanitize(name);
File dir = new File(destDir);
if (!dir.isDirectory() && !dir.mkdirs())
return "error: cannot use " + destDir;
File out = unique(dir, name);
final ContentResolver cr = ctx.getContentResolver();
final InputStream in = cr.openInputStream(uri);
if (in == null)
return "error: the picker gave no data for that file";
long total = 0;
try {
final OutputStream os = new FileOutputStream(out);
try {
final byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) > 0) {
total += n;
if (total > MAX_BYTES) {
os.close();
in.close();
out.delete();
return "error: that file is larger than 256 MB";
}
os.write(buf, 0, n);
}
} finally {
os.close();
}
} finally {
in.close();
}
if (total == 0) {
out.delete();
return "error: that file is empty";
}
return out.getAbsolutePath();
} catch (Throwable t) {
return "error: cannot read the picked file: " + t;
}
}
/* Android 11+ keeps "all files access" in a special app-op; older systems
* have no such gate. */
public static boolean hasAllFilesAccess(Context ctx) {
try {
if (Build.VERSION.SDK_INT < 30)
return true;
return Environment.isExternalStorageManager();
} catch (Throwable t) {
return false;
}
}
/* The Settings screen Android requires for it. "" when it was opened. */
public static String requestAllFilesAccess(Context ctx) {
try {
if (Build.VERSION.SDK_INT < 30 || ctx == null)
return "";
if (Environment.isExternalStorageManager())
return "";
final Intent intent = new Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + ctx.getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
ctx.startActivity(intent);
} catch (ActivityNotFoundException notFound) {
final Intent all = new Intent(
Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
all.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
ctx.startActivity(all);
}
return "";
} catch (Throwable t) {
return "cannot open the storage settings: " + t;
}
}
private static String displayName(Context ctx, Uri uri) {
Cursor c = null;
try {
c = ctx.getContentResolver().query(uri,
new String[] { OpenableColumns.DISPLAY_NAME }, null, null,
null);
if (c != null && c.moveToFirst()) {
final int i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (i >= 0)
return c.getString(i);
}
} catch (Throwable ignored) {
/* fall back to the last path segment */
} finally {
if (c != null)
c.close();
}
return null;
}
/* A plain file name: no separators, no traversal, no control bytes. */
private static String sanitize(String name) {
if (name == null || name.isEmpty())
return "picked.bin";
final StringBuilder b = new StringBuilder();
for (int i = 0; i < name.length() && b.length() < 120; i++) {
final char ch = name.charAt(i);
if (ch == '/' || ch == '\\' || ch < 0x20)
b.append('_');
else
b.append(ch);
}
String s = b.toString();
while (s.startsWith("."))
s = s.substring(1);
if (s.isEmpty())
return "picked.bin";
return s;
}
private static File unique(File dir, String name) {
File f = new File(dir, name);
if (!f.exists())
return f;
final int dot = name.lastIndexOf('.');
final String stem = dot > 0 ? name.substring(0, dot) : name;
final String ext = dot > 0 ? name.substring(dot) : "";
for (int i = 2; i < 1000; i++) {
f = new File(dir, stem + "-" + i + ext);
if (!f.exists())
return f;
}
return f;
}
}
@@ -0,0 +1,143 @@
package xyz.whcself.cstagent;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
/**
* The service that keeps a turn running while the app is not on screen.
*
* A phone freezes an app it cannot see (and may kill it), so a turn that takes
* minutes -- a build, a long web fetch, a provider that thinks -- dies in the
* pocket. While one is going, AgentCore::send() starts this service through
* app::holdForeground(); being a foreground service makes the process one
* Android does not touch, and the partial wake lock keeps the CPU awake with
* the screen off. turnFinished() stops it again (app::releaseForeground()), so
* the permanent notification exists exactly as long as the work does.
*
* Started with an explicit component (AndroidTools.cpp builds the Intent), no
* intent filter: nothing outside the app can start it.
*/
public class CstService extends Service {
/** Same channel the notification_send tool uses: one place in settings. */
private static final String CHANNEL_ID = "cst-agent";
private static final int NOTE_ID = 2;
private static final String ACTION_STOP = "xyz.whcself.cstagent.STOP";
/** A turn that runs longer than this is stuck; the lock must not outlive it. */
private static final long WAKE_LOCK_TIMEOUT_MS = 30 * 60 * 1000L;
private PowerManager.WakeLock wake;
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
stopEverything();
return START_NOT_STICKY;
}
String text = intent == null ? null : intent.getStringExtra("text");
try {
startForeground(NOTE_ID, buildNote(text));
} catch (Throwable t) {
/* Android 12+ refuses a foreground start from the background: the
* turn then simply runs without the protection rather than not at
* all. Nothing to show the user here -- the turn's own result is
* what matters. */
stopSelf();
return START_NOT_STICKY;
}
acquireWake();
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
releaseWake();
super.onDestroy();
}
private void stopEverything() {
releaseWake();
if (Build.VERSION.SDK_INT >= 24)
stopForeground(STOP_FOREGROUND_REMOVE);
else
stopForeground(true);
stopSelf();
}
private void acquireWake() {
try {
if (wake == null) {
PowerManager pm =
(PowerManager) getSystemService(Context.POWER_SERVICE);
if (pm == null)
return;
wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"cst-agent:turn");
wake.setReferenceCounted(false);
}
wake.acquire(WAKE_LOCK_TIMEOUT_MS);
} catch (Throwable ignored) {
/* no wake lock: the turn still runs while the screen is on */
}
}
private void releaseWake() {
try {
if (wake != null && wake.isHeld())
wake.release();
} catch (Throwable ignored) {
}
}
/** "the agent is working: <first line of the prompt>", tapping opens it. */
private Notification buildNote(String text) {
Context ctx = getApplicationContext();
NotificationManager manager =
(NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager != null && Build.VERSION.SDK_INT >= 26) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID, "CST-agent", NotificationManager.IMPORTANCE_LOW);
channel.setDescription("A turn is running");
manager.createNotificationChannel(channel);
}
Notification.Builder builder = Build.VERSION.SDK_INT >= 26
? new Notification.Builder(ctx, CHANNEL_ID)
: new Notification.Builder(ctx);
builder.setContentTitle("CST-agent is working")
.setSmallIcon(smallIcon())
.setOngoing(true)
.setShowWhen(false);
if (text != null && !text.isEmpty())
builder.setContentText(text);
Intent open = ctx.getPackageManager().getLaunchIntentForPackage(ctx.getPackageName());
if (open != null) {
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
builder.setContentIntent(PendingIntent.getActivity(ctx, 0, open,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
}
return builder.build();
}
/** A framework drawable first (the same order the tool uses), then the app's. */
private int smallIcon() {
int icon = getResources().getIdentifier("ic_dialog_info", "drawable", "android");
if (icon == 0)
icon = getResources().getIdentifier("stat_notify_chat", "drawable", "android");
if (icon == 0)
icon = getApplicationInfo().icon;
return icon;
}
}
@@ -0,0 +1,60 @@
package xyz.whcself.cstagent;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
/**
* The notification behind the engine's notification_send tool.
*
* It lives in Java because a Java exception is invisible to C++ by the time
* Qt's JNI wrapper is done with the call: Notification.Builder.build() can
* throw (a small icon Android refuses, a channel it does not know) and the
* JNI call then simply returns null -- the tool could only say "failed, no
* exception detail" on a device where the phone's own shade never saw
* anything. Catching it here turns the failure into the line the tool shows.
*
* Called from app/src/platform/android/AndroidTools.cpp through QJniObject.
* androiddeployqt copies this file with the rest of the package source
* (QT_ANDROID_PACKAGE_SOURCE_DIR, app/android/) and Qt's Gradle template adds
* "src" to java.srcDirs, so no build script change is needed for a file
* added here.
*/
public final class Notifier {
private Notifier() {}
/**
* Post one notification. An empty channelId means an API below 26, which
* has no channels. Returns "" when it went out, the Java failure
* otherwise (class name and message), so the tool result can name it.
*/
public static String post(Context ctx, String channelId, String title,
String text, int iconId, Intent tap, int id) {
try {
Notification.Builder builder = channelId == null || channelId.isEmpty()
? new Notification.Builder(ctx)
: new Notification.Builder(ctx, channelId);
builder.setContentTitle(title).setContentText(text).setSmallIcon(iconId);
if (tap != null) {
tap.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pending = PendingIntent.getActivity(
ctx, 0, tap,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
builder.setContentIntent(pending);
}
NotificationManager manager = (NotificationManager)
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null)
return "no NotificationManager";
manager.notify(id, builder.build());
return "";
} catch (Throwable t) {
String message = t.getMessage();
return t.getClass().getSimpleName()
+ (message == null || message.isEmpty() ? "" : ": " + message);
}
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3 5v2h16v-2h-16m0 5v2h16v-2h-16m0 5v2h16v-2h-16" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 446 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 549 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m7.707031 3l-.707031.707031 6.125 6.125 1.167969 1.167969-1.167969 1.167969-6.125 6.125.707031.707031 6.125-6.125 1.875-1.875-1.875-1.875-6.125-6.125" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 547 B

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 24 24" width="24" height="24">
<defs>
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path class="ColorScheme-Text" transform="translate(-.99999-.99999)" d="m4 4v16h16v-16h-16m1 1h14v14h-14v-14" fill="currentColor"/>
<path d="m3.5056345 10.977883v-7.5052069h14.878582v15.010415h-14.878582z" class="ColorScheme-Text" fill="currentColor" fill-opacity=".50196078"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 589 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m10.04492 3c-1.77683 0-2.521484.706446-2.521484 2.509766v2.441406c0 1.11382-.034195 2.404297-1.015625 2.404297h-.507813v1.289062h.507813c.98148 0 1.015625 1.290477 1.015625 2.404297v2.441406c0 1.80332.744654 2.509766 2.521484 2.509766v-1.183594c-1.18455 0-1.201172-.478327-1.201172-2.210937v-2.01563c0-.83093-.204705-2.067378-1.203125-2.580078.99842-.51271 1.203125-1.751092 1.203125-2.582032v-2.033203c0-1.7326.016592-2.210937 1.201172-2.210937v-1.183594m1.894531 0v1.183594c1.18456 0 1.216797.478337 1.216797 2.210937v2.033203c0 .83094.204705 2.069322 1.203125 2.582032-.99842.5127-1.203125 1.749148-1.203125 2.580078v2.01563c0 1.73261-.032187 2.210937-1.216797 2.210937v1.183594c1.77684 0 2.537109-.706446 2.537109-2.509766v-2.441406c0-1.11382.068644-2.404297 1.033204-2.404297h.490234v-1.289062h-.490234c-.96457 0-1.033204-1.290477-1.033204-2.404297v-2.441406c0-1.80332-.760269-2.509766-2.537109-2.509766" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m14.996094 3l-11.992188 11.992188h-.003906v4.00781h1 2 1.00781v-.003906l11.992188-11.992188-.001953-.001953.001953-.001953-4-4-.001953.001953-.001953-.001953m-1.998047 3.412109l2.589844 2.589844-7.587891 7.587891v-1.589844h-1-1v-1-.589844l6.998047-6.998047m-7.998047 7.998047v1.589844h1 1v1 .589844l-.410156.410156h-1.589844l-1-1v-1.589844l1-1" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 741 B

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" version="1.1" width="24" height="24">
<defs>
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path class="ColorScheme-Text" style="fill:currentColor; fill-opacity:1; stroke:none" d="M 3 3 L 3 17 L 7 17 L 7 19 L 17 19 L 17 10 L 13 6 L 12 6 L 9 3 L 3 3 Z M 4 4 L 8 4 L 8 6 L 7 6 L 7 16 L 4 16 L 4 4 Z M 8 7 L 12 7 L 12 11 L 16 11 L 16 18 L 8 18 L 8 7 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 583 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-NegativeText { color: #da4453; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" class="ColorScheme-NegativeText" d="M8 3v2h1V4h4v1h1V3H8M4 6v1h14V6H4m2 2v11h10V8h-1v10H7V8H6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 471 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 9 3 C 5.6759952 3 3 5.6759952 3 9 C 3 12.324005 5.6759952 15 9 15 C 10.481205 15 11.830584 14.465318 12.875 13.582031 L 18.292969 19 L 19 18.292969 L 13.582031 12.875 C 14.465318 11.830584 15 10.481205 15 9 C 15 5.6759952 12.324005 3 9 3 z M 9 4 C 11.770005 4 14 6.2299952 14 9 C 14 11.770005 11.770005 14 9 14 C 6.2299952 14 4 11.770005 4 9 C 4 6.2299952 6.2299952 4 9 4 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 774 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor" d="M 3 3 L 3 4 L 3 19 L 4 19 L 19 19 L 19 18 L 19 5 L 12. 5 L 10. 3 L 10 3 L 10 3 L 4 3 L 3 3 z M 9 8 L 18 8 L 18 18 L 4 18 L 4 10 L 7 10 L 7 10 L 7 10 L 9 8 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 528 B

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 4 3 C 3.4477153 3 3 3.4477153 3 4 C 3 4.5522847 3.4477153 5 4 5 C 4.5522847 5 5 4.5522847 5 4 C 5 3.4477153 4.5522847 3 4 3 z M 10 3 L 10 4 L 19 4 L 19 3 L 10 3 z M 10 5 L 10 6 L 16 6 L 16 5 L 10 5 z M 4 9 C 3.4477153 9 3 9.4477153 3 10 C 3 10.552285 3.4477153 11 4 11 C 4.5522847 11 5 10.552285 5 10 C 5 9.4477153 4.5522847 9 4 9 z M 10 9 L 10 10 L 19 10 L 19 9 L 10 9 z M 10 11 L 10 12 L 12 12 L 12 11 L 10 11 z M 4 15 C 3.4477153 15 3 15.447715 3 16 C 3 16.552285 3.4477153 17 4 17 C 4.5522847 17 5 16.552285 5 16 C 5 15.447715 4.5522847 15 4 15 z M 10 15 L 10 16 L 19 16 L 19 15 L 10 15 z M 10 17 L 10 18 L 16 18 L 16 17 L 10 17 z M 17 18 L 17 19 L 19 19 L 19 18 L 17 18 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3.707031 7l-.707031.707031 6.125 6.125 1.875 1.875 1.875-1.875 6.125-6.125-.707031-.707031-6.125 6.125-1.167969 1.167969-1.167969-1.167969-6.125-6.125" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 549 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 11 3 C 6.568 3 3 6.568 3 11 C 3 15.432 6.568 19 11 19 C 15.432 19 19 15.432 19 11 C 19 6.568 15.432 3 11 3 z M 11 4 C 14.878 4 18 7.122 18 11 C 18 14.878 14.878 18 11 18 C 7.122 18 4 14.878 4 11 C 4 7.122 7.122 4 11 4 z M 10 6 L 10 8 L 12 8 L 12 6 L 10 6 z M 10 9 L 10 16 L 12 16 L 12 9 L 10 9 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 696 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 10 4 L 10 11 L 3 11 L 3 12 L 10 12 L 10 19 L 11 19 L 11 12 L 18 12 L 18 11 L 11 11 L 11 4 L 10 4 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 499 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 12.902344 3 L 3.734375 12.167969 C 2.167415 13.734929 2.167415 16.257279 3.734375 17.824219 C 5.301315 19.391169 7.823675 19.391169 9.390625 17.824219 L 17.851562 9.3632812 L 18.558594 8.65625 C 19.733804 7.48104 19.733794 5.5892625 18.558594 4.4140625 C 17.383384 3.2388525 15.491616 3.2388525 14.316406 4.4140625 L 13.609375 5.1210938 L 5.1484375 13.582031 C 4.3649575 14.365511 4.3649675 15.626686 5.1484375 16.410156 C 5.9319175 17.193636 7.1930825 17.193646 7.9765625 16.410156 L 16.4375 7.9492188 L 15.730469 7.2421875 L 7.9765625 14.996094 L 7.2695312 15.703125 C 6.8777912 16.094865 6.2471888 16.094865 5.8554688 15.703125 C 5.4637287 15.311405 5.4637187 14.680813 5.8554688 14.289062 L 14.316406 5.828125 L 15.023438 5.1210938 C 15.806917 4.3376037 17.068092 4.3376037 17.851562 5.1210938 C 18.635042 5.9045538 18.635042 7.1657387 17.851562 7.9492188 L 8.6835938 17.117188 C 7.5083838 18.292397 5.6166162 18.292397 4.4414062 17.117188 C 3.2661862 15.941977 3.2661862 14.05021 4.4414062 12.875 L 13.609375 3.7070312 L 12.902344 3 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="M 11.5 3 C 10.286139 3 9.2809778 3.8559279 9.0507812 5 L 3 5 L 3 6 L 9.0507812 6 C 9.2809778 7.1440721 10.286139 8 11.5 8 C 12.713861 8 13.719022 7.1440721 13.949219 6 L 19 6 L 19 5 L 13.949219 5 C 13.719022 3.8559279 12.713861 3 11.5 3 z M 5.5 14 C 4.1149999 14 3 15.115 3 16.5 C 3 17.885 4.1149999 19 5.5 19 C 6.7138604 19 7.7190223 18.144072 7.9492188 17 L 19 17 L 19 16 L 7.9492188 16 C 7.7190223 14.855928 6.7138604 14 5.5 14 z M 5.5 15 C 6.3310001 15 7 15.669 7 16.5 C 7 17.331 6.3310001 18 5.5 18 C 4.6689999 18 4 17.331 4 16.5 C 4 15.669 4.6689999 15 5.5 15 z " class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 966 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style id="current-color-scheme" type="text/css">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path class="ColorScheme-Text" d="M 19,9 12,16 8,12 9.4896719,10.543135 12,13 17.482382,7.5492102 Z M 4,3 V 5 H 3 V 6 H 4 V 7 H 3 v 1 h 1 v 6 H 3 v 1 h 1 v 1 H 3 v 1 h 1 v 2 h 3 12 v -1 -7 h -1 v 1 1 2 1 2 H 10 9 7 V 16 15 13 12 10 9 7 6 4 h 2 1 8 v 2 h 1 V 3 H 7 Z m 4,3 v 2 h 2 V 6 Z m 3,0 v 1 h 6 V 6 Z" style="fill:currentColor;fill-opacity:1;stroke:none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 669 B

+14
View File
@@ -0,0 +1,14 @@
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="24" version="1.1" height="24" viewBox="0 0 24 24">
<defs id="defs3871"/>
<metadata id="metadata3874"/>
<g transform="translate(1,1)">
<g inkscape:label="Capa 1" inkscape:groupmode="layer" id="layer1" transform="matrix(1 0 0 1 -326 -534.3622)">
<rect width="8" x="329" y="537.36218" height="1.00002" style="fill:#6c7a89" id="rect4279"/>
<rect width="16" x="329" y="541.36218" height="1" style="fill:#6c7a89" id="rect4281"/>
<rect width="16" x="329" y="548.36218" height="1" style="fill:#6c7a89" id="rect4283"/>
<rect width="8" x="329" y="545.36218" height="1" style="fill:#6c7a89" id="rect4285"/>
<rect width="12" x="329" y="552.36218" height="0.999983" style="fill:#6c7a89" id="rect4287"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 919 B

@@ -0,0 +1,15 @@
[Desktop Entry]
Type=Application
Name=CST-agent
GenericName=Local coding agent
Comment=The engine in a window: workspace is the directory it is started in
Exec=cst-agent-app
TryExec=cst-agent-app
Icon=cst-agent-app
Terminal=false
StartupNotify=true
# Qt puts the application name in the Wayland app_id / WM_CLASS, so this has to
# stay the file's own name or the launcher draws a generic icon.
StartupWMClass=cst-agent-app
Categories=Development;Utility;
Keywords=agent;ai;assistant;code;
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- The launcher icon: the prompt the CLI shows, on a tile in the window's own
palette (app/qml/Theme.qml). Installed under hicolor/scalable, so the
desktops that want raster sizes render it themselves. -->
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect x="6" y="6" width="116" height="116" rx="26" ry="26"
fill="#15181a" stroke="#343b41" stroke-width="5"/>
<path d="M36 42 L56 64 L36 86" fill="none" stroke="#3daee9" stroke-width="12"
stroke-linecap="round" stroke-linejoin="round"/>
<path d="M69 88 H94" fill="none" stroke="#eff0f1" stroke-width="12"
stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 712 B

+108
View File
@@ -0,0 +1,108 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// on_ask_user: the agent stopped and wants an answer from the human. Options
// (when the engine sends any) become buttons; the field is always there.
Dialog {
id: dialog
property string question: ""
property string optionText: ""
signal answered(string text)
readonly property var options: {
const raw = optionText.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0 && s.length < 60)
return raw.length > 1 && raw.length <= 8 ? raw : []
}
modal: true
anchors.centerIn: parent
width: Math.min(620, parent ? parent.width - 60 : 620)
padding: Theme.pad
background: Rectangle {
color: Theme.surface
border.width: 1
border.color: Theme.border
radius: Theme.radius
}
function send(text) {
const t = text.trim()
if (!t.length)
return
answered(t)
close()
}
contentItem: ColumnLayout {
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
Text {
text: "The agent asks"
color: Theme.text
font.pixelSize: Theme.bodySize + 1
font.bold: true
}
}
Text {
Layout.fillWidth: true
text: dialog.question
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
}
Text {
Layout.fillWidth: true
visible: dialog.optionText.length > 0 && dialog.options.length === 0
text: dialog.optionText
color: Theme.textDim
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
Flow {
Layout.fillWidth: true
visible: dialog.options.length > 0
spacing: Theme.gap
Repeater {
model: dialog.options
Button {
text: modelData
onClicked: dialog.send(modelData)
}
}
}
TextField {
id: answer
Layout.fillWidth: true
placeholderText: "your answer"
placeholderTextColor: Theme.textFaint
color: Theme.text
font.pixelSize: Theme.bodySize
selectByMouse: true
onAccepted: dialog.send(text)
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: answer.activeFocus ? Theme.accentDim : Theme.border
}
}
}
footer: DialogButtonBox {
padding: Theme.pad
Button {
text: "Send"
highlighted: true
enabled: answer.text.trim().length > 0
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
onClicked: dialog.send(answer.text)
}
}
onOpened: answer.forceActiveFocus()
}
+152
View File
@@ -0,0 +1,152 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The transcript: cards for the messages, a card per tool call, folded
// thinking blocks and one-line notes. Follows the tail of the conversation
// until the user scrolls away from it.
Item {
id: view
property var model: null
property bool running: false
ListView {
id: list
objectName: "transcript"
anchors.fill: parent
anchors.leftMargin: Theme.gapL
anchors.rightMargin: Theme.gapL
model: view.model
clip: true
spacing: Theme.gap
topMargin: Theme.gapL
bottomMargin: Theme.gapL
cacheBuffer: 2000
boundsBehavior: Flickable.StopAtBounds
// The delegate picks the card by row kind; the components below read
// the ListModel roles directly, which is why they are declared in the
// delegate's scope.
delegate: Loader {
id: rowLoader
width: list.width
sourceComponent: model.kind === "tool" ? toolComp
: model.kind === "think" ? thinkComp
: model.kind === "note" ? noteComp : msgComp
Component {
id: msgComp
MessageCard {
role: model.role
text: model.text
complete: model.complete
images: model.images !== undefined ? model.images : ""
/* html comes from the streaming renderer (Main.qml) or,
* for a replayed session, from here once. */
html: model.html.length > 0 ? model.html
: (model.role === "agent" || model.role === "error") &&
model.complete && model.text.length > 0
? agent.renderMarkdown(model.text) : ""
}
}
Component {
id: thinkComp
ThinkBlock {
text: model.text
complete: model.complete
}
}
Component {
id: toolComp
ToolCard {
objectName: "toolCard"
name: model.name
state: model.state
detail: model.detail
output: model.output
exitCode: model.exitCode
hasResult: model.complete
images: model.images !== undefined ? model.images : ""
}
}
Component {
id: noteComp
NoteLine {
text: model.text
level: model.state === "warn" || model.state === "error" ? model.state : "info"
}
}
}
onMovementEnded: view.stickBottom = atYEnd
onCountChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
onContentHeightChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
}
// Follow the tail unless the user asked otherwise.
property bool stickBottom: true
// Empty state: what this window is and what to do with it.
ColumnLayout {
anchors.centerIn: parent
width: Math.min(parent.width - 80, 520)
spacing: Theme.gap
visible: view.model ? view.model.count === 0 : true
Text {
Layout.alignment: Qt.AlignHCenter
text: "CST-agent"
color: Theme.text
font.pixelSize: 22
font.bold: true
}
Text {
Layout.alignment: Qt.AlignHCenter
text: "The engine runs in this window — no server, no second process."
color: Theme.textDim
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
Text {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Theme.gap
text: "Ask for a change, a command to run, a file to explain.\n" +
"Every write, delete and command asks first: Deny / Once / Always.\n" +
"Enter sends, Shift+Enter adds a line, Esc stops a running turn."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
// Jump back to the live end of the conversation.
RoundButton {
id: jump
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.padL
width: Theme.jumpButton
height: Theme.jumpButton
visible: !view.stickBottom && list.contentHeight > list.height
contentItem: ThemeIcon {
name: "go-down"
glyph: "↓"
size: 20
color: Theme.text
}
background: Rectangle {
radius: width / 2
color: jump.hovered ? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
onClicked: {
view.stickBottom = true
list.positionViewAtEnd()
}
}
}
+252
View File
@@ -0,0 +1,252 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
// The input line: multiline, Enter sends, the button changes with the turn
// state. Sized for a thumb as much as for a mouse, and it asks the platform
// keyboard not to autocorrect what is usually code or a path.
//
// Files arrive three ways -- the clip picker, a drop on the window (Main.qml)
// and an image on the clipboard (Ctrl+V) -- and all of them end up in the
// engine's pending list, which the next prompt carries.
Rectangle {
id: root
objectName: "composer"
property bool running: false
property bool compact: false
property string note: "" // a failure, or what was just attached
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
property alias text: input.text
signal submitted(string text)
function submit() {
const t = input.text.trim()
if (!t.length)
return
input.text = ""
submitted(t)
}
/* A file: from the picker, or a URL dropped on the window. */
function localPath(url) {
let s = String(url)
// A phone's picker (SAF) hands over content:// URIs: they are not
// paths and must not be decoded -- the app copies what they point at
// into its own directory before the engine sees it (see
// app::materializeAttachment).
if (s.startsWith("content://"))
return s
if (s.startsWith("file://"))
s = s.substring(7)
/* Windows keeps its drive letter after the slash. */
if (/^\/[A-Za-z]:/.test(s))
s = s.substring(1)
return decodeURIComponent(s)
}
function attachPath(path) {
const r = agent.attachFile(path)
note = r.length ? r : ""
}
radius: Theme.radius
color: Theme.surface
border.width: 1
border.color: input.activeFocus ? Theme.accentDim : Theme.border
/* One pad of frame on every side of the column: with the top margin
* alone the last row (the clip and Send) hung over the lower border,
* which is what put the clip on the frame instead of inside it. */
implicitHeight: layout.implicitHeight + Theme.pad * 2
ColumnLayout {
id: layout
anchors {
fill: parent
margins: Theme.pad
}
spacing: 6
// What the next prompt will carry, in the engine's own words.
Flow {
Layout.fillWidth: true
visible: root.attachments.length > 0
spacing: 6
Repeater {
model: root.attachments
delegate: Rectangle {
id: chip
required property var modelData
required property int index
objectName: "attachChip" + chip.index
implicitWidth: chipText.implicitWidth + 14
implicitHeight: 24
radius: Theme.radiusSm
color: Theme.surfaceAlt
border.width: 1
border.color: Theme.border
Text {
id: chipText
anchors.centerIn: parent
text: String(chip.modelData)
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
}
}
Button {
id: clearAtt
objectName: "clearAttachments"
text: "clear all"
implicitHeight: 24
onClicked: {
agent.clearPending()
root.note = ""
}
background: Rectangle {
radius: Theme.radiusSm
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: clearAtt.text
color: clearAtt.hovered ? Theme.text : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
Text {
Layout.fillWidth: true
visible: root.note.length > 0
text: root.note
color: Theme.warn
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
TextArea {
id: input
objectName: "composerInput"
Layout.fillWidth: true
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
Math.max(56, contentHeight + 12))
placeholderText: root.running
? "Send a correction now — Esc pauses execution"
: (root.compact
? "Ask for something…"
: "Ask for something (Enter sends, Shift+Enter for a new line)")
placeholderTextColor: Theme.textFaint
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: TextArea.Wrap
selectByMouse: true
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
Qt.ImhNoAutoUppercase
background: Rectangle { color: "transparent" }
enabled: true
Keys.onPressed: function (ev) {
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
if (!(ev.modifiers & Qt.ShiftModifier)) {
root.submit()
ev.accepted = true
}
return
}
/* An image has nothing the editor could paste, so Ctrl+V
* becomes an attachment there; text stays with the
* editor's own paste. */
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
agent.clipboardHasImage()) {
root.note = agent.attachClipboard()
ev.accepted = true
}
}
}
ToolButton {
id: attach
objectName: "attachButton"
Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? Theme.touchTarget : 40
implicitHeight: Theme.touchTarget + 4
enabled: !root.running
ToolTip.visible: hovered
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
contentItem: ThemeIcon {
name: "mail-attachment"
glyph: "📎"
size: 20
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
: Theme.textFaint
}
background: Rectangle {
radius: Theme.radiusSm
color: attach.hovered && attach.enabled ? Theme.surfaceHi
: "transparent"
}
onClicked: picker.open()
}
Button {
id: action
objectName: "sendButton"
Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? 68 : 88
implicitHeight: Theme.touchTarget + 4
text: root.running ? (input.text.trim().length ? "Redirect" : "Stop") : "Send"
enabled: root.running || input.text.trim().length > 0
onClicked: root.running && !input.text.trim().length ? agent.cancel() : root.submit()
background: Rectangle {
radius: Theme.radiusSm
color: !action.enabled ? Theme.surfaceAlt
: root.running ? (action.hovered ? "#e05666" : Theme.err)
: (action.hovered ? "#5cc0f0" : Theme.accent)
}
contentItem: Text {
text: action.text
color: action.enabled ? Theme.accentInk : Theme.textFaint
font.pixelSize: Theme.bodySize
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// On a narrow window the hint would wrap into the editor's space.
Text {
Layout.fillWidth: true
visible: !root.compact
text: {
if (root.running)
return "working — Esc cancels the turn"
let s = "Enter sends · Shift+Enter newline · Esc stops · the clip attaches a file, Ctrl+V an image"
if (root.attachments.length > 0 && agent.seesImages() === 0)
s += " · this model is text-only, images arrive as notes"
return s
}
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
FileDialog {
id: picker
title: "Attach a file"
fileMode: FileDialog.OpenFile
onAccepted: root.attachPath(root.localPath(selectedFile))
}
}
+122
View File
@@ -0,0 +1,122 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The engine's on_permission hook is blocked on this dialog: nothing runs
// until one of the three answers goes back.
Dialog {
id: dialog
property string tool: ""
property string detail: ""
property string preview: ""
signal answered(int choice)
modal: true
anchors.centerIn: parent
width: Math.min(760, parent ? parent.width - 60 : 760)
padding: Theme.pad
title: ""
background: Rectangle {
color: Theme.surface
border.width: 1
border.color: Theme.border
radius: Theme.radius
}
contentItem: ColumnLayout {
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle {
width: 8
height: 8
radius: 4
color: Theme.warn
}
Text {
text: "Permission requested"
color: Theme.text
font.pixelSize: Theme.bodySize + 1
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: dialog.tool
color: Theme.warn
font.family: Theme.mono
font.pixelSize: Theme.monoSize
}
}
Text {
Layout.fillWidth: true
visible: dialog.detail.length > 0
text: dialog.detail
color: Theme.textDim
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(320, previewText.implicitHeight + Theme.pad * 2)
visible: dialog.preview.length > 0
color: Theme.bg
radius: Theme.radiusSm
border.width: 1
border.color: Theme.border
Flickable {
id: flick
anchors.fill: parent
anchors.margins: 8
contentWidth: previewText.width
contentHeight: previewText.height
clip: true
ScrollBar.vertical: ScrollBar {}
Text {
id: previewText
width: Math.max(flick.width, implicitWidth)
text: dialog.preview
color: Theme.text
font.family: Theme.mono
font.pixelSize: Theme.monoSize
textFormat: Text.PlainText
}
}
}
Text {
Layout.fillWidth: true
text: "“Always” is remembered for this tool in .agent/permissions.json — the TUI and the CLI see the same decision."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
footer: DialogButtonBox {
spacing: Theme.gap
padding: Theme.pad
Button {
text: "Deny"
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
onClicked: { dialog.answered(0); dialog.close() }
}
Button {
text: "Once"
highlighted: true
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
onClicked: { dialog.answered(1); dialog.close() }
}
Button {
text: "Always"
DialogButtonBox.buttonRole: DialogButtonBox.ActionRole
onClicked: { dialog.answered(2); dialog.close() }
}
}
}
+269
View File
@@ -0,0 +1,269 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The files a message or a tool call carries.
//
// A picture is a thumbnail from the session's own store (image://cstattach/,
// app/src/ui/SessionImages.cpp) and opens with whatever the system uses for
// images when it is tapped. A file the agent delivered (deliver_file) is a card
// with the name, the type, the size and two buttons: open and share. The names
// are the ones the engine wrote next to the session -- a card never invents a
// path.
//
// One line per file, written by AgentCore.cpp (filesText):
// <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
// A session saved by an older version wrote "<stored name>|<name>|<origin>|
// <w>x<h>" for pictures; both shapes are read here.
Flow {
id: strip
objectName: "imageStrip"
/* A row carries this as text: a ListModel role cannot hold a list. */
property string text: ""
property var files: decode(text)
/* The last refusal, so a tap that cannot work says why (the header's own
* status line is the engine's). */
property string errorText: ""
spacing: 6
function decode(t) {
const out = []
const lines = String(t).split("\n")
for (let i = 0; i < lines.length; i++) {
if (!lines[i].length)
continue
const f = lines[i].split("|")
if (!f[0].length)
continue
const old = f.length < 7 // a picture line from an old session
const dim = String(old ? f[3] : f[6]).split("x")
let w = 0, h = 0
if (dim.length === 2) {
w = parseInt(dim[0], 10) || 0
h = parseInt(dim[1], 10) || 0
}
const rel = old ? f[0] : f[1]
out.push({ kind: old ? "img" : f[0],
rel: rel,
name: (old ? f[1] : f[2]) || rel,
origin: (old ? f[2] : f[3]) || "",
mime: old ? "image/*" : (f[4] || ""),
bytes: old ? 0 : (parseInt(f[5], 10) || 0),
w: w, h: h,
url: "image://cstattach/" + rel })
}
return out
}
function humanSize(n) {
if (!n || n <= 0)
return ""
if (n >= 1024 * 1024)
return (n / (1024 * 1024)).toFixed(1) + " MB"
if (n >= 1024)
return Math.round(n / 1024) + " KB"
return n + " B"
}
/* The two things a file card does. The engine answers with "" when it
* worked (it puts the line in the header itself) or a reason. */
function openFile(rel) {
errorText = agent.openArtifact(rel)
}
function shareFile(rel) {
errorText = agent.shareArtifact(rel)
}
Repeater {
model: strip.files
delegate: Rectangle {
id: cell
required property var modelData
required property int index
readonly property bool isFile: String(modelData.kind) !== "img"
readonly property bool hovered: mouse.containsMouse
/* Images keep their own shape, capped; a file card is a fixed
* width so a long name cannot stretch the transcript. */
readonly property real ratio: {
const w = Number(modelData.w), h = Number(modelData.h)
return (w > 0 && h > 0) ? w / h : 1.5
}
readonly property int cap: 150
objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index)
width: isFile ? 300
: Math.max(56, Math.min(Math.round(cap * ratio), 320))
height: isFile ? 52
: Math.max(40, Math.min(cap, Math.round(width / ratio)))
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: hovered ? Theme.accentDim : Theme.border
clip: true
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
}
// --- a picture: tap it to look at it ---------------------------
Image {
id: pic
anchors.fill: parent
anchors.margins: 2
visible: !cell.isFile && status === Image.Ready
source: String(cell.modelData.url)
sourceSize.width: 512
sourceSize.height: 512
fillMode: Image.PreserveAspectFit
asynchronous: false
}
Text {
anchors.centerIn: parent
width: parent.width - 8
visible: !cell.isFile && pic.status === Image.Error
text: String(cell.modelData.name)
elide: Text.ElideMiddle
horizontalAlignment: Text.AlignHCenter
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
MouseArea {
anchors.fill: parent
enabled: !cell.isFile
cursorShape: Qt.PointingHandCursor
onClicked: strip.openFile(String(cell.modelData.rel))
}
// --- a delivered file: name, type, open, share -----------------
RowLayout {
id: row
visible: cell.isFile
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 6
spacing: 6
/* The extension stands in for an icon: no theme lookup, and a
* ZIP that says ZIP is more use than a generic sheet. */
Rectangle {
implicitWidth: 40
implicitHeight: 30
radius: Theme.radiusSm
color: Theme.surfaceHi
Text {
anchors.centerIn: parent
text: {
const n = String(cell.modelData.name)
const dot = n.lastIndexOf(".")
if (dot < 0 || dot === n.length - 1)
return "FILE"
return n.slice(dot + 1, dot + 5).toUpperCase()
}
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Text {
Layout.fillWidth: true
text: String(cell.modelData.name)
color: Theme.text
font.pixelSize: Theme.bodySize
elide: Text.ElideMiddle
}
Text {
Layout.fillWidth: true
text: {
const m = cell.modelData
let s = m.mime ? String(m.mime) : ""
const size = strip.humanSize(m.bytes)
if (size)
s += (s ? " · " : "") + size
if (m.origin && m.origin !== "user" && m.origin !== "tool")
s += (s ? " · " : "") + m.origin
return s
}
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
ToolButton {
objectName: "fileOpen" + cell.index
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
onClicked: strip.openFile(String(cell.modelData.rel))
contentItem: Text {
text: "Open"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
}
ToolButton {
objectName: "fileShare" + cell.index
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
onClicked: strip.shareFile(String(cell.modelData.rel))
contentItem: Text {
text: "Share"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: Theme.border
}
}
}
ToolTip.visible: hovered
ToolTip.delay: 400
ToolTip.text: {
const m = cell.modelData
let s = String(m.name)
if (m.w > 0 && m.h > 0)
s += " · " + m.w + "×" + m.h
if (m.mime && m.kind !== "img")
s += " · " + m.mime
return s
}
}
}
/* A tap that could not work says why, right where the card is: a phone
* with nothing installed for the type is the common case ("no app on the
* phone opens report.md"), and a silence there looks like a dead button. */
Text {
objectName: "fileError"
visible: strip.errorText.length > 0
width: strip.width
wrapMode: Text.WordWrap
text: strip.errorText
color: Theme.err
font.pixelSize: Theme.smallSize
}
}
+717
View File
@@ -0,0 +1,717 @@
// The window: a sidebar with the engine's settings, the transcript, the
// composer, and the two dialogs the engine can block on.
//
// One shell, three sizes: the sidebar is docked on a desktop (and can be
// dragged), slides over the transcript when the window is narrow, and every
// control keeps a finger-sized hit area for touch screens. Qt Quick Controls
// only, laid out the way Kirigami expects (sidebar + page, cards, header), so
// swapping in org.kde.kirigami later stays a QML change.
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: win
width: initialWidth > 0 ? initialWidth : 1180
height: initialHeight > 0 ? initialHeight : 800
minimumWidth: 360
minimumHeight: 420
visible: true
color: Theme.bg
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
property string statusText: ""
property var sessionsView: [] // the store, for the sidebar
property bool navOpen: false // the sliding sidebar, compact only
TaskPanel { id: taskPanel; anchors.centerIn: parent }
property int sidebarWidth: 0 // 0 = follow the window width
readonly property bool compact: width < Theme.compactBreakpoint
readonly property int sidebarWide: sidebarWidth > 0
? sidebarWidth
: Math.max(228, Math.min(360, Math.round(width * 0.26)))
// The settings panel, used docked and in the sliding panel: one definition,
// two places.
component ShellSidebar: Sidebar {
workspace: agent.workspace
provider: agent.provider
model: agent.model
mode: agent.mode
approval: agent.approval
planMode: agent.planMode
running: agent.running
status: win.statusText
todo: agent.todo
sessions: win.sessionsView
sessionId: agent.sessionId
onModeRequested: function (m) { agent.setMode(m) }
onSessionRequested: function (id) { win.switchSession(id) }
onSessionNewRequested: win.newSession()
onSessionDeleteRequested: function (id) { win.deleteSession(id) }
onApprovalRequested: function (a) { agent.setApproval(a) }
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
}
// ---- the transcript --------------------------------------------------
ListModel { id: transcript }
function lastIndex() { return transcript.count - 1 }
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
function addRow(kind, role, state) {
transcript.append({ kind: kind, role: role, text: "", html: "", complete: false,
name: "", callId: "", state: state, detail: "", output: "",
exitCode: 0, images: "" })
}
// The engine streams one kind at a time: close whatever was open before.
function closeOpenRows(exceptKind) {
const last = lastRow()
if (last && !last.complete && last.kind !== exceptKind)
transcript.setProperty(lastIndex(), "complete", true)
}
function appendDelta(kind, role, text) {
closeOpenRows(kind)
if (!lastRow() || lastRow().kind !== kind || lastRow().complete)
addRow(kind, role, "running")
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
}
// The open card of this call, -1 when none. The id is what tells two calls
// of the same tool apart (pairing by name left the first card running
// forever); a call without one -- an old text-protocol session -- falls
// back to the name.
function findTool(callId, name) {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "tool" || r.complete)
continue
if (callId.length) {
if (r.callId === callId)
return i
continue /* another call: its card is further back */
}
if (r.callId.length)
continue
if (r.name === name)
return i
return -1
}
return -1
}
function openTool(callId, name, state) {
closeOpenRows("tool")
addRow("tool", "tool", state)
transcript.setProperty(lastIndex(), "name", name)
transcript.setProperty(lastIndex(), "callId", callId)
return lastIndex()
}
function submit(text) {
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
name: "", callId: "", state: "", detail: "", output: "",
exitCode: 0, images: "" })
agent.send(text)
}
// A turn is over: nothing else is coming, so nothing may keep spinning.
function closeAllRows() {
for (let i = 0; i < transcript.count; i++) {
const r = transcript.get(i)
if (r.complete)
continue
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
r.state === "running"))
transcript.setProperty(i, "state", "unfinished")
transcript.setProperty(i, "complete", true)
}
}
// Markdown while it streams: the open answer is re-rendered with the
// engine's own renderer (the timer below), so the text fills in formatted
// instead of turning into markdown when the turn ends.
function renderStreaming() {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "message" || r.role !== "agent" || r.complete)
continue
const html = agent.renderMarkdown(r.text)
if (html !== r.html)
transcript.setProperty(i, "html", html)
return
}
}
// ---- sessions --------------------------------------------------------
function reloadSessions() { win.sessionsView = agent.sessions() }
// A stored session comes back as rows: the engine holds the transcript
// (AgentCore::history()), these rows only mirror it.
function replay() {
transcript.clear()
const rows = agent.history()
for (let i = 0; i < rows.length; i++)
transcript.append(rows[i])
chat.stickBottom = true
}
function switchSession(id) {
if (agent.switchSession(id))
win.reloadSessions()
}
function newSession() {
if (agent.newSession()) {
transcript.clear()
win.reloadSessions()
}
}
function deleteSession(id) {
if (agent.deleteSession(id))
win.reloadSessions()
}
// ---- provider, model, key --------------------------------------------
function openSettings() {
settings.load(agent.providers(), agent.provider, agent.model)
settings.open()
}
function applySettings(provider, model, key, temperature, topP, maxTokens) {
/* The parameters are stored first: useProvider() then picks up what
* was remembered for whichever provider it ends on. */
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
const err = perr.length ? perr : agent.useProvider(provider, model, key)
settings.note = err.length ? err
: "using " + agent.provider + " / " + agent.model
settings.choices = agent.modelChoices(provider)
settings.syncPick()
}
/* The engine opens the workspace's last session (cst_agent_open()), so the
* window is created with a transcript already in it: draw it, or the pane
* starts empty while the sidebar points at the very session it belongs to.
* A switch draws it the same way (the sessionChanged handler below). */
Component.onCompleted: {
win.reloadSessions()
win.replay()
}
// ---- the shell -------------------------------------------------------
/* A plain Rectangle, not a ToolBar. A Controls ToolBar hands its children
* to a style-owned contentItem, and on this phone that item sat 35 px below
* the bar itself -- header y 0 h 42 with its gear at y 56 -- so the gear
* was drawn, and clicked, outside the header, over the sidebar. A
* Rectangle puts its children where it is (checked with --uitest on the
* device and on both styles at home). */
header: Rectangle {
id: appBar
objectName: "appBar"
implicitHeight: Theme.headerHeight
color: Theme.surface
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Theme.border
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: Theme.pad
anchors.rightMargin: Theme.gap
spacing: Theme.gap
// The docked sidebar has its own title: only the sliding one needs
// a way in.
ToolButton {
objectName: "navButton"
visible: win.compact
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered && !win.compact
ToolTip.text: "Settings and plan"
contentItem: ThemeIcon {
name: "application-menu"
glyph: "☰"
size: 22
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.navOpen = !win.navOpen
}
Text {
visible: !win.compact
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideMiddle
text: agent.workspace.length ? agent.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
objectName: "statusText"
visible: win.statusText.length > 0 && !win.compact
text: win.statusText
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
Layout.maximumWidth: 260
}
BusyIndicator {
running: agent.running
visible: agent.running
implicitWidth: 22
implicitHeight: 22
}
ToolButton {
objectName: "settingsButton"
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered
ToolTip.text: "Provider, model and key"
contentItem: ThemeIcon {
name: "settings-configure"
glyph: "⚙"
size: 22
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.openSettings()
}
ToolButton {
objectName: "clearButton"
enabled: transcript.count > 0 && !agent.running
implicitWidth: win.compact ? Theme.touchTarget : 56
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered
ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history"
contentItem: Text {
text: "Clear"
color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent"
}
onClicked: {
transcript.clear()
win.statusText = ""
}
}
ToolButton {
objectName: "taskPanelButton"
text: "Tasks"
implicitHeight: Theme.touchTarget
onClicked: taskPanel.open()
}
}
}
RowLayout {
anchors.fill: parent
spacing: 0
ShellSidebar {
id: dockedSidebar
objectName: "dockedSidebar"
visible: !win.compact
Layout.fillHeight: true
Layout.preferredWidth: win.sidebarWide
}
// Drag the divider: a mouse affordance that costs nothing on touch
// (the handle is only there while the sidebar is docked).
Item {
id: divider
visible: !win.compact
Layout.fillHeight: true
implicitWidth: 5
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 1
height: parent.height
color: dividerMa.pressed ? Theme.accentDim
: dividerMa.containsMouse ? Theme.surfaceHi : Theme.border
}
MouseArea {
id: dividerMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SplitHCursor
property real startX: 0
property int startW: 0
onPressed: function (mouse) {
startX = mouse.x
startW = win.sidebarWide
}
onPositionChanged: function (mouse) {
if (pressed)
win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX)))
}
onDoubleClicked: win.sidebarWidth = 0
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 0
ChatView {
id: chat
objectName: "chatView"
Layout.fillWidth: true
Layout.fillHeight: true
model: transcript
running: agent.running
}
Composer {
id: composer
Layout.fillWidth: true
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
running: agent.running
compact: win.compact
attachments: agent.pending
onSubmitted: function (text) { win.submit(text) }
}
}
}
// ---- compact: the sidebar slides in over the transcript --------------
/* Files dropped anywhere on the window: the engine copies them next to
* the session and the next prompt carries them. The picker and Ctrl+V in
* the composer land in the same list. */
DropArea {
id: dropZone
objectName: "dropZone"
anchors.fill: parent
onDropped: function (drop) {
if (!drop.hasUrls)
return
let note = ""
let n = 0
for (let i = 0; i < drop.urls.length; i++) {
const r = agent.attachFile(composer.localPath(drop.urls[i]))
if (r.length)
note = r
else
n++
}
composer.note = note.length ? note
: n + (n === 1 ? " file attached" : " files attached")
drop.accept()
}
}
// While something is dragged over the window, say what will happen.
Rectangle {
anchors.fill: parent
visible: dropZone.containsDrag
color: "transparent"
border.width: 2
border.color: Theme.accent
Text {
anchors.centerIn: parent
text: "drop to attach"
color: Theme.accent
font.pixelSize: Theme.bodySize
font.bold: true
}
}
Rectangle {
id: scrim
objectName: "navScrim"
visible: win.navOpen && win.compact
anchors.fill: parent
color: "#000000"
opacity: 0.45
MouseArea {
anchors.fill: parent
onClicked: win.navOpen = false
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: navPanel
objectName: "navPanel"
visible: win.navOpen && win.compact
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: Math.min(320, win.width * 0.88)
color: Theme.surface
border.width: 1
border.color: Theme.border
x: win.navOpen && win.compact ? 0 : -width
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
Flickable {
anchors.fill: parent
contentHeight: panelSidebar.implicitHeight
clip: true
ScrollBar.vertical: ScrollBar {}
ShellSidebar {
id: panelSidebar
width: parent.width
border.width: 0
}
}
}
ConsentDialog {
id: consent
onAnswered: function (choice) { agent.answerPermission(choice) }
}
SettingsDialog {
id: settings
onApplyRequested: function (provider, model, key, temperature, topP, maxTokens) {
win.applySettings(provider, model, key, temperature, topP, maxTokens)
}
}
AskDialog {
id: ask
onAnswered: function (text) { agent.answerQuestion(text) }
}
Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() }
Shortcut {
sequence: "Ctrl+L"
enabled: !agent.running
onActivated: {
transcript.clear()
win.statusText = ""
}
}
/* The back key a phone has instead of a mouse: it closes what is open, one
* layer at a time -- the dialog on top, then the sliding sidebar. With
* nothing open the key goes on to the platform, and a phone leaves the
* window (Qt finishes the activity), which is right for the last layer.
*
* Which layer that is, and whether there is one, lives in one place: the
* C++ side (app/src/ui/BackKey.cpp) reads `backLayer()` on every Back key
* and calls `stepBack()` -- not a QML Shortcut, which a modal popup never
* lets through. --uitest reads the same two functions. */
function backLayer() {
if (settings.visible)
return "settings"
if (ask.visible)
return "ask"
if (consent.visible)
return "consent"
if (win.navOpen)
return "drawer"
return ""
}
/* One layer back. The dialogs are closed on the next turn of the event
* loop: closing one from inside the key event leaves its modal overlay up
* in this Qt (the dialog says it is closed, the overlay still eats every
* click after it), which --uitest walks straight into. */
function stepBack() {
if (settings.visible)
Qt.callLater(settings.close)
else if (ask.visible)
Qt.callLater(ask.close)
else if (consent.visible)
Qt.callLater(consent.close)
else if (win.navOpen)
win.navOpen = false
}
// ---- engine -> UI ----------------------------------------------------
/* The signal handlers only forward: every one of them has a function
* here, so the same path can be driven from --uitest (which is how the
* two-calls-of-one-tool case stays fixed). */
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
function engineThink(text) { win.appendDelta("think", "agent", text) }
function engineToolEvent(phase, id, name, detail) {
win.closeOpenRows("tool")
let i = win.findTool(id, name)
if (phase === "open" || phase === "arg") {
if (i < 0)
i = win.openTool(id, name, "pending")
if (detail.length)
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
return
}
if (phase === "ready" || phase === "run") {
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
transcript.setProperty(i, "detail", detail)
return
}
// done | denied
if (i < 0)
i = win.openTool(id, name, phase)
transcript.setProperty(i, "state", phase)
if (detail.length && transcript.get(i).output.length === 0)
transcript.setProperty(i, "output", detail)
}
function engineToolResult(id, name, exitCode, output) {
let i = win.findTool(id, name)
if (i < 0) /* a result whose call is not in the view: its own card */
i = win.openTool(id, name, "done")
transcript.setProperty(i, "output", output)
transcript.setProperty(i, "exitCode", exitCode)
transcript.setProperty(i, "state", "done")
transcript.setProperty(i, "complete", true)
}
function engineTurnFinished(rc, err) {
win.renderStreaming(); /* one last render while the row is still open */
win.closeAllRows()
if (err.length)
transcript.append({ kind: "note", role: "error", text: err, html: "",
complete: true, name: "", callId: "", state: "error",
detail: "", output: "", exitCode: 0 })
win.statusText = ""
}
/* The images the prompt just carried (AgentCore::promptImages): the
* composer has appended the user row, so they attach to it. */
function enginePromptImages(images) {
if (!images.length)
return
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind === "message" && r.role === "user") {
transcript.setProperty(i, "images", images)
return
}
}
}
/* Files a tool produced, on the card of the call that made them. The
* engine reports the result first and the files after it, so the card is
* normally already complete by the time they arrive -- findTool() only
* looks at cards still running, which is why this looks for the call id
* itself (and falls back to findTool for a call that has no id). */
function engineToolImages(callId, images) {
if (!images.length)
return
let i = -1
if (callId.length) {
for (let k = transcript.count - 1; k >= 0; k--) {
const r = transcript.get(k)
if (r.kind === "tool" && r.callId === callId) {
i = k
break
}
}
} else {
i = win.findTool("", "")
}
if (i >= 0)
transcript.setProperty(i, "images", images)
}
/* For --uitest: one transcript row, or null. */
function rowAt(i) {
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
}
/* For --uitest: the same row as JSON (a QObject's return value does not
* survive QMetaObject::invokeMethod, a string does). */
function rowJson(i) {
return JSON.stringify(rowAt(i))
}
/* While a turn runs the open answer is re-rendered a few times a second:
* markdown text fills in formatted rather than at the end of the turn. */
Timer {
interval: 120
repeat: true
running: agent.running
onTriggered: win.renderStreaming()
}
Connections {
target: agent
function onSessionChanged() { win.reloadSessions(); win.replay() }
function onSessionListChanged() { win.reloadSessions() }
function onAssistantDelta(text) { win.engineAssistant(text) }
function onThinkDelta(text) { win.engineThink(text) }
function onStatusChanged(text) { win.statusText = text }
function onToolEvent(phase, id, name, detail) {
win.engineToolEvent(phase, id, name, detail)
}
function onToolResult(id, name, exitCode, output) {
win.engineToolResult(id, name, exitCode, output)
}
function onToolImages(callId, images) {
win.engineToolImages(callId, images)
}
function onPromptImages(images) {
win.enginePromptImages(images)
}
function onPermissionRequested(tool, detail, preview) {
consent.tool = tool
consent.detail = detail
consent.preview = preview
consent.open()
}
function onQuestionAsked(question, options) {
ask.question = question
ask.optionText = options
ask.open()
}
/* Nobody answered (the app was in the background, or the dialog sat
* there too long): the turn went on without it, so put the dialog
* away instead of leaving a question that is no longer being asked. */
function onPermissionExpired(tool) { consent.close() }
function onQuestionExpired() { ask.close() }
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
function onRunningChanged() {
if (!agent.running)
win.closeAllRows()
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// One message in the transcript: the prompt, the answer, or an error. The
// answer arrives as plain text while it streams and is rendered from markdown
// (by the engine, through AgentCore::renderMarkdown) once it is complete.
Rectangle {
id: card
property string role: "agent" // user | agent | error
property string text: ""
property string html: ""
property bool complete: true
property string images: ""
radius: Theme.radius
color: role === "user" ? Theme.surfaceAlt : Theme.surface
border.width: 1
border.color: role === "error" ? Theme.err
: role === "user" ? Theme.border : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad * 2
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle {
width: 6
height: 6
radius: 3
color: card.role === "user" ? Theme.accent
: card.role === "error" ? Theme.err : Theme.ok
}
Text {
text: card.role === "user" ? "You"
: card.role === "error" ? "Error" : "CST-agent"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Text {
visible: !card.complete
text: "streaming…"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
ToolButton {
implicitWidth: 26
implicitHeight: 22
visible: card.text.length > 0
ToolTip.visible: hovered
ToolTip.text: "Copy"
contentItem: ThemeIcon {
name: "edit-copy"
glyph: "⧉"
size: 14
color: parent.hovered ? Theme.text : Theme.textFaint
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: {
plain.text = card.text
plain.selectAll()
plain.copy()
}
}
}
// Hidden holder so "copy" always has the plain text, rtf or not.
TextEdit {
id: plain
visible: false
}
TextEdit {
id: streamText
visible: card.html.length === 0
Layout.fillWidth: true
text: card.text
readOnly: true
selectByMouse: true
wrapMode: TextEdit.Wrap
color: card.role === "error" ? Theme.err : Theme.text
font.pixelSize: Theme.bodySize
selectionColor: Theme.accentDim
}
Text {
visible: card.html.length > 0
Layout.fillWidth: true
text: card.html
textFormat: Text.RichText
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
onLinkActivated: function (link) { Qt.openUrlExternally(link) }
}
// What the prompt carried: pictures from the store, not their names.
ImageStrip {
Layout.fillWidth: true
visible: card.images.length > 0
text: card.images
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import QtQuick
import QtQuick.Layouts
// A one-line note from the engine (status text, a refusal, a hint).
RowLayout {
id: note
property string text: ""
property string level: "info" // info | warn | error
spacing: Theme.gap
Rectangle {
Layout.alignment: Qt.AlignTop
Layout.topMargin: 6
width: 5
height: 5
radius: 3
color: note.level === "error" ? Theme.err
: note.level === "warn" ? Theme.warn : Theme.textFaint
}
Text {
Layout.fillWidth: true
text: note.text
color: note.level === "error" ? Theme.err
: note.level === "warn" ? Theme.warn : Theme.textDim
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
textFormat: Text.PlainText
}
}
+106
View File
@@ -0,0 +1,106 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// One stored session in the sidebar: its name (a rename, else its first
// prompt), how much is in it and when it was last written, and a way to drop
// it. The open one is marked. A file of its own because the sidebar lists them
// through a Repeater -- an inline component would not see modelData there.
Rectangle {
id: srow
property var entry: null
readonly property bool open: entry !== null && entry.current === true
objectName: "sessionRow:" + (entry !== null ? entry.id : "")
implicitHeight: rowCol.implicitHeight + 8
radius: Theme.radiusSm
color: srow.open ? Theme.accentDim
: rowHover.hovered ? Theme.surfaceAlt : "transparent"
border.width: 1
border.color: srow.open ? Theme.accent : "transparent"
signal activated(string id)
signal removeRequested(string id)
// "3h ago"; the entries carry ms since the epoch.
function ago(ms) {
if (!ms)
return "never"
const s = Math.max(0, (Date.now() - ms) / 1000)
if (s < 60)
return "just now"
if (s < 3600)
return Math.floor(s / 60) + "m ago"
if (s < 86400)
return Math.floor(s / 3600) + "h ago"
return Math.floor(s / 86400) + "d ago"
}
HoverHandler { id: rowHover }
MouseArea {
anchors.fill: parent
onClicked: srow.activated(srow.entry.id)
}
RowLayout {
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 4
}
spacing: 2
ColumnLayout {
id: rowCol
Layout.fillWidth: true
Layout.leftMargin: 4
spacing: 0
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: srow.entry !== null && srow.entry.title.length ? srow.entry.title
: srow.entry !== null && srow.entry.firstUser.length ? srow.entry.firstUser
: "(no prompt yet)"
color: srow.open ? Theme.accentText : Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: {
if (srow.entry === null)
return ""
const n = srow.entry.messages
const when = srow.ago(srow.entry.mtime)
return n > 0 ? n + (n === 1 ? " message · " : " messages · ") + when
: when
}
color: srow.open ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
ToolButton {
objectName: "sessionDelete:" + (srow.entry !== null ? srow.entry.id : "")
implicitWidth: 26
implicitHeight: 26
ToolTip.visible: hovered
ToolTip.text: "Delete this session and its transcript"
contentItem: ThemeIcon {
name: "edit-delete"
glyph: "×"
size: 14
color: parent.hovered ? Theme.err
: srow.open ? Theme.accentTextDim : Theme.textFaint
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: srow.removeRequested(srow.entry.id)
}
}
}
+739
View File
@@ -0,0 +1,739 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Provider, model and key: what the engine needs before it can talk to a model
// at all. Everything here goes through AgentCore, which moves the engine's own
// config (its session stays where it is) and remembers the choice. A key is
// verified against the provider before it is saved and is never read back into
// this window -- the dialog only ever says where the key it will use comes
// from.
//
// Three things live under "Advanced options", because they are for the odd
// provider or the odd model: the provider's own entry (name, base URL, key
// variable, models -- the engine keeps those in <config>/models.json, which is
// how a provider the presets do not know is added or an existing one is
// pointed somewhere else) and the generation parameters (temperature, top-p,
// max tokens), which are remembered per provider.
Dialog {
id: dlg
objectName: "settingsDialog"
parent: Overlay.overlay
anchors.centerIn: parent
modal: true
closePolicy: Popup.CloseOnEscape
padding: Theme.pad
property var providers: [] // AgentCore::providers()
property var choices: [] // AgentCore::modelChoices(picked)
property string picked: ""
property string model: ""
property string note: "" // what the last action answered
property string keyEnv: ""
property string keyFrom: ""
property bool hasKey: false
property bool advanced: false
property string modelFilter: "" // narrows a long model list
/* The advanced form: the picked provider's entry, editable. */
property string advName: ""
property string advBase: ""
property string advKeyEnv: ""
property string advModel: ""
property string advModels: ""
property string tempText: ""
property string topPText: ""
property string maxTokText: ""
signal applyRequested(string provider, string model, string key,
real temperature, real topP, int maxTokens)
width: Math.min(660, parent ? parent.width - 32 : 660)
height: Math.min(640, parent ? parent.height - 40 : 640)
/* The Basic style paints a light panel; this window is dark, so the
* dialog brings its own surface like every other one here. */
background: Rectangle {
color: Theme.surface
radius: Theme.radius
border.width: 1
border.color: Theme.border
}
// A long provider list (a router lists hundreds) is a wall of chips: the
// filter narrows it and the rest is one line ("+N more").
readonly property int chipCap: 40
readonly property var chips: {
const f = modelFilter.trim().toLowerCase()
const out = []
for (let i = 0; i < choices.length; i++) {
const c = String(choices[i])
if (!f.length || c.toLowerCase().indexOf(f) >= 0)
out.push(c)
}
return out
}
readonly property int chipsHidden: Math.max(0, chips.length - chipCap)
/* "0.2" -> 0.2; anything empty or unreadable -> 0 (the field is not sent,
* which is not the same as sending 0). */
function num(text) {
const v = parseFloat(String(text).replace(",", "."))
return isFinite(v) && v > 0 ? v : 0
}
function pickedEntry() {
for (let i = 0; i < providers.length; i++)
if (providers[i].name === dlg.picked)
return providers[i]
return null
}
/* Fill it in: the presets, the provider the engine is on now, and the
* model that provider last used. */
function load(list, provider, currentModel) {
providers = list
picked = provider
model = currentModel
note = ""
for (let i = 0; i < list.length; i++)
if (list[i].current === true)
picked = list[i].name
choices = picked.length ? agent.modelChoices(picked) : []
modelFilter = ""
syncPick()
}
/* Where the picked provider's key would come from and what it is set to.
* From the entries -- a key itself never reaches the QML side. */
function syncPick() {
hasKey = false
keyFrom = ""
keyEnv = ""
const p = pickedEntry()
if (p) {
hasKey = p.hasKey === true
keyFrom = p.keyFrom
keyEnv = p.keyEnv
/* The advanced form describes the picked provider: edit it, or
* type another name to add one. */
advName = p.name
advBase = p.base
advKeyEnv = p.keyEnv
advModel = p.model
advModels = p.custom === true ? agent.modelChoices(p.name).join(", ") : ""
}
const g = picked.length ? agent.genParams(picked) : null
tempText = g && g.temperature > 0 ? String(g.temperature) : ""
topPText = g && g.top_p > 0 ? String(g.top_p) : ""
maxTokText = g && g.max_tokens > 0 ? String(g.max_tokens) : ""
}
function keyState() {
if (hasKey)
return keyFrom === "saved" ? "a key is saved · paste another to replace it"
: "key from the environment: " + keyFrom
return keyEnv.length ? "no key yet — set " + keyEnv + ", or paste one here"
: "no key yet — paste one here"
}
function usageLine() {
const u = agent.usage()
if (!u.requests)
return "no tokens spent in this session yet"
let s = u.requests + (u.requests === 1 ? " request" : " requests") +
" · " + u.prompt + " in · " + u.out + " out"
if (u.priced)
s += " · " + u.cost.toFixed(4) + " " + u.currency
return s
}
/* Write the form's entry into the engine's user file and list it again. */
function saveProvider() {
/* The fields are the truth here, not the properties: a hand-typed
* edit is what the user wants saved. */
const name = advNameField.text.trim()
const err = agent.addProvider(name, advBaseField.text.trim(),
advKeyEnvField.text.trim(),
advModelField.text.trim(),
advModelsField.text)
if (err.length) {
note = err
return
}
providers = agent.providers()
picked = name
choices = agent.modelChoices(name)
syncPick()
note = "saved " + name + " to models.json — it is in the list above now"
}
function removeProvider() {
const typed = advNameField.text.trim()
const name = typed.length ? typed : picked
const rc = agent.removeProvider(name)
if (rc < 0) {
note = "could not remove " + name
return
}
providers = agent.providers()
note = rc === 0
? name + " is not in models.json (a preset of that name stays)"
: "removed " + name + " from models.json"
if (rc === 1 && providers.length > 0)
picked = providers[0].name
choices = picked.length ? agent.modelChoices(picked) : []
syncPick()
}
/* The form's fields follow the picked provider (and a user edit breaks
* the binding, so the push is imperative). */
onAdvNameChanged: if (advNameField && advNameField.text !== advName) advNameField.text = advName
onAdvBaseChanged: if (advBaseField && advBaseField.text !== advBase) advBaseField.text = advBase
onAdvKeyEnvChanged: if (advKeyEnvField && advKeyEnvField.text !== advKeyEnv) advKeyEnvField.text = advKeyEnv
onAdvModelChanged: if (advModelField && advModelField.text !== advModel) advModelField.text = advModel
onAdvModelsChanged: if (advModelsField && advModelsField.text !== advModels) advModelsField.text = advModels
onTempTextChanged: if (tempField && tempField.text !== tempText) tempField.text = tempText
onTopPTextChanged: if (topPField && topPField.text !== topPText) topPField.text = topPText
onMaxTokTextChanged: if (maxTokField && maxTokField.text !== maxTokText) maxTokField.text = maxTokText
// One labelled field of the advanced form.
component AdvField: ColumnLayout {
property string label: ""
property alias text: field.text
property alias placeholder: field.placeholderText
property int hintWidth: 0
Layout.fillWidth: true
spacing: 2
Text {
text: parent.label
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
TextField {
id: field
Layout.fillWidth: true
implicitHeight: Theme.touchTarget
selectByMouse: true
color: Theme.text
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: field.activeFocus ? Theme.accentDim : Theme.border
}
}
}
contentItem: ColumnLayout {
objectName: "settingsPanel"
spacing: Theme.gap
Text {
Layout.fillWidth: true
text: "Provider, model, key"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Text {
Layout.fillWidth: true
text: "Keys are stored under <config>/cst-agent/credentials (mode 0600) and are never shown again."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
/* Everything scrolls, so the buttons stay where they are and a long
* model list cannot push them out of the dialog. */
Flickable {
id: body
objectName: "settingsBody"
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 120
clip: true
contentWidth: width
contentHeight: bodyCol.implicitHeight
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {}
ColumnLayout {
id: bodyCol
width: parent.width
spacing: Theme.gap
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Repeater {
model: dlg.providers
delegate: Rectangle {
id: prow
required property var modelData
readonly property bool selected: dlg.picked === modelData.name
objectName: "providerRow:" + modelData.name
Layout.fillWidth: true
implicitHeight: prowCol.implicitHeight + 8
radius: Theme.radiusSm
color: prow.selected ? Theme.accentDim
: prowHover.hovered ? Theme.surfaceAlt
: "transparent"
border.width: 1
border.color: prow.selected ? Theme.accent : "transparent"
HoverHandler { id: prowHover }
MouseArea {
anchors.fill: parent
onClicked: {
dlg.picked = prow.modelData.name
modelField.text = prow.modelData.model
dlg.choices = agent.modelChoices(prow.modelData.name)
dlg.modelFilter = ""
dlg.syncPick()
}
}
ColumnLayout {
id: prowCol
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 4
}
spacing: 0
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
text: prow.modelData.name
color: prow.selected ? Theme.accentText : Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: prow.selected
}
Text {
visible: prow.modelData.current === true
text: "in use"
color: prow.selected ? Theme.okOnAccent : Theme.ok
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
Text {
text: prow.modelData.custom === true
? "custom" : prow.modelData.source
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
elide: Text.ElideMiddle
text: prow.modelData.base + " · " +
(prow.modelData.hasKey === true
? (prow.modelData.keyFrom === "saved"
? "key saved"
: "key from " + prow.modelData.keyFrom)
: (prow.modelData.keyEnv.length
? "no key (" + prow.modelData.keyEnv + ")"
: "no key"))
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
}
}
}
Text {
Layout.fillWidth: true
text: "Model"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
TextField {
id: modelField
objectName: "settingsModel"
Layout.fillWidth: true
text: dlg.model
implicitHeight: Theme.touchTarget
selectByMouse: true
color: Theme.text
placeholderText: "model name"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: modelField.activeFocus ? Theme.accentDim
: Theme.border
}
}
Text {
Layout.preferredWidth: 90
elide: Text.ElideRight
text: modelField.text.length ? agent.modelInfo(modelField.text) : ""
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
/* A router can list hundreds of models: filter, cap, say how
* many are left instead of filling the dialog. */
RowLayout {
Layout.fillWidth: true
visible: dlg.choices.length > 12
spacing: Theme.gap
TextField {
id: filterField
objectName: "settingsFilter"
Layout.fillWidth: true
text: dlg.modelFilter
implicitHeight: Theme.segmentHeight
selectByMouse: true
color: Theme.text
placeholderText: "filter " + dlg.choices.length + " models"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText
onTextChanged: dlg.modelFilter = text
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: filterField.activeFocus ? Theme.accentDim
: Theme.border
}
}
Text {
Layout.preferredWidth: 110
elide: Text.ElideRight
text: dlg.chips.length + " shown"
+ (dlg.chipsHidden ? " · +" + dlg.chipsHidden + " more" : "")
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
Flow {
Layout.fillWidth: true
spacing: 6
visible: dlg.choices.length > 0
Repeater {
model: dlg.chips.slice(0, dlg.chipCap)
delegate: Rectangle {
id: chip
required property var modelData
readonly property string name: String(chip.modelData)
objectName: "modelChoice:" + chip.name
implicitWidth: chipText.implicitWidth + 14
implicitHeight: 26
radius: Theme.radiusSm
color: chipHover.hovered ? Theme.surfaceHi : "transparent"
border.width: 1
border.color: modelField.text === chip.name ? Theme.accent
: Theme.border
HoverHandler { id: chipHover }
Text {
id: chipText
anchors.centerIn: parent
text: chip.name
color: modelField.text === chip.name ? Theme.text
: Theme.textDim
font.pixelSize: Theme.smallSize
}
TapHandler { onTapped: modelField.text = chip.name }
}
}
}
TextField {
id: keyField
objectName: "settingsKey"
Layout.fillWidth: true
implicitHeight: Theme.touchTarget
echoMode: TextInput.Password
selectByMouse: true
color: Theme.text
placeholderText: "API key (leave empty to keep what is there)"
placeholderTextColor: Theme.textFaint
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
onAccepted: dlg.apply()
background: Rectangle {
radius: Theme.radiusSm
color: Theme.bg
border.width: 1
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
}
}
Text {
Layout.fillWidth: true
text: dlg.keyState()
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
/* --- advanced ------------------------------------------- */
ToolButton {
id: advToggle
objectName: "settingsAdvanced"
Layout.fillWidth: true
implicitHeight: Theme.segmentHeight
onClicked: dlg.advanced = !dlg.advanced
contentItem: RowLayout {
spacing: 6
ThemeIcon {
name: dlg.advanced ? "arrow-down" : "arrow-right"
glyph: dlg.advanced ? "▾" : "▸"
size: 14
color: Theme.textDim
}
Text {
text: "Advanced options"
color: advToggle.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Item { Layout.fillWidth: true }
Text {
text: "custom provider · model parameters"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
background: Rectangle {
radius: Theme.radiusSm
color: advToggle.hovered ? Theme.surfaceAlt : "transparent"
}
}
ColumnLayout {
Layout.fillWidth: true
visible: dlg.advanced
spacing: 6
Text {
Layout.fillWidth: true
text: "Provider entry — saved in <config>/cst-agent/models.json. " +
"A name the presets know is overridden, a new one is added."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: Theme.gap
rowSpacing: 4
AdvField {
id: advNameField
objectName: "advName"
label: "Name"
placeholder: "myllm"
Layout.preferredWidth: 180
}
AdvField {
id: advBaseField
objectName: "advBase"
label: "Base URL"
placeholder: "https://api.example.com/v1"
}
AdvField {
id: advKeyEnvField
objectName: "advKeyEnv"
label: "Key variable"
placeholder: "MYLLM_API_KEY"
}
AdvField {
id: advModelField
objectName: "advModel"
label: "Default model"
placeholder: "the model to start with"
}
AdvField {
id: advModelsField
objectName: "advModels"
label: "Models it offers (comma separated, optional)"
placeholder: "m1, m2"
Layout.columnSpan: 2
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Button {
id: saveProviderBtn
objectName: "settingsSaveProvider"
text: "Save provider"
implicitHeight: Theme.touchTarget
enabled: advNameField.text.trim().length > 0 &&
advBaseField.text.trim().length > 0
onClicked: dlg.saveProvider()
background: Rectangle {
radius: Theme.radiusSm
color: saveProviderBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: saveProviderBtn.text
color: saveProviderBtn.enabled ? Theme.textDim : Theme.textFaint
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
id: removeProviderBtn
objectName: "settingsRemoveProvider"
text: "Remove from models.json"
implicitHeight: Theme.touchTarget
readonly property var entry: dlg.pickedEntry()
enabled: entry !== null && entry.custom === true
ToolTip.visible: hovered
ToolTip.text: "Only the user's own entry goes: a built-in preset of the same name stays"
onClicked: dlg.removeProvider()
background: Rectangle {
radius: Theme.radiusSm
color: removeProviderBtn.hovered && removeProviderBtn.enabled
? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: removeProviderBtn.text
color: removeProviderBtn.enabled ? Theme.textDim
: Theme.textFaint
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
Layout.topMargin: 4
text: "Model parameters — remembered for " +
(dlg.picked.length ? dlg.picked : "this provider") +
", sent with every request. Empty = the provider's own default."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
AdvField {
id: tempField
objectName: "advTemperature"
label: "Temperature"
placeholder: "not sent"
Layout.preferredWidth: 130
}
AdvField {
id: topPField
objectName: "advTopP"
label: "Top-p"
placeholder: "not sent"
Layout.preferredWidth: 130
}
AdvField {
id: maxTokField
objectName: "advMaxTokens"
label: "Max tokens"
placeholder: "not sent"
Layout.preferredWidth: 150
}
Item { Layout.fillWidth: true }
}
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
objectName: "settingsNote"
Layout.fillWidth: true
text: dlg.note.length ? dlg.note : dlg.usageLine()
color: dlg.note.length ? Theme.warn : Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
}
Button {
id: closeBtn
objectName: "settingsClose"
text: "Close"
implicitHeight: Theme.touchTarget
onClicked: dlg.close()
background: Rectangle {
radius: Theme.radiusSm
color: closeBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
border.width: 1
border.color: Theme.border
}
contentItem: Text {
text: closeBtn.text
color: Theme.textDim
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Button {
id: applyBtn
objectName: "settingsApply"
text: "Use this"
implicitHeight: Theme.touchTarget
onClicked: dlg.apply()
background: Rectangle {
radius: Theme.radiusSm
color: applyBtn.hovered ? "#5cc0f0" : Theme.accent
}
contentItem: Text {
text: applyBtn.text
color: Theme.accentInk
font.pixelSize: Theme.bodySize
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
/* The model parameters belong to the provider that is picked, so they are
* applied first: useProvider() then takes over what was remembered for
* whichever provider it ends on. */
function apply() {
dlg.applyRequested(dlg.picked, modelField.text, keyField.text,
dlg.num(tempField.text), dlg.num(topPField.text),
Math.round(dlg.num(maxTokField.text)))
}
onOpened: {
keyField.text = ""
modelField.forceActiveFocus()
}
}
+345
View File
@@ -0,0 +1,345 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// What the engine is set to: where it runs, how it frames itself, when it
// asks, whether it is planning. Everything here writes straight into the
// engine's config (AgentCore), never into a copy the engine cannot see.
//
// The panel scrolls (a phone-sized window cannot show it all) and every
// control keeps a finger-sized hit area.
Rectangle {
id: side
property string workspace: ""
property string provider: ""
property string model: ""
property string mode: "coding"
property int approval: 1 // cst_approval_t
property bool planMode: false
property bool running: false
property string status: ""
property string todo: ""
property var sessions: [] // what the store holds (AgentCore::sessions)
property string sessionId: ""
signal modeRequested(string mode)
signal approvalRequested(int approval)
signal planModeRequested(bool on)
signal sessionRequested(string id)
signal sessionNewRequested()
signal sessionDeleteRequested(string id)
readonly property alias contentColumn: col
color: Theme.surface
border.width: 1
border.color: Theme.border
implicitHeight: col.implicitHeight + Theme.pad * 2
// One selectable row of a segmented control.
component Seg: Button {
id: seg
property bool current: false
flat: true
implicitHeight: Theme.segmentHeight
padding: 0
background: Rectangle {
radius: Theme.radiusSm
color: seg.current ? Theme.accentDim : (seg.hovered ? Theme.surfaceAlt : "transparent")
border.width: 1
border.color: seg.current ? Theme.accent : Theme.border
}
contentItem: Text {
text: seg.text
color: seg.current ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight + Theme.pad * 2
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {}
ColumnLayout {
id: col
x: Theme.pad
y: Theme.pad
width: parent.width - Theme.pad * 2
spacing: Theme.gapL
// --- where ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
spacing: Theme.gap
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
Text {
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Item { Layout.fillWidth: true }
Text {
visible: side.running
text: "turn running"
color: Theme.warn
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
text: side.workspace.length ? side.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
elide: Text.ElideMiddle
ToolTip.visible: wsHover.hovered
ToolTip.text: side.workspace
HoverHandler { id: wsHover }
}
Text {
Layout.fillWidth: true
visible: side.provider.length > 0 || side.model.length > 0
text: (side.provider.length ? side.provider : "?") +
(side.model.length ? " · " + side.model : "")
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
visible: side.sessionId.length > 0
text: "session " + side.sessionId
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideMiddle
}
Text {
Layout.fillWidth: true
visible: side.status.length > 0
text: side.status
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
// --- which conversation ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
text: "Sessions"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Item { Layout.fillWidth: true }
// Starting a session while a turn runs would move the
// engine out from under it: the whole block is off then.
ToolButton {
id: newSession
objectName: "newSessionButton"
implicitWidth: 68
implicitHeight: Theme.segmentHeight
enabled: !side.running
ToolTip.visible: hovered
ToolTip.text: "Start a new session"
contentItem: RowLayout {
spacing: 4
ThemeIcon {
name: "list-add"
glyph: "+"
size: 14
color: newSession.enabled
? (newSession.hovered ? Theme.text : Theme.textDim)
: Theme.textFaint
}
Text {
text: "new"
color: newSession.enabled
? (newSession.hovered ? Theme.text : Theme.textDim)
: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered && parent.enabled ? Theme.surfaceHi
: "transparent"
border.width: 1
border.color: Theme.border
}
onClicked: side.sessionNewRequested()
}
}
Repeater {
model: side.sessions
delegate: SessionRow {
Layout.fillWidth: true
entry: modelData
onActivated: function (id) { side.sessionRequested(id) }
onRemoveRequested: function (id) { side.sessionDeleteRequested(id) }
}
}
Text {
Layout.fillWidth: true
visible: side.sessions.length === 0
text: "nothing stored here yet"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- how it frames itself ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Mode"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
RowLayout {
Layout.fillWidth: true
spacing: 4
Seg {
objectName: "modeCoding"
Layout.fillWidth: true
text: "coding"
current: side.mode !== "general"
onClicked: side.modeRequested("coding")
}
Seg {
objectName: "modeGeneral"
Layout.fillWidth: true
text: "general"
current: side.mode === "general"
onClicked: side.modeRequested("general")
}
}
Text {
Layout.fillWidth: true
text: side.mode === "general" ? "documents, data, everyday files"
: "code framed: repos, builds, tests"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- when it asks ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Approval"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
RowLayout {
Layout.fillWidth: true
spacing: 4
Seg {
Layout.fillWidth: true
text: "ask"
current: side.approval === 0
onClicked: side.approvalRequested(0)
}
Seg {
Layout.fillWidth: true
text: "auto"
current: side.approval === 1
onClicked: side.approvalRequested(1)
}
Seg {
Layout.fillWidth: true
text: "yolo"
current: side.approval === 2
onClicked: side.approvalRequested(2)
}
}
Text {
Layout.fillWidth: true
text: side.approval === 0 ? "every tool asks first"
: side.approval === 2 ? "never asks — writes go through"
: "sandbox and rules decide, asks on doubt"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 2
spacing: Theme.gap
Switch {
id: planSwitch
objectName: "planSwitch"
checked: side.planMode
implicitHeight: Theme.touchTarget
onClicked: side.planModeRequested(checked)
/* The Basic style's indicator is a dark pill with a dark
* knob here, so it is drawn: thumb-sized, accent when
* on, clearly off when not. */
indicator: Rectangle {
implicitWidth: 44
implicitHeight: 22
x: planSwitch.leftPadding
y: parent.height / 2 - height / 2
radius: 11
color: planSwitch.checked ? Theme.accentDim : Theme.surfaceAlt
border.width: 1
border.color: planSwitch.checked ? Theme.accent : Theme.border
Rectangle {
width: 16
height: 16
y: 2
x: planSwitch.checked ? parent.width - width - 3 : 3
radius: 8
color: planSwitch.checked ? Theme.text : Theme.textDim
Behavior on x {
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
}
}
}
}
Text {
text: "Plan mode"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
visible: side.planMode
text: "read-only tools and a planning note"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- the plan the engine keeps ---
TodoPanel {
Layout.fillWidth: true
text: side.todo
}
Text {
Layout.fillWidth: true
Layout.topMargin: Theme.gap
text: "engine in-process · v" + Qt.application.version
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Dialog {
id: root
objectName: "taskControlDialog"
title: "Task control and jobs"
modal: true
standardButtons: Dialog.Close
width: Math.min(760, parent ? parent.width - 24 : 760)
height: Math.min(640, parent ? parent.height - 24 : 640)
property var state: ({})
function refresh() {
try { state = JSON.parse(agent.taskState()) } catch (e) { state = ({}) }
}
onOpened: { refresh(); config.text = agent.workerConfig() }
Timer { interval: 500; repeat: true; running: root.visible; onTriggered: root.refresh() }
ColumnLayout {
anchors.fill: parent
TabBar {
id: tabs
Layout.fillWidth: true
TabButton { text: "Task / Jobs" }
TabButton { text: "Models" }
TabButton { text: "Integration" }
}
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: tabs.currentIndex
ColumnLayout {
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (root.state.goal || "No active goal") }
Label { text: "Revision " + (root.state.revision || 0) + " · " + (root.state.status || "idle") + " · " + (root.state.cancel_source || "") }
RowLayout {
CheckBox {
id: readonly
objectName: "taskReadOnly"
text: "Read only"
checked: !!root.state.read_only
onClicked: agent.taskControl(checked, !direct.checked, !!root.state.paused)
}
CheckBox {
id: direct
objectName: "taskMainOnly"
text: "Main agent only"
checked: root.state.delegation === false
onClicked: agent.taskControl(readonly.checked, !checked, !!root.state.paused)
}
Button { text: root.state.paused ? "Resume" : "Pause"; onClicked: agent.taskControl(readonly.checked, !direct.checked, !root.state.paused) }
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
width: parent.width
Repeater {
model: root.state.jobs || []
Frame {
required property var modelData
Layout.fillWidth: true
ColumnLayout {
anchors.fill: parent
Label { text: modelData.job_id + " · " + modelData.kind + " · " + modelData.status }
Label { Layout.fillWidth: true; text: modelData.resource || ""; elide: Text.ElideMiddle }
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (modelData.output || "").slice(-1000) }
RowLayout {
Button { text: "Cancel"; enabled: modelData.status === "running" || modelData.status === "queued"; onClicked: agent.cancelJob(modelData.job_id, false) }
Button { text: "Force stop"; visible: modelData.status === "running_protected"; onClicked: agent.cancelJob(modelData.job_id, true) }
Button { text: "Review delta"; visible: (modelData.resource || "").indexOf("/.agent/v2/wt-") >= 0; onClicked: { worktree.text = modelData.resource; tabs.currentIndex = 2 } }
}
}
}
}
}
}
}
ColumnLayout {
Label { text: "Role model profiles and budgets (provider credentials stay separate)"; wrapMode: Text.Wrap; Layout.fillWidth: true }
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: config; wrapMode: TextEdit.Wrap; selectByMouse: true } }
Label { id: configStatus; Layout.fillWidth: true; wrapMode: Text.Wrap }
Button { text: "Save model configuration"; enabled: !agent.running; onClicked: { const e = agent.saveWorkerConfig(config.text); configStatus.text = e.length ? e : "Saved" } }
}
ColumnLayout {
TextField { id: worktree; Layout.fillWidth: true; placeholderText: "Worker worktree path" }
RowLayout {
Button { text: "Preview"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, false) }
Button { text: "Integrate reviewed delta"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, true) }
}
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: delta; readOnly: true; wrapMode: TextEdit.Wrap; selectByMouse: true } }
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
// One place for the look: colors, spacing, fonts. The window, the cards and
// the dialogs all read from here, so re-theming (or swapping in Kirigami's
// Kirigami.Theme) is a single file.
pragma Singleton
import QtQuick
QtObject {
// Breeze-dark-ish (the desktop the app is built for), no hard contrast.
readonly property color bg: "#15181a"
readonly property color surface: "#1e2225"
readonly property color surfaceAlt: "#262b2f"
readonly property color surfaceHi: "#313840"
readonly property color border: "#343b41"
readonly property color text: "#eff0f1"
readonly property color textDim: "#9aa2a8"
/* Faint is still text: small print (hints, secondary lines, a selected
* row's detail) has to stay readable on both the background and the
* accent fill, so it is a step lighter than it looks in a mockup. */
readonly property color textFaint: "#8d959c"
readonly property color accent: "#3daee9"
readonly property color accentDim: "#1d6f95"
/* What is written *on* the accent fill (a selection, the open session).
* Named "accentText...", not "onAccent...": QML reads any `onFoo` as a
* signal handler and refuses the assignment. */
readonly property color accentText: "#ffffff"
readonly property color accentTextDim: "#dceefb"
/* The dark ink a filled button carries (accent, ok, err are all light). */
readonly property color accentInk: "#12161a"
readonly property color ok: "#27ae60"
readonly property color warn: "#f67400"
readonly property color err: "#da4453"
/* Green on the accent fill is mud: the "in use" mark needs its own ink. */
readonly property color okOnAccent: "#d8f5e3"
readonly property int radius: 6
readonly property int radiusSm: 3
readonly property int gap: 8
readonly property int gapL: 14
readonly property int pad: 12
readonly property int padL: 16
readonly property int bodySize: 14
readonly property int smallSize: 12
readonly property int monoSize: 13
readonly property string mono: "monospace"
readonly property int sidebarWidth: 268
readonly property int contentMax: 1100
// Layout: below the breakpoint the sidebar slides over the transcript
// instead of taking a column, and controls stay thumb-sized above it
// (a finger needs ~34-40 px, a mouse is happy with 26).
readonly property int compactBreakpoint: 880
readonly property int touchTarget: 34
readonly property int segmentHeight: 30
readonly property int headerHeight: 42
readonly property int jumpButton: 40
// Which Breeze icon stands for a tool: the card shows at a glance what
// kind of thing ran. Unknown names still get one (no glyph fallbacks for
// tools -- a missing icon just leaves the name alone).
function toolIcon(name) {
const n = String(name).toLowerCase()
if (n.indexOf("patch") >= 0 || n.indexOf("write") >= 0 || n.indexOf("edit") >= 0)
return "document-edit"
if (n.indexOf("read") >= 0 || n.indexOf("cat") >= 0)
return "text-x-generic"
if (n.indexOf("list_dir") >= 0 || n.indexOf("glob") >= 0 ||
n.indexOf("find") >= 0 || n.indexOf("dir") >= 0 || n.indexOf("file") >= 0)
return "folder-open"
if (n.indexOf("grep") >= 0 || n.indexOf("search") >= 0)
return "edit-find"
if (n.indexOf("bash") >= 0 || n.indexOf("shell") >= 0 ||
n.indexOf("command") >= 0 || n.indexOf("run") >= 0)
return "code-context"
if (n.indexOf("web") >= 0 || n.indexOf("http") >= 0 || n.indexOf("fetch") >= 0)
return "internet-services"
if (n.indexOf("todo") >= 0 || n.indexOf("plan") >= 0)
return "format-list-unordered"
if (n.indexOf("ask") >= 0 || n.indexOf("question") >= 0 || n.indexOf("notif") >= 0)
return "help-hint"
return "code-context"
}
// The tool output / diff line colors, used by ToolCard.
function lineColor(line) {
if (line.startsWith("@@"))
return accent
if (line.startsWith("+++") || line.startsWith("---"))
return textDim
if (line.startsWith("+"))
return ok
if (line.startsWith("-"))
return err
return text
}
}
+50
View File
@@ -0,0 +1,50 @@
import QtQuick
// One icon, from the desktop's icon theme (image://csttheme/, see
// app/src/ui/ThemeIcons.cpp), tinted with a color of the window's own.
//
// `glyph` is the last resort: a machine with no icon theme at all gets the
// unicode character the app used before, rather than an empty hole. Every
// place that shows an icon names its glyph, so nothing can go blank.
Item {
id: root
property string name: ""
property string glyph: ""
property color color: Theme.textDim
property int size: 16
implicitWidth: size
implicitHeight: size
/* "#rrggbb": a color typed to a string in QML carries alpha and a
* leading '#', which the provider's query would choke on. */
function hex(c) {
function two(v) {
const s = Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16)
return s.length < 2 ? "0" + s : s
}
return "#" + two(c.r) + two(c.g) + two(c.b)
}
Image {
id: img
anchors.fill: parent
source: root.name.length
? "image://csttheme/" + root.name + "?color=" + encodeURIComponent(root.hex(root.color))
: ""
sourceSize.width: root.size
sourceSize.height: root.size
fillMode: Image.PreserveAspectFit
smooth: true
visible: status === Image.Ready
}
Text {
anchors.fill: parent
visible: img.status === Image.Error && root.glyph.length > 0
text: root.glyph
color: root.color
font.pixelSize: Math.round(root.size * 0.85)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
+73
View File
@@ -0,0 +1,73 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The model's reasoning stream, collapsed by default: it is context, not the
// answer.
Rectangle {
id: block
property string text: ""
property bool complete: false
property bool expanded: false
radius: Theme.radius
color: Theme.surfaceAlt
border.width: 1
border.color: Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
opacity: 0.9
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
leftMargin: Theme.pad
rightMargin: Theme.pad
topMargin: Theme.pad / 2
}
spacing: 2
RowLayout {
spacing: Theme.gap
/* A handler, not a MouseArea: a layout decides the geometry of its
* children, and anchoring one of them is undefined behavior. */
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: block.expanded = !block.expanded
}
ThemeIcon {
name: block.expanded ? "arrow-down" : "arrow-right"
glyph: block.expanded ? "▾" : "▸"
size: Theme.smallSize + 2
color: Theme.textDim
}
Text {
text: block.complete ? "thinking" : "thinking…"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
visible: !block.expanded
text: block.text.replace(/\s+/g, " ").trim()
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
Layout.topMargin: 4
visible: block.expanded
text: block.text
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.italic: true
wrapMode: Text.Wrap
textFormat: Text.PlainText
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The engine's todo list (on_todo), rendered as a small checklist. The text
// is markdown-ish: "- [x] thing" lines.
ColumnLayout {
id: panel
property string text: ""
function entries() {
return text.split("\n")
.map(function (l) { return l.trim() })
.filter(function (l) { return l.length > 0 })
.map(function (l) {
const done = /^[-*]\s*\[[xX]\]/.test(l) || /^\[[xX]\]/.test(l)
const open = /^[-*]\s*\[\s*\]/.test(l) || /^\[\s*\]/.test(l)
return { done: done, todo: open, label: l.replace(/^[-*]?\s*\[[xX ]?\]\s*/, "").replace(/^[-*]\s*/, "") }
})
}
spacing: 6
Text {
text: "Plan"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Repeater {
model: panel.entries()
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
ThemeIcon {
Layout.alignment: Qt.AlignTop
name: modelData.done ? "task-complete" : "box"
glyph: modelData.done ? "✓" : "○"
size: Theme.smallSize + 2
color: modelData.done ? Theme.ok : Theme.textFaint
}
Text {
Layout.fillWidth: true
text: modelData.label
color: modelData.done ? Theme.textFaint : Theme.textDim
font.pixelSize: Theme.smallSize
font.strikeout: modelData.done
wrapMode: Text.Wrap
}
}
}
Text {
Layout.fillWidth: true
visible: panel.entries().length === 0
text: "no plan yet"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
+225
View File
@@ -0,0 +1,225 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// A tool call: what ran, whether it is still running, and what came back.
// Long output stays folded until asked for, diff lines are colored.
Rectangle {
id: card
property string name: ""
property string state: "pending" // pending | ready | running | done | denied
// | unfinished (replayed, no result)
property string detail: ""
property string output: ""
property int exitCode: 0
property bool hasResult: false
property bool expanded: false
property string images: ""
readonly property bool running: state === "pending" || state === "ready" || state === "running"
readonly property color stateColor: state === "denied" ? Theme.err
: state === "unfinished" ? Theme.textFaint
: running ? Theme.warn
: hasResult && exitCode !== 0 ? Theme.err
: Theme.ok
readonly property string stateText: state === "pending" ? "reading"
: state === "ready" ? "queued"
: state === "running" ? "running"
: state === "denied" ? "denied"
: state === "unfinished" ? "no result"
: hasResult && exitCode !== 0 ? "failed" : "done"
/* How long this call has been going. A tool that takes minutes -- a
* build, a wide search, a command nobody can predict -- must not read as
* a window that has hung: while the card runs, its chip counts. */
property double startedAt: 0
property int runningFor: 0
function elapsedText() {
const s = runningFor
if (s < 60)
return s + "s"
const m = Math.floor(s / 60)
if (m < 60)
return m + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)
return Math.floor(m / 60) + "h" + (m % 60 < 10 ? "0" : "") + (m % 60)
}
/* The clock starts when the card starts running -- and a card that is
* already running when it appears (a replayed transcript) starts one too,
* hence Component.onCompleted. */
function noteRunning() {
if (running && startedAt === 0)
startedAt = Date.now()
else if (!running)
startedAt = 0
}
onRunningChanged: noteRunning()
Component.onCompleted: noteRunning()
Timer {
interval: 1000
repeat: true
running: card.running && card.startedAt > 0
onTriggered: card.runningFor = Math.floor((Date.now() - card.startedAt) / 1000)
}
/* What the chip says: the state, how long it has been running (from five
* seconds on -- before that it is noise), or the exit code once there is
* a result. "no result" is not an outcome, so no exit code belongs on
* it. */
function chipText() {
if (running)
return runningFor >= 5
? stateText + " · " + elapsedText()
: stateText
return stateText + (hasResult && state !== "unfinished"
? " · exit " + exitCode : "")
}
function lines() {
const all = output.split("\n")
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
}
radius: Theme.radius
color: Theme.surfaceAlt
border.width: 1
border.color: expanded ? Theme.stateColor : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: 6
RowLayout {
spacing: Theme.gap
BusyIndicator {
visible: card.running
running: card.running
implicitWidth: 16
implicitHeight: 16
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
visible: !card.running
Layout.alignment: Qt.AlignVCenter
width: 6
height: 6
radius: 3
color: card.stateColor
}
/* What kind of tool this was, from the icon theme (Theme.toolIcon
* maps the engine's names onto Breeze ones). */
ThemeIcon {
Layout.alignment: Qt.AlignVCenter
name: Theme.toolIcon(card.name)
size: 16
color: Theme.textDim
}
Text {
text: card.name
color: Theme.text
font.family: Theme.mono
font.pixelSize: Theme.monoSize
font.bold: true
}
Rectangle {
Layout.alignment: Qt.AlignVCenter
radius: Theme.radiusSm
color: "transparent"
border.width: 1
border.color: card.stateColor
implicitWidth: chip.implicitWidth + 10
implicitHeight: chip.implicitHeight + 2
Text {
id: chip
objectName: "toolChip"
anchors.centerIn: parent
text: card.chipText()
color: card.stateColor
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: card.detail.replace(/\s+/g, " ").trim()
color: Theme.textFaint
font.family: Theme.mono
font.pixelSize: Theme.smallSize
}
ToolButton {
implicitWidth: 26
implicitHeight: 22
visible: card.output.length > 0
contentItem: ThemeIcon {
name: card.expanded ? "arrow-down" : "arrow-right"
glyph: card.expanded ? "▾" : "▸"
size: 14
color: parent.hovered ? Theme.text : Theme.textDim
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: card.expanded = !card.expanded
}
}
// What the tool made (view_image, an MCP image): shown as it is, with
// the output left folded -- the picture is the result.
ImageStrip {
Layout.fillWidth: true
visible: card.images.length > 0
text: card.images
}
// Folded view: the first line of the result, so a done card still says
// something without being opened.
Text {
Layout.fillWidth: true
visible: !card.expanded && card.output.length > 0
text: {
const l = card.output.split("\n")
return l[0] + (l.length > 1 ? " …" : "")
}
elide: Text.ElideRight
color: Theme.textFaint
font.family: Theme.mono
font.pixelSize: Theme.smallSize
}
ColumnLayout {
Layout.fillWidth: true
visible: card.expanded && card.output.length > 0
spacing: 0
Repeater {
model: card.expanded ? card.lines() : []
Text {
Layout.fillWidth: true
text: modelData.length ? modelData : " "
color: Theme.lineColor(modelData)
font.family: Theme.mono
font.pixelSize: Theme.monoSize
wrapMode: Text.NoWrap
textFormat: Text.PlainText
}
}
Text {
Layout.topMargin: 4
visible: card.output.split("\n").length > 500
text: "output truncated for the view"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
}
}
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
/* AgentCore: the Qt wrapper around one cst_agent_t.
*
* The engine runs turns on a worker thread; every cst_agent_hooks_t callback
* is turned into a Qt signal, and the two hooks that need an answer
* (on_permission, on_ask_user) block the worker until the GUI replies. No
* HTTP, no socket: the GUI owns the engine in its own process. */
#ifndef CST_APP_AGENTCORE_H
#define CST_APP_AGENTCORE_H
#include <QByteArray>
#include <QHash>
#include <QMutex>
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#include <QWaitCondition>
#include "cst_agent.h"
#include "cst_tools.h"
class QThread;
class AgentCore : public QObject {
Q_OBJECT
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged)
Q_PROPERTY(QString mode READ mode NOTIFY modeChanged)
Q_PROPERTY(QString provider READ provider NOTIFY configChanged)
Q_PROPERTY(QString model READ model NOTIFY configChanged)
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged)
public:
explicit AgentCore(QObject *parent = nullptr);
~AgentCore() override;
/* Start the engine on a workspace. provider/model/apiKey may be empty to
* fall back to the saved credentials and the provider's default model. */
bool open(const QString &workspace, const QString &provider,
const QString &model, const QString &apiKey, QString *err);
void close();
bool running() const { return m_running; }
QString workspace() const { return m_workspace; }
QString mode() const { return QString::fromUtf8(m_cfg.mode); }
QString provider() const { return QString::fromUtf8(m_cfg.provider); }
QString model() const { return QString::fromUtf8(m_cfg.model); }
int approval() const { return m_cfg.approval; }
bool planMode() const { return m_cfg.plan_mode != 0; }
QString todo() const { return m_todo; }
QString sessionId() const;
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
* message looks the same here as it does in the WebUI. */
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
/* --- sessions (the workspace's .agent/sessions store) ------------------- */
/* Stored sessions, newest first; one map each: id, title (a rename, else
* empty), firstUser, messages, mtime (ms since the epoch), current. */
Q_INVOKABLE QVariantList sessions() const;
/* The open session as transcript rows, in exactly the shape the live
* signals build (kind, role, text, name, state, detail, output, exitCode,
* complete, images), so a reopened session renders like the turn that wrote
* it. */
Q_INVOKABLE QVariantList history() const;
/* None of these run during a turn; all return false when one does. */
Q_INVOKABLE bool newSession();
Q_INVOKABLE bool switchSession(const QString &id);
Q_INVOKABLE bool deleteSession(const QString &id);
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
/* --- provider, model, key (see cst_provider_switch.h) ------------------- */
/* The presets: name, base, model (remembered, else the preset's), hasKey
* and keyFrom ("saved", else the environment variable), keyEnv, source and
* current, custom (the user's own provider, kept in the engine's user
* model file). Never a key: the engine keeps those out of the UI's reach. */
Q_INVOKABLE QVariantList providers() const;
/* A provider the engine's presets do not know: it goes into the user model
* file (<config>/cst-agent/models.json), which is where the registry keeps
* them, and the registry is reloaded, so the next providers() call lists
* it. `modelsCsv` is a comma-separated model list (may be empty).
* "" when it worked, else the reason. */
Q_INVOKABLE QString addProvider(const QString &name, const QString &base,
const QString &keyEnv,
const QString &defaultModel,
const QString &modelsCsv);
/* Drop a provider that came from that file (1 = it was there, 0 = it was
* not, -1 = with a reason). A preset of the same name stays. */
Q_INVOKABLE int removeProvider(const QString &name);
/* The generation parameters remembered for a provider (0 = not set, the
* field is not sent at all). */
Q_INVOKABLE QVariantMap genParams(const QString &provider) const;
/* Remember temperature / top-p / max tokens (0 = unset) for a provider and
* apply them to the engine when that provider is the live one.
* "" when it worked, else the reason. */
Q_INVOKABLE QString setGenParams(const QString &provider, double temperature,
double topP, int maxTokens);
/* Model candidates for a provider: its own list, what was fetched into the
* cache, and the remembered pick. No network. */
Q_INVOKABLE QStringList modelChoices(const QString &provider) const;
/* "1M · vision" for the model picker, "" when nothing is known. */
Q_INVOKABLE QString modelInfo(const QString &model) const;
/* Switch provider, model and key in one call. The engine's own config moves
* (its session does not), the choice is remembered, and an empty return
* means it worked; anything else is a reason to show. A key is verified
* against the provider's /models when one is given. */
Q_INVOKABLE QString useProvider(const QString &provider, const QString &model,
const QString &key);
/* Tokens of this session and of the last turn, plus the cost when priced. */
Q_INVOKABLE QVariantMap usage() const;
/* --- attachments (see cst_attach.h) ------------------------------------- */
Q_PROPERTY(QStringList pending READ pendingAttachments NOTIFY pendingChanged)
/* What the next prompt will carry: "<name> (<mime>, 12 KB)" per line. */
QStringList pendingAttachments() const;
/* Copy a file next to the session and give it to the next prompt. "" when
* it worked, else why not. */
Q_INVOKABLE QString attachFile(const QString &path);
/* The same for an image on the clipboard (Ctrl+V). "" when it worked; a
* reason -- "no image on the clipboard" -- when the editor should have the
* keystroke instead. */
Q_INVOKABLE QString attachClipboard();
/* Whether Ctrl+V should become an attachment at all (an image is on the
* clipboard) or stay with the editor (text). */
Q_INVOKABLE bool clipboardHasImage() const;
Q_INVOKABLE void clearPending();
/* The images among the pending attachments, in the engine's order, in the
* same text history() and toolImages() carry (see imagesText()): one line
* per picture, "<stored name>|<name>|<origin>|<w>x<h>". Empty for a PDF or
* a text file, and for a picture whose stored name the engine did not
* report. */
Q_INVOKABLE QString pendingImages() const;
/* Where the current session keeps its stored images: the directory
* image://cstattach/ serves (SessionImages.cpp). "" until the engine is
* open. It follows the session. */
Q_INVOKABLE QString attachmentDir() const;
/* A file card's two buttons: the stored file goes to the system -- a phone
* opens it with ACTION_VIEW or hands it to the share sheet, a desktop gives
* it to its own application or to the clipboard. "" when it went through
* (the header gets the line from statusChanged), else what to say. The name
* is the stored one the card carries; anything else is refused. */
Q_INVOKABLE QString openArtifact(const QString &rel);
Q_INVOKABLE QString shareArtifact(const QString &rel);
/* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
Q_INVOKABLE int seesImages() const;
/* The engine, for the parts that need it (tool registration). */
cst_agent_t *agent() const { return m_agent; }
const cst_agent_config_t *config() const { return &m_cfg; }
/* Ask a question; emits the streaming signals, then turnFinished(). */
Q_INVOKABLE void send(const QString &prompt);
Q_INVOKABLE void cancel();
Q_INVOKABLE QString taskState() const;
Q_INVOKABLE void taskControl(bool readOnly, bool delegation, bool paused);
Q_INVOKABLE void cancelJob(const QString &id, bool force);
Q_INVOKABLE QString workerConfig() const;
Q_INVOKABLE QString saveWorkerConfig(const QString &json);
Q_INVOKABLE QString worktreeDiff(const QString &path, bool integrate);
/* Consent: answers map to CST_PERM_*. */
Q_INVOKABLE void answerPermission(int choice);
Q_INVOKABLE void answerQuestion(const QString &answer);
Q_INVOKABLE void setMode(const QString &mode); /* coding | general */
Q_INVOKABLE void setApproval(int approval); /* cst_approval_t */
Q_INVOKABLE void setPlanMode(bool on);
/* Tools the front end implements itself. The engine copies the definition
* (strings included), so a stack value is fine; ud must outlive it.
* owner is what unregisterOwner() later matches. */
bool registerTool(const cst_tool_def_t *def);
void unregisterOwner(const QString &owner);
/* Re-probe platform tools after a service appeared or went away. */
void reloadToolMeta() { cst_tool_meta_reload(); }
/* Answer every consent question with "once" (the C smoke test, headless
* runs, a --yes flag). */
void setAutoApprove(bool on) { m_autoApprove = on; }
signals:
void configChanged();
void approvalChanged();
void planModeChanged();
void sessionChanged(); /* another session is open now */
void sessionListChanged(); /* the store gained, lost or renamed one */
void pendingChanged(); /* attachments for the next prompt changed */
void statusChanged(const QString &text);
void assistantDelta(const QString &text);
void thinkDelta(const QString &text);
/* A call is identified by its id: the same tool can run twice in one turn,
* and only the id tells the two cards apart. Text protocols carry ids too
* (cst_tool_call_from_native, the ctl parser), so this is never empty. */
void toolEvent(const QString &phase, const QString &id, const QString &name,
const QString &detail);
void toolResult(const QString &id, const QString &name, int exitCode,
const QString &output);
/* Images a tool produced (view_image, an MCP image), right after its
* result, as the text ImageStrip.qml decodes. */
void toolImages(const QString &callId, const QString &images);
/* The images the prompt that was just sent carries, in the same shape,
* fired before the turn's first delta. */
void promptImages(const QString &images);
void todoChanged(const QString &text);
void permissionRequested(const QString &tool, const QString &detail,
const QString &preview);
void questionAsked(const QString &question, const QString &options);
/* The worker asked and nobody answered in time (or the app was in the
* background, where the dialog cannot be seen): the turn went on with a
* refusal, and the window puts its dialog away again. */
void permissionExpired(const QString &tool);
void questionExpired();
void turnFinished(int rc, const QString &err);
void runningChanged();
void workspaceChanged();
void modeChanged();
private:
friend class Turn;
void finishTurn(int rc, const QString &err);
void setRunning(bool on);
/* Take the engine's config over m_cfg again (a switch changed it) and say
* so. */
void syncConfig();
/* Re-read what a session switch changed (id, todo) and say so. */
void afterSessionChange();
/* True when the app is not on screen: a phone in the background (Qt
* suspends it), or a window that is not there at all. A dialog opened now
* would be invisible, so consent is refused instead of waited for. */
bool backgrounded() const;
/* Hook trampolines (C, run on the worker thread). */
static void hookStatus(const char *msg, void *ud);
static void hookAssistant(const char *text, size_t len, void *ud);
static void hookThink(const char *text, size_t len, void *ud);
static void hookToolEvent(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud);
static void hookToolResult(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud);
static void hookToolImages(const ctl_tool_call_t *call,
const cst_attachment_t *list, int n, void *ud);
static void hookTodo(const char *text, void *ud);
static int hookPermission(const char *tool, const char *detail,
const char *preview, void *ud);
static int hookAskUser(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud);
/* Post a lambda onto the GUI thread. */
template <typename F>
void post(F f) {
QMetaObject::invokeMethod(this, f, Qt::QueuedConnection);
}
cst_agent_t *m_agent = nullptr;
cst_agent_config_t m_cfg;
cst_agent_hooks_t m_hooks;
QString m_workspace;
QThread *m_turn = nullptr;
bool m_running = false;
bool m_closing = false;
/* consent handshake between the worker and the GUI thread */
QMutex m_askMu;
QWaitCondition m_askCv;
int m_permAnswer = CST_PERM_DENY;
bool m_permPending = false;
QString m_question;
QString m_todo;
/* The session list rebuilt only when a transcript moved: filling one row
* walks the whole file, and the sidebar asks for the list on every store
* change. Keyed by id, dropped when the id is gone. */
struct SessionCache {
qint64 mtime = 0;
qint64 size = 0;
QVariantMap row;
};
mutable QHash<QString, SessionCache> m_sessionCache;
/* Names of the files this window attached, in the engine's pending order:
* the engine's own description says what it is, not where it came from. */
QStringList m_attachNames;
QByteArray m_answer;
bool m_questionPending = false;
bool m_autoApprove = false;
};
#endif /* CST_APP_AGENTCORE_H */
+333
View File
@@ -0,0 +1,333 @@
/* The window's own tools, in Qt rather than in the engine: a document the
* person asked for, laid out as a PDF.
*
* Why here and not in the C engine: laying text out on pages is exactly what
* Qt does (QTextDocument + QPdfWriter, both in QtGui, which the app already
* links), while the engine has no layout code at all -- and md_to_html, its
* sibling, lives in the engine because rendering markdown is a shared
* capability. This one is a front-end capability: it is registered under
* owner "app:qt" and is available on every platform the app runs on.
*
* The path of a document is the one md_to_html walks: markdown (or a
* workspace .md/.html file) -> cst_web_md_document() -> a file inside the
* workspace -> cst_attach_import_artifact(), which is what turns it into a
* file card the person can open or share. The bytes never reach the model.
*
* Pagination is ours on purpose: QPrinter/PrintSupport is a desktop-only
* module (a phone has no print subsystem), and a round of
* pageSize + translate + drawContents per page is all a paginated PDF needs.
* The tool runs on the turn's worker thread, so a long document does not
* freeze the window. */
#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMarginsF>
#include <QPageLayout>
#include <QPageSize>
#include <QPainter>
#include <QPdfWriter>
#include <QRectF>
#include <QString>
#include <QTextDocument>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_tools.h"
#include "cst_web_md.h"
namespace app {
namespace {
/* md_to_html caps its input the same way: a document nobody could read is
* not worth laying out. */
constexpr int kMaxInputBytes = 8 * 1024 * 1024;
/* 150 dpi: crisp enough for text, and a good deal lighter on a phone than the
* default 300 (a 40-page document is laid out and written in a moment). */
constexpr int kPdfDpi = 150;
const char *argOf(const ctl_tool_call_t *call, const char *key) {
for (int i = 0; i < call->nargs; i++)
if (strcmp(call->args[i].key, key) == 0)
return call->args[i].value;
return nullptr;
}
int setOut(cst_tool_result_t *r, const QString &text, int rc) {
const QByteArray b = text.toUtf8();
r->out = strdup(b.constData());
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = rc;
return rc;
}
/* "docs/notes.md" -> "notes". */
QString stemOf(const QString &path) {
QString base = path;
const int slash = qMax(base.lastIndexOf(QLatin1Char('/')),
base.lastIndexOf(QLatin1Char('\\')));
if (slash >= 0)
base = base.mid(slash + 1);
const int dot = base.lastIndexOf(QLatin1Char('.'));
if (dot > 0)
base = base.left(dot);
return base.isEmpty() ? QStringLiteral("document") : base;
}
/* What the file is called: `out` when given (a directory takes the source's
* name inside it), otherwise the source's stem, or the title. */
QString outPathOf(const ctl_tool_call_t *c, const QString &stem) {
const char *out = argOf(c, "out");
QString name = out && out[0] ? QString::fromUtf8(out) : QString();
if (name.isEmpty())
return stem + QStringLiteral(".pdf");
if (name.endsWith(QLatin1Char('/')) || name.endsWith(QLatin1Char('\\')))
return name + stem + QStringLiteral(".pdf");
if (!QFileInfo(name).fileName().contains(QLatin1Char('.')))
name += QStringLiteral(".pdf");
return name;
}
/* The HTML that goes into the page: an .html file is already a document, a
* .md file (or the `markdown` argument) goes through the engine's renderer,
* which is the same one the WebUI and md_to_html use. */
bool sourceHtml(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
QString *html, QString *out_name, QString *err) {
const char *md = argOf(c, "markdown");
const char *file = argOf(c, "file");
const char *given = argOf(c, "title");
QString title = given && given[0] ? QString::fromUtf8(given) : QString();
QByteArray text;
QString stem;
if (file && file[0]) {
char rel[4096] = "";
if (cst_sandbox_resolve(ctx->workspace_root, file, rel, sizeof(rel)) != 0) {
*err = QStringLiteral("error: path outside the workspace or missing "
"parent: %1")
.arg(QString::fromUtf8(file));
return false;
}
QFile in(QString::fromUtf8(rel));
if (!in.open(QIODevice::ReadOnly)) {
*err = QStringLiteral("error: cannot read %1")
.arg(QString::fromUtf8(file));
return false;
}
if (in.size() > kMaxInputBytes) {
*err = QStringLiteral("error: %1 is larger than %2 MB")
.arg(QString::fromUtf8(file))
.arg(kMaxInputBytes >> 20);
return false;
}
text = in.readAll();
stem = stemOf(QString::fromUtf8(file));
const QString lower = QString::fromUtf8(file).toLower();
if (lower.endsWith(QLatin1String(".html")) ||
lower.endsWith(QLatin1String(".htm"))) {
/* already a document: what md_to_html wrote, or anything else */
*html = QString::fromUtf8(text);
if (title.isEmpty())
title = stem;
*out_name = outPathOf(c, stem);
return true;
}
} else if (md && md[0]) {
text = QByteArray(md);
if (stem.isEmpty())
stem = title.isEmpty() ? QStringLiteral("document") : stemOf(title);
} else {
*err = QStringLiteral("error: give markdown, or a workspace .md or .html "
"file to render");
return false;
}
if (title.isEmpty())
title = stem;
char *doc =
cst_web_md_document(text.constData(), static_cast<size_t>(text.size()),
title.toUtf8().constData());
if (!doc) {
*err = QStringLiteral("error: cannot render the markdown");
return false;
}
*html = QString::fromUtf8(doc);
free(doc);
*out_name = outPathOf(c, stem);
return true;
}
/* Write `html` into `path` as an A4 PDF with 15 mm margins: one page rect,
* translated down per page, and the document is asked for exactly that much
* text at a time -- which is what makes Qt lay out pages instead of one long
* strip. */
bool writePdf(const QString &html, const QString &path, const QString &title,
QString *err) {
std::unique_ptr<QTextDocument> doc(paperDocument(html));
if (!title.isEmpty())
doc->setMetaInformation(QTextDocument::DocumentTitle, title);
QPdfWriter writer(path);
writer.setPageSize(QPageSize(QPageSize::A4));
writer.setPageMargins(QMarginsF(15, 15, 15, 15), QPageLayout::Millimeter);
writer.setResolution(kPdfDpi);
if (!title.isEmpty())
writer.setTitle(title);
QPainter painter(&writer);
if (!painter.isActive()) {
*err = QStringLiteral("error: cannot draw into %1").arg(path);
return false;
}
const QRectF page = writer.pageLayout().paintRectPixels(writer.resolution());
doc->setPageSize(page.size());
const int pages = qMax(1, doc->pageCount());
for (int i = 0; i < pages; i++) {
if (i)
writer.newPage();
painter.save();
painter.translate(page.topLeft());
painter.translate(0, -i * page.height());
doc->drawContents(&painter,
QRectF(0, 0, page.width(), page.height())
.translated(0, i * page.height()));
painter.restore();
}
painter.end();
/* The painter says nothing about the file: the only proof the PDF landed is
* that there is one with bytes in it. */
if (QFileInfo(path).size() <= 0) {
*err = QStringLiteral("error: nothing was written to %1").arg(path);
return false;
}
return true;
}
int mdToPdfPreview(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
char **out) {
(void)ctx;
const char *md = argOf(c, "markdown");
const char *file = argOf(c, "file");
if ((!md || !md[0]) && (!file || !file[0])) {
*out = strdup("error: give markdown, or a .md or .html file to render");
return -1;
}
const QString stem =
file && file[0] ? stemOf(QString::fromUtf8(file)) : QStringLiteral("document");
const QString name = outPathOf(c, stem);
QByteArray line;
if (file && file[0])
line = QStringLiteral("lay out %1 → %2 as a PDF\n")
.arg(QString::fromUtf8(file), name)
.toUtf8();
else
line = QStringLiteral("lay out %1 bytes of markdown → %2 as a PDF\n")
.arg(md ? qstrlen(md) : 0)
.arg(name)
.toUtf8();
*out = strdup(line.constData());
return 0;
}
int mdToPdfRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r, void *ud) {
(void)ud;
QString html, out_name, err;
if (!sourceHtml(ctx, c, &html, &out_name, &err))
return setOut(r, err, 1);
char full[4096] = "";
if (cst_sandbox_resolve(ctx->workspace_root, out_name.toUtf8().constData(),
full, sizeof(full)) != 0)
return setOut(r, QStringLiteral("error: path outside the workspace: %1")
.arg(out_name),
1);
const QString target = QString::fromUtf8(full);
/* A directory in the way of the name is what the engine's write tools call
* "missing parent": make it, the same way md_to_html does. */
QDir().mkpath(QFileInfo(target).absolutePath());
const char *given = argOf(c, "title");
const QString title = given && given[0] ? QString::fromUtf8(given) : QString();
if (!writePdf(html, target, title, &err))
return setOut(r, err, 1);
const qint64 bytes = QFileInfo(target).size();
const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : nullptr;
cst_attachment_t *a = nullptr;
char aerr[256] = "";
if (session && session[0])
a = cst_attach_import_artifact(session, full, nullptr, aerr, sizeof(aerr));
QString text = QStringLiteral("wrote %1 (%2 KB, A4, 15 mm margins)\n")
.arg(out_name)
.arg(bytes / 1024);
if (a)
text += QStringLiteral("(the user sees it as a file card they can open or "
"share; you will not be shown its bytes again)\n");
else if (aerr[0])
text += QStringLiteral("(the file is in the workspace; handing it over "
"failed: %1)\n")
.arg(QString::fromUtf8(aerr));
const int rc = setOut(r, text, 0);
if (a) {
r->attach = a;
r->nattach = 1;
}
return rc;
}
} // namespace
/* The document writePdf lays out, with a colour filled in for text that names
* none of its own.
*
* Paper is white and has no theme, but a QTextDocument takes its default text
* colour from the application palette: in a window running a dark palette --
* which is what a phone in dark mode hands Qt -- every plain markdown
* paragraph came out #eeeeee on a white page, i.e. a PDF that looks blank
* when printed, and reads as "the font is oddly faint" on screen. Naming the
* colour makes the page depend on the paper rather than on the window. A
* colour the document does name still wins (an .html file somebody handed us
* keeps its own), so this only fills the holes. */
QTextDocument *paperDocument(const QString &html) {
auto *doc = new QTextDocument;
doc->setDefaultStyleSheet(QStringLiteral("body { color: #000000; }"));
doc->setHtml(html);
return doc;
}
int registerQtTools(AgentCore *core) {
if (!core)
return 0;
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"markdown", "string", 0, "markdown text to render"},
{"file", "string", 0,
"workspace .md file to render, or an .html one to lay out as it is"},
{"out", "string", 0,
"output path in the workspace (default: the source's name + .pdf)"},
{"title", "string", 0, "document title (default: the file name)"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
static cst_tool_def_t def;
memset(&def, 0, sizeof(def));
setMeta(&def, "md_to_pdf",
"Write a document as a PDF: markdown (or a workspace .md/.html "
"file) in, a laid-out A4 PDF out, saved in the workspace and "
"handed to the person as a card they can open, print or share. "
"Use it when the answer should be something to keep or send.",
CST_TP_WRITE, "pdf", nullptr, nullptr);
memcpy(def.params, params, sizeof(params));
def.run_ex = mdToPdfRun;
def.preview = mdToPdfPreview;
def.owner = "app:qt";
return core->registerTool(&def) ? 1 : 0;
}
} // namespace app
+43
View File
@@ -0,0 +1,43 @@
#include "ToolProvider.h"
#include "AgentCore.h"
namespace app {
/* Keep these in one place: every platform tool of the front end uses one of
* them, so the engine's availability gate and the policy UI agree. */
const char *const kDesktopOnly[] = {"desktop", NULL};
const char *const kAndroidOnly[] = {"android", NULL};
int registerPlatformTools(AgentCore *core) {
if (!core)
return 0;
int n = 0;
#if defined(__ANDROID__)
n += platformRegisterAndroid(core);
#elif defined(__APPLE__)
n += platformRegisterMac(core);
#elif defined(_WIN32)
n += platformRegisterWindows(core);
#elif defined(__linux__)
n += platformRegisterLinux(core);
#endif
/* Qt is not a platform API: these exist wherever the window does. */
n += registerQtTools(core);
/* A service may have appeared or gone since the last probe (a desktop
* session starting, the notification daemon coming up). */
cst_tool_meta_reload();
return n;
}
void unregisterPlatformTools(AgentCore *core) {
if (!core)
return;
core->unregisterOwner(QStringLiteral("app:linux"));
core->unregisterOwner(QStringLiteral("app:windows"));
core->unregisterOwner(QStringLiteral("app:macos"));
core->unregisterOwner(QStringLiteral("app:android"));
core->unregisterOwner(QStringLiteral("app:qt"));
}
} // namespace app
+126
View File
@@ -0,0 +1,126 @@
/* How the front end hands a tool to the engine.
*
* A tool implemented in C++ is an ordinary cst_tool_def_t: the engine owns a
* copy of the definition, so a static (or stack) value is enough, and
* `ud`/`run_ex` keep pointing at the front end's state. The callback runs on
* the turn's worker thread -- touch Qt objects only through queued signals.
*
* See app/src/platform/linux/NotifyTool.cpp for a worked example, and
* tests/test_embed.c for the same contract without Qt.
*/
#ifndef CST_APP_TOOLPROVIDER_H
#define CST_APP_TOOLPROVIDER_H
#include <QString>
#include "cst_tools.h"
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
* travels through this header, so the class stays incomplete here. */
class AgentCore;
/* Same for the document md_to_pdf lays out (PdfTools.cpp): the caller owns
* it, and the test in ui/UiTest.cpp inspects how it draws. */
class QTextDocument;
namespace app {
/* Fill a definition in one place, so every front-end tool carries the same
* metadata (class + policy name + platform list + probe). */
inline void setMeta(cst_tool_def_t *def, const char *name, const char *desc,
cst_tool_perm_t perm, const char *perm_name,
const char *const *platforms,
int (*available)(char *, size_t)) {
def->name = name;
def->desc = desc;
def->perm = perm;
def->perm_name = perm_name;
def->platforms = platforms;
def->available = available;
}
/* Tools that only work on the phone. */
extern const char *const kAndroidOnly[];
/* Tools that only work on a desktop (linux / win / macos). */
extern const char *const kDesktopOnly[];
/* The platform tools this app provides. Called once the engine exists;
* returns how many were registered. */
int registerPlatformTools(AgentCore *core);
/* The tools this front end provides that are the same on every platform
* (they are Qt, not a platform API): today md_to_pdf, owned by "app:qt".
* Called next to registerPlatformTools(). */
int registerQtTools(AgentCore *core);
/* The document md_to_pdf lays out from `html`: the page's own defaults
* applied (text with no colour of its own is black, not the window's palette
* colour). The caller owns the result. */
QTextDocument *paperDocument(const QString &html);
/* Drop them again (they belong to the owners "app:*"). */
void unregisterPlatformTools(AgentCore *core);
/* One entry point per platform, implemented by the file under
* src/platform/<os>/; ToolProvider.cpp picks one at compile time. */
int platformRegisterLinux(AgentCore *core);
int platformRegisterMac(AgentCore *core);
int platformRegisterWindows(AgentCore *core);
int platformRegisterAndroid(AgentCore *core);
/* Hand a file of the session's store to the system: a phone opens it with
* ACTION_VIEW through the app's FileProvider, a desktop gives it to whatever
* the desktop uses. `title` is the name a person knows the file by (the
* stored copy is named by its id, which means nothing outside), used for the
* status line. Both return "" on success and a reason to show the user
* otherwise; *status (optional) gets the line to put in the header.
* CST_APP_NO_OPEN=1 makes both do nothing (--uitest drives the window without
* opening anything for real). */
QString openLocalFile(const QString &path, const QString &title, QString *status);
/* The same file through the share sheet where there is one (Android); a
* desktop has no share sheet, so the path goes to the clipboard. */
QString shareLocalFile(const QString &path, const QString &title,
QString *status);
/* Hold the machine while a turn runs, so it survives the app leaving the
* screen: a phone starts a foreground service with a partial wake lock (the
* process is one Android will not freeze or kill, and the CPU keeps going
* with the screen off) and shows a notification naming the work; a desktop
* does nothing -- the window *is* the app there. Returns "" when the hold is
* in place (or when the platform has nothing to do), a reason otherwise; the
* turn runs either way. `text` is what the phone's notification says. */
QString holdForeground(const QString &text);
/* Drop it again, at the end of the turn (a phone: the notification goes). */
void releaseForeground();
/* Turn what a file chooser handed over into a path the engine can open. A
* desktop picker gives a path and nothing happens; a phone's is a SAF
* content:// URI, which the app copies into its own directory first (the
* grant travels with the URI, so no permission is involved -- and without
* the copy the engine, which opens files by path, would fail with "cannot
* read"). Returns the path to read, or "" with *err filled. */
QString materializeAttachment(const QString &pathOrUri, QString *err);
/* Does this app have "all files access" (Android 11+)? A desktop always
* does. Without it the app still attaches whatever the picker handed it; what
* it cannot do is read shared storage by path. */
bool hasAllFilesAccess();
/* Ask for it, through the one screen Android offers (there is no runtime
* dialog for it). A desktop, a phone that already has it or one too old to
* know about it returns "" and nothing opens. */
QString requestAllFilesAccess();
/* Take the whole screen. A phone's status bar -- and a tablet's taskbar, which
* is a status bar with icons in it -- is drawn *over* the window, and the
* window here draws from its own first pixel, so the bars sit on the app's top
* row and the controls there cannot be touched. Hiding them is what "full
* screen" means on a phone; a desktop has no such bars to ask about and
* returns "". Asking is repeatable and cheap: the bars come back with a swipe
* from the edge or a switch to another app, so the front end asks again every
* time the window comes to the front. Returns "" once they are gone, a reason
* otherwise (which the front end logs). */
QString requestFullscreen();
/* The arguments the Android activity was started with (the
* "applicationArguments" intent extra): the way
* `cst-agent-app --uitest --workspace DIR` is reachable on a device, since
* Qt's own plumbing for that extra does not reach this build. Empty
* everywhere else, and empty on a device that was not started with any. */
QStringList activityArguments();
} // namespace app
#endif /* CST_APP_TOOLPROVIDER_H */
+378
View File
@@ -0,0 +1,378 @@
/* cst-agent-app: the Qt front end.
*
* cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k]
* cst-agent-app --size WxH window geometry (480x900, 1180x800, ...)
* cst-agent-app --selftest embedding checks, no window, no network
*
* The engine runs in this process (see app/README.md); nothing listens on a
* port and no second process is started. */
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
#include <QQuickWindow>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QTextStream>
#include <QUrl>
#if defined(Q_OS_ANDROID)
#include <QJniObject>
#include <QtCore/private/qandroidextras_p.h>
#endif
#include <cstdio>
#include <cstring>
#include <memory>
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
#include "ui/UiTest.h"
#include "ui/BackKey.h"
#include "ui/SessionImages.h"
#include "ui/ThemeIcons.h"
#include "cst_version.h"
#if defined(Q_OS_ANDROID)
/* Where an Android app is allowed to write: its own data directory. There is
* no usable $HOME, no writable "/" (QDir::currentPath()) and no /tmp, while
* the engine wants a config dir ($XDG_CONFIG_HOME/cst-agent, os_config_dir())
* and a scratch dir (the first writable entry of os_temp_dir()'s list). All
* of them are pointed here -- once, before the engine exists. */
QString androidRoot() {
const QString root =
QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
return root.isEmpty() ? QDir::tempPath() + QStringLiteral("/cst-agent")
: root;
}
#endif
namespace {
int runSelftest() {
QTextStream out(stdout);
int bad = 0;
QTemporaryDir tmp, cfg;
if (!tmp.isValid() || !cfg.isValid()) {
out << "selftest: cannot create a scratch dir\n";
return 1;
}
/* The window passes no --api-key, so the key the user saved for the provider
* has to come back from <config>/cst-agent/credentials -- the file the
* settings dialog writes. Planted in a scratch config dir, never in the
* real one. */
const QString cfgdir = cfg.path() + QStringLiteral("/cst-agent");
QDir().mkpath(cfgdir);
QFile creds(cfgdir + QStringLiteral("/credentials"));
if (!creds.open(QIODevice::WriteOnly | QIODevice::Truncate) ||
creds.write("deepseek.api_key=selftest-saved\n") < 0) {
out << "selftest: cannot write a scratch credentials file\n";
return 1;
}
creds.close();
qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8());
AgentCore core;
QString err;
if (!core.open(tmp.path(), QStringLiteral("deepseek"), QString(), QString(),
&err)) {
out << "selftest: engine did not start: " << err << "\n";
return 1;
}
const cst_agent_config_t *live =
core.agent() ? cst_agent_config(core.agent()) : nullptr;
if (!live || strcmp(live->api_key, "selftest-saved") != 0) {
out << "selftest: the saved key did not reach the engine config\n";
bad++;
} else {
out << "selftest: the saved key is in the engine config\n";
}
if (!core.open(tmp.path(), QStringLiteral("deepseek"),
QStringLiteral("deepseek-flash"), QStringLiteral("test-key"),
&err)) {
out << "selftest: engine did not start: " << err << "\n";
return 1;
}
out << "selftest: engine started in-process\n";
if (core.agent() == nullptr) {
out << "selftest: no agent handle\n";
return 1;
}
/* the front end's own tools go through the engine's registry */
const int n = app::registerPlatformTools(&core);
out << "selftest: platform tools registered: " << n << "\n";
const cst_tool_def_t *t = cst_tool_find("notification_send");
if (!t) {
out << "selftest: notification_send is not in the registry\n";
bad++;
} else {
if (t->owner == nullptr || strncmp(t->owner, "app:", 4) != 0) {
out << "selftest: notification_send has no app owner\n";
bad++;
}
char why[160] = "";
const bool ok = cst_tool_available(t, why, sizeof(why)) == 0;
out << "selftest: notification_send available=" << (ok ? "yes" : "no")
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
<< "\n";
}
/* The engine's own deliver_file: the file a tool hands to the person. */
const cst_tool_def_t *dv = cst_tool_find("deliver_file");
if (!dv) {
out << "selftest: deliver_file is not in the registry\n";
bad++;
} else {
char why[160] = "";
const bool ok = cst_tool_available(dv, why, sizeof(why)) == 0;
out << "selftest: deliver_file available=" << (ok ? "yes" : "no")
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
<< "\n";
}
/* the mode comes from the engine, not from the UI */
core.setMode(QStringLiteral("general"));
if (core.mode() != QLatin1String("general")) {
out << "selftest: mode did not stick\n";
bad++;
}
app::unregisterPlatformTools(&core);
if (cst_tool_find("notification_send") != nullptr) {
out << "selftest: platform tools were not dropped\n";
bad++;
}
core.close();
out << (bad ? "selftest: FAILED\n" : "selftest: OK\n");
out.flush();
return bad ? 1 : 0;
}
} // namespace
int main(int argc, char **argv) {
/* --selftest stays headless: no window, no QML, no display needed. */
bool selftest = false;
for (int i = 1; i < argc; i++)
if (strcmp(argv[i], "--selftest") == 0)
selftest = true;
if (selftest) {
QCoreApplication app(argc, argv);
return runSelftest();
}
QGuiApplication app(argc, argv);
/* The window styles its own controls; the platform style only adds noise
* here (the KDE Breeze plugin logs a line per registered type and trips
* over the composer's TextArea). An explicit choice still wins. */
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
QQuickStyle::setStyle(QStringLiteral("Basic"));
QGuiApplication::setApplicationName(QStringLiteral("cst-agent-app"));
QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION));
QGuiApplication::setOrganizationName(QStringLiteral("cst-agent"));
QCommandLineParser parser;
parser.setApplicationDescription(
QStringLiteral("CST-agent desktop front end (engine in-process)"));
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption wsOpt({QStringLiteral("w"), QStringLiteral("workspace")},
QStringLiteral("workspace directory"),
QStringLiteral("dir"));
QCommandLineOption providerOpt(QStringLiteral("provider"),
QStringLiteral("provider name"),
QStringLiteral("name"));
QCommandLineOption modelOpt(QStringLiteral("model"),
QStringLiteral("model name"), QStringLiteral("name"));
QCommandLineOption planOpt(QStringLiteral("plan"),
QStringLiteral("start in plan mode"));
QCommandLineOption uitestOpt(
QStringLiteral("uitest"),
QStringLiteral("drive the window with synthesized mouse/touch input and exit"));
QCommandLineOption sizeOpt(QStringLiteral("size"),
QStringLiteral("initial window size, e.g. 480x900"),
QStringLiteral("WxH"));
/* A device run cannot be handed an environment (see activityArguments()), so
* what a desktop run asks for with CST_APP_SHOT=shot.png has to be an
* argument here. */
QCommandLineOption shotsOpt(
QStringLiteral("shots"),
QStringLiteral("--uitest: save a picture of the window at the interesting "
"points, in the workspace"));
parser.addOption(wsOpt);
parser.addOption(providerOpt);
parser.addOption(modelOpt);
parser.addOption(planOpt);
parser.addOption(uitestOpt);
parser.addOption(sizeOpt);
parser.addOption(shotsOpt);
/* What the process was started with, plus -- on a phone -- what the
* activity was started with: Qt's own applicationArguments plumbing does
* not deliver the extra on the NX789J (see activityArguments()), and
* without it there is no way to hand this window a workspace or a --uitest
* run on a device that has no keyboard. */
QStringList args = QCoreApplication::arguments();
#if defined(Q_OS_ANDROID)
args += app::activityArguments();
#endif
parser.process(args);
QString ws = parser.value(wsOpt);
if (ws.isEmpty()) {
#if defined(Q_OS_ANDROID)
ws = androidRoot() + QStringLiteral("/workspace");
#else
ws = QDir::currentPath();
#endif
}
ws = QFileInfo(ws).absoluteFilePath();
/* --uitest drives a scratch workspace when none was given: the session
* checks create and delete sessions, which is not something to do in
* whatever directory the app happened to be started from. */
std::unique_ptr<QTemporaryDir> scratch;
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
scratch.reset(new QTemporaryDir);
if (scratch->isValid()) {
ws = scratch->path();
/* settings.json and credentials live in <config>/cst-agent: point them
* at the scratch dir too, so a test run never rewrites the user's own
* provider choice or key. */
qputenv("XDG_CONFIG_HOME", scratch->path().toUtf8());
}
}
/* Before the window exists: maybeShot() reads this at the point a picture is
* wanted (the settings dialog, the end of the run). */
if (parser.isSet(shotsOpt))
qputenv("CST_APP_SHOT",
(ws + QStringLiteral("/uitest-shot.png")).toUtf8());
#if defined(Q_OS_ANDROID)
/* Before the engine is created: its settings, credentials and session store
* all resolve their paths from the environment. */
const QString root = androidRoot();
for (const QString &sub : {QStringLiteral("config"), QStringLiteral("tmp")})
QDir().mkpath(root + QLatin1Char('/') + sub);
qputenv("XDG_CONFIG_HOME", (root + QStringLiteral("/config")).toUtf8());
qputenv("TMPDIR", (root + QStringLiteral("/tmp")).toUtf8());
qputenv("HOME", root.toUtf8());
#endif
AgentCore core;
QString err;
if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt),
QString(), &err)) {
QTextStream(stderr) << "cannot start the engine: " << err << "\n";
return 1;
}
app::registerPlatformTools(&core); /* the app's own tools */
#if defined(Q_OS_ANDROID)
/* notification_send sits behind POST_NOTIFICATIONS from Android 13 on, and
* the tool is hidden while it is not granted (its probe says so): ask once,
* here, so the tool is there for the first turn rather than a prompt that
* shows up mid-answer. Denying it only costs the tool. */
if (QNativeInterface::QAndroidApplication::context().isValid() &&
QtAndroidPrivate::checkPermission(
QStringLiteral("android.permission.POST_NOTIFICATIONS"))
.result() != QtAndroidPrivate::Authorized) {
QtAndroidPrivate::requestPermission(
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
}
/* "All files access" is the other half of storage: it is what lets the
* engine read a path in shared storage (a file the user names, not one the
* picker hands over -- that one is copied into the app either way, see
* AttachFile.java). Android has no runtime dialog for it, only the Settings
* screen, so it is opened once (a marker file remembers) and never
* insisted on: without it everything still works, one picked file at a
* time. The line below is what logcat shows when a device behaves oddly. */
const bool allFiles = app::hasAllFilesAccess();
qInfo("storage: all files access = %s", allFiles ? "yes" : "no");
if (!allFiles) {
const QString marker = androidRoot() + QStringLiteral("/.all-files-asked");
if (!QFileInfo::exists(marker)) {
const QString why = app::requestAllFilesAccess();
if (!why.isEmpty())
qWarning("all files access: %s", qUtf8Printable(why));
QFile asked(marker);
if (asked.open(QIODevice::WriteOnly))
asked.write("1\n");
}
}
#endif
if (parser.isSet(planOpt))
core.setPlanMode(true);
/* --uitest: same window, driven by synthesized input (app/src/ui/UiTest.cpp). */
if (parser.isSet(uitestOpt))
return app::runUiTest(core, parser.value(sizeOpt));
/* The layout is responsive; --size is how it gets exercised without a
* different screen (see app/README.md). */
int winW = 0, winH = 0;
const QString size = parser.value(sizeOpt);
if (!size.isEmpty()) {
const int x = size.indexOf(QLatin1Char('x'));
bool okW = false, okH = false;
if (x > 0) {
winW = size.left(x).toInt(&okW);
winH = size.mid(x + 1).toInt(&okH);
}
if (!okW || !okH || winW < 320 || winH < 360) {
QTextStream(stderr) << "--size wants WxH (at least 320x360)\n";
return 2;
}
}
QQmlApplicationEngine engine;
/* image://csttheme/ plus the desktop's icon theme behind it. */
app::installThemeIcons(&engine);
/* image://cstattach/: the session's own store, so the pictures a tool made
* (view_image, an MCP image) and the ones the user attached are shown, not
* only named. The directory follows the session. */
app::SessionImages *images = app::installSessionImages(&engine);
images->setDir(core.attachmentDir());
QObject::connect(&core, &AgentCore::sessionChanged, images,
[images, &core] { images->setDir(core.attachmentDir()); });
engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core);
engine.rootContext()->setContextProperty(QStringLiteral("initialWidth"), winW);
engine.rootContext()->setContextProperty(QStringLiteral("initialHeight"), winH);
const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/Main.qml"));
QObject::connect(
&engine, &QQmlApplicationEngine::objectCreated, &app,
[url](QObject *obj, const QUrl &objUrl) {
if (!obj && url == objUrl)
QCoreApplication::exit(-1);
},
Qt::QueuedConnection);
engine.load(url);
if (engine.rootObjects().isEmpty())
return 1;
/* The phone's back key, one layer at a time (app/src/ui/BackKey.h). */
app::installBackKey(engine.rootObjects().value(0),
engine.rootObjects().value(0));
/* The system bars are drawn over the window (see requestFullscreen): ask for
* the whole screen, and ask again whenever the app comes back to the front,
* because a swipe from the edge or a switch to another app brings them back.
* A refusal is not fatal -- it is logged and the window keeps working with
* the bars over its top row. */
{
const QString why = app::requestFullscreen();
if (!why.isEmpty())
qWarning("fullscreen: %s", qUtf8Printable(why));
QQuickWindow *qwin =
qobject_cast<QQuickWindow *>(engine.rootObjects().value(0));
if (qwin)
QObject::connect(qwin, &QQuickWindow::sceneGraphInitialized, qwin,
[] { app::requestFullscreen(); });
QObject::connect(&app, &QGuiApplication::applicationStateChanged, &app,
[](Qt::ApplicationState state) {
if (state == Qt::ApplicationActive)
app::requestFullscreen();
});
}
return app.exec();
}
@@ -0,0 +1,988 @@
/* Platform tools on Android, implemented by the app: the same contract as
* app/src/platform/linux/NotifyTool.cpp, with Android APIs behind it.
*
* The engine only ever sees the capability: `platforms = kAndroidOnly` (the
* engine reports "android" when it was compiled with the NDK, see
* src/tools/registry.c: cst_platform_name()) plus an `available` probe decide
* whether a tool reaches the prompt and tools[] at all -- a Termux build on
* the same device reports "termux" and gets the android tools too, an APK
* never gets the desktop ones. `owner = "app:android"` is what
* unregisterPlatformTools() drops again.
*
* WHERE THE CODE RUNS: `run_ex` is called on the turn's worker thread, never
* on the GUI thread. JNI calls are fine from any thread (Qt attaches it),
* Qt GUI objects are not -- a tool that needs the widget tree
* has to hop back with a blocking queued call (onGuiThread() below).
*
* The clipboard tools keep the names of the engine's built-ins: those are
* gated to CST_PLAT_DESKTOP and the registry lets an app tool of the same
* name shadow the built-in (src/tools/registry.c: cst_tool_find()), so the
* model sees one capability either way. */
#include <QByteArray>
#include <QClipboard>
#include <QCoreApplication>
#include <QDir>
#include <QFileInfo>
#include <QFuture>
#include <QGuiApplication>
#include <QJniEnvironment>
#include <QJniObject>
#include <QMetaObject>
#include <QSemaphore>
#include <QStandardPaths>
#include <QString>
#include <QStringList>
#include <QThread>
#include <QtCore/private/qandroidextras_p.h>
#include <cstdio>
#include <cstring>
#include <memory>
#include <utility>
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
namespace app {
namespace {
/* --- small helpers ------------------------------------------------------- */
const char *argOf(const ctl_tool_call_t *call, const char *key) {
for (int i = 0; i < call->nargs; i++)
if (strcmp(call->args[i].key, key) == 0)
return call->args[i].value;
return nullptr;
}
void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
r->out = strdup(text.constData());
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = rc;
}
/* How long the worker is willing to wait for the GUI thread to pick its work
* up. The hop itself is always waited for to the end (the closure writes into
* the caller's stack), so what this bounds is the case that makes a turn look
* stuck: a GUI thread that is not getting to the event loop at all. */
constexpr int kGuiStallMs = 3000;
/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to
* run on the GUI thread; the turn's worker thread waits for it.
*
* The wait is bounded: a blocking queued call to a GUI thread that is busy
* (the session list reading transcripts used to do exactly that) would park
* the worker for as long as it lasts. A cheap ping first tells whether the
* GUI thread is there at all; only then is the real work handed over and
* waited for, which keeps the closure valid for its whole life. */
template <typename Fn>
bool onGuiThread(Fn &&fn) {
if (!qApp || QThread::currentThread() == qApp->thread()) {
fn();
return true;
}
auto pinged = std::make_shared<QSemaphore>(0);
QMetaObject::invokeMethod(qApp, [pinged] { pinged->release(); },
Qt::QueuedConnection);
if (!pinged->tryAcquire(1, kGuiStallMs))
return false; /* a GUI thread that is not answering: do not wait on it */
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
Qt::BlockingQueuedConnection);
return true;
}
jint sdkInt() {
static const jint sdk = QJniObject::getStaticField<jint>(
"android/os/Build$VERSION", "SDK_INT");
return sdk;
}
QJniObject appContext() {
return QNativeInterface::QAndroidApplication::context();
}
/* The strings the activity was started with. Qt is meant to hand the
* "applicationArguments" extra to QCoreApplication::arguments() by itself; on
* the NX789J it does not -- `am start ... --esa applicationArguments
* "--workspace,/sdcard/x"` left the window on its default workspace, and so
* did the same extra as a plain string. So the intent is asked directly, which
* is what makes `cst-agent-app --uitest --workspace DIR` (or any other
* argument) reachable on a device with no keyboard attached.
*
* Everything here degrades to "no arguments": an invalid object, a missing
* method, or a context that is not an activity all come back empty. */
QStringList intentArguments() {
QStringList out;
QJniObject activity = appContext();
if (!activity.isValid())
return out;
QJniObject intent =
activity.callObjectMethod("getIntent", "()Landroid/content/Intent;");
if (!intent.isValid())
return out;
const QJniObject key =
QJniObject::fromString(QStringLiteral("applicationArguments"));
/* Three shapes for the same extra, and which one arrives depends on who
* sent it: `am start --esa applicationArguments "--uitest,--workspace,/x"`
* stores a String[] (an empty launch, arguments and all, is what that cost
* us -- getStringArrayListExtra answers null for an array), a launcher or
* Qt's own loader uses putStringArrayListExtra, and `--es` puts one
* comma-separated string there. Try each, in that order. */
const QJniObject array = intent.callObjectMethod(
"getStringArrayExtra", "(Ljava/lang/String;)[Ljava/lang/String;",
key.object<jstring>());
if (array.isValid()) {
QJniEnvironment env;
const jobjectArray a = reinterpret_cast<jobjectArray>(array.object());
const jsize n = env->GetArrayLength(a);
for (jsize i = 0; i < n; i++) {
const jstring item =
reinterpret_cast<jstring>(env->GetObjectArrayElement(a, i));
if (!item)
continue;
const char *c = env->GetStringUTFChars(item, nullptr);
if (c) {
out << QString::fromUtf8(c);
env->ReleaseStringUTFChars(item, c);
}
env->DeleteLocalRef(item);
}
if (!out.isEmpty())
return out;
}
QJniObject list = intent.callObjectMethod(
"getStringArrayListExtra", "(Ljava/lang/String;)Ljava/util/ArrayList;",
key.object<jstring>());
if (list.isValid()) {
const jint n = list.callMethod<jint>("size", "()I");
for (jint i = 0; i < n; i++) {
QJniObject item =
list.callObjectMethod("get", "(I)Ljava/lang/Object;", i);
if (item.isValid())
out << item.toString();
}
if (!out.isEmpty())
return out;
}
const QJniObject one = intent.callObjectMethod(
"getStringExtra", "(Ljava/lang/String;)Ljava/lang/String;",
key.object<jstring>());
if (one.isValid()) {
const QStringList parts =
one.toString().split(QLatin1Char(','), Qt::SkipEmptyParts);
out << parts;
}
return out;
}
/* --- notification_send --------------------------------------------------- */
/* Android 13 (API 33) put notifications behind a runtime permission; before
* that the permission does not exist and they always work. main.cpp asks for
* it once at startup, so the probe is normally green on a device where the
* user said yes. */
int notifyProbe(char *why, size_t why_sz) {
if (sdkInt() < 33)
return 0;
const QFuture<QtAndroidPrivate::PermissionResult> f =
QtAndroidPrivate::checkPermission(
QStringLiteral("android.permission.POST_NOTIFICATIONS"));
if (f.result() == QtAndroidPrivate::Authorized)
return 0;
snprintf(why, why_sz,
"POST_NOTIFICATIONS is not granted (Android 13+ asks the user the "
"first time the app runs)");
return -1;
}
bool postNotification(const QString &title, const QString &message,
QString *err) {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
*err = QStringLiteral("no Android context");
return false;
}
QJniObject manager = ctx.callObjectMethod(
"getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;",
QJniObject::fromString(QStringLiteral("notification")).object<jstring>());
if (!manager.isValid()) {
*err = QStringLiteral("no NotificationManager");
return false;
}
const QString channelId = QStringLiteral("cst-agent");
if (sdkInt() >= 26) {
QJniObject channel(
"android/app/NotificationChannel",
"(Ljava/lang/String;Ljava/lang/CharSequence;I)V",
QJniObject::fromString(channelId).object<jstring>(),
QJniObject::fromString(QStringLiteral("CST-agent")).object<jstring>(),
static_cast<jint>(3) /* IMPORTANCE_DEFAULT */);
manager.callMethod<void>("createNotificationChannel",
"(Landroid/app/NotificationChannel;)V",
channel.object<jobject>());
}
/* The small icon. The R classes are not reachable from here, so the id is
* looked up by name, and build() throws IllegalArgumentException ("Invalid
* notification (no valid small icon)") when there is none at all: every
* candidate that can be there is tried -- the framework drawables first,
* then the launcher icon the APK itself carries (AGP puts it in mipmap, a
* hand made APK in drawable/icon). */
QJniObject res = ctx.callObjectMethod("getResources",
"()Landroid/content/res/Resources;");
QJniObject pkg = ctx.callObjectMethod("getPackageName",
"()Ljava/lang/String;");
jint icon = 0;
if (res.isValid()) {
auto idOf = [&res, &pkg](const char *name, const char *type,
const char *owner) {
return res.callMethod<jint>(
"getIdentifier", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
QJniObject::fromString(QString::fromLatin1(name)).object<jstring>(),
QJniObject::fromString(QString::fromLatin1(type)).object<jstring>(),
owner ? QJniObject::fromString(QString::fromLatin1(owner)).object<jstring>()
: pkg.object<jstring>());
};
for (const char *n : {"ic_dialog_info", "ic_popup_reminder",
"stat_notify_chat", "sym_def_app_icon"}) {
icon = idOf(n, "drawable", "android");
if (icon != 0)
break;
}
for (const char *n : {"ic_launcher", "icon", "app_icon", "qt_icon"}) {
if (icon == 0)
icon = idOf(n, "mipmap", nullptr);
if (icon == 0)
icon = idOf(n, "drawable", nullptr);
if (icon != 0)
break;
}
}
if (icon == 0) {
*err = QStringLiteral("no small icon: it is in neither the framework "
"resources nor the APK's own");
return false;
}
/* Tapping it opens the app again (the launch intent of the package). */
QJniObject pm = ctx.callObjectMethod(
"getPackageManager", "()Landroid/content/pm/PackageManager;");
/* The builder, the build and the notify happen in Java (Notifier.java):
* a Java exception is invisible to C++ once Qt's JNI wrapper has cleared
* it, so on the phone this only ever said "failed, no exception detail"
* while the shade stayed empty. Java catches it and hands back the line to
* show. */
QJniObject launch;
if (pm.isValid() && pkg.isValid())
launch = pm.callObjectMethod(
"getLaunchIntentForPackage",
"(Ljava/lang/String;)Landroid/content/Intent;", pkg.object<jstring>());
QJniObject posted = QJniObject::callStaticObjectMethod(
"xyz/whcself/cstagent/Notifier", "post",
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;"
"Ljava/lang/String;ILandroid/content/Intent;I)Ljava/lang/String;",
ctx.object<jobject>(),
QJniObject::fromString(channelId).object<jstring>(),
QJniObject::fromString(title).object<jstring>(),
QJniObject::fromString(message).object<jstring>(), icon,
launch.isValid() ? launch.object<jobject>() : nullptr,
static_cast<jint>(1));
const QString java =
posted.isValid() ? posted.toString()
: QStringLiteral("the Notifier helper is not in this APK");
if (!java.isEmpty()) {
*err = QStringLiteral("cannot post the notification: %1").arg(java);
return false;
}
return true;
}
/* Runs on the turn's worker thread: no GUI objects here. */
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *message = argOf(call, "message");
const char *title = argOf(call, "title");
if (!message || !message[0]) {
setOut(r, QByteArrayLiteral("error: notification_send needs a message"), 1);
return 0;
}
const QString t = title && title[0] ? QString::fromUtf8(title)
: QStringLiteral("CST-agent");
const QString m = QString::fromUtf8(message);
QString err;
bool ok = false;
onGuiThread([&] { ok = postNotification(t, m, &err); });
if (ok)
setOut(r, QByteArrayLiteral("notification sent"), 0);
else
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
return 0;
}
/* --- clipboard_read / clipboard_write ----------------------------------- */
int clipboardProbe(char *why, size_t why_sz) {
if (QGuiApplication::clipboard())
return 0;
snprintf(why, why_sz, "no QClipboard (no GUI application)");
return -1;
}
int clipboardReadRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *cap = argOf(call, "max_bytes");
const long max =
cap && cap[0] ? strtol(cap, nullptr, 10) : 65536L;
QString text;
bool have = false;
onGuiThread([&] {
if (QClipboard *cb = QGuiApplication::clipboard()) {
text = cb->text();
have = true;
}
});
if (!have) {
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
return 0;
}
QByteArray body = text.toUtf8();
const bool cut = max > 0 && body.size() > max;
if (cut)
body.truncate(static_cast<int>(max));
QByteArray out =
QByteArrayLiteral("clipboard text (") + QByteArray::number(body.size()) +
QByteArrayLiteral(" bytes") +
(cut ? QByteArrayLiteral(", truncated") : QByteArray()) +
QByteArrayLiteral("):\n") + body + QByteArrayLiteral("\n");
setOut(r, out, 0);
return 0;
}
int clipboardWriteRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *text = argOf(call, "text");
if (!text) {
setOut(r, QByteArrayLiteral("error: clipboard_write needs text"), 1);
return 0;
}
const QString t = QString::fromUtf8(text);
bool ok = false;
onGuiThread([&] {
if (QClipboard *cb = QGuiApplication::clipboard()) {
cb->setText(t);
ok = true;
}
});
if (ok)
setOut(r, QByteArrayLiteral("clipboard: replaced with ") +
QByteArray::number(t.toUtf8().size()) +
QByteArrayLiteral(" bytes"),
0);
else
setOut(r, QByteArrayLiteral("error: no clipboard"), 1);
return 0;
}
/* --- share --------------------------------------------------------------- */
/* A path inside the workspace, resolved the way the engine's file tools do:
* relative to the workspace and never outside it. */
QString resolveInWorkspace(const cst_tool_ctx_t *ctx, const char *rel) {
if (!ctx || !ctx->workspace_root || !rel || !rel[0])
return QString();
const QString root = QDir::cleanPath(QString::fromUtf8(ctx->workspace_root));
const QString full = QDir::cleanPath(root + QLatin1Char('/') +
QString::fromUtf8(rel));
if (full != root && !full.startsWith(root + QLatin1Char('/')))
return QString();
return full;
}
QString mimeOf(const QString &path) {
const QString ext = QFileInfo(path).suffix().toLower();
if (ext == QLatin1String("png")) return QStringLiteral("image/png");
if (ext == QLatin1String("jpg") || ext == QLatin1String("jpeg"))
return QStringLiteral("image/jpeg");
if (ext == QLatin1String("webp")) return QStringLiteral("image/webp");
if (ext == QLatin1String("gif")) return QStringLiteral("image/gif");
if (ext == QLatin1String("pdf")) return QStringLiteral("application/pdf");
if (ext == QLatin1String("json")) return QStringLiteral("application/json");
if (ext == QLatin1String("txt") || ext == QLatin1String("md") ||
ext == QLatin1String("log"))
return QStringLiteral("text/plain");
return QStringLiteral("*/*");
}
/* ACTION_SEND through the system share sheet. `text` shares a sentence (the
* usual case: the user takes it to another app), `file` shares one file out of
* the workspace by way of the FileProvider Qt's manifest template declares
* (androidx.core.content.FileProvider, authority <package>.qtprovider). */
bool shareText(const QString &text, const QString &subject, QString *err) {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
*err = QStringLiteral("no Android context");
return false;
}
QJniObject send = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
send.object<jstring>());
intent.callObjectMethod("setType",
"(Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("text/plain"))
.object<jstring>());
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
.object<jstring>(),
QJniObject::fromString(text).object<jstring>());
if (!subject.isEmpty())
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.SUBJECT"))
.object<jstring>(),
QJniObject::fromString(subject).object<jstring>());
QJniObject chooser("android/content/Intent", "(Ljava/lang/String;)V",
QJniObject::fromString(QStringLiteral("android.intent.action.CHOOSER"))
.object<jstring>());
/* Intent.createChooser(Intent, CharSequence) -- the share sheet itself. */
QJniObject sheet = QJniObject::callStaticObjectMethod(
"android/content/Intent", "createChooser",
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
intent.object<jobject>(),
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
QJniObject toStart = sheet.isValid() ? sheet : intent;
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000) /* NEW_TASK */);
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
toStart.object<jobject>());
return true;
}
/* The content:// uri of a file inside the app's own data directory. The
* FileProvider and its paths xml come from Qt's manifest template
* (authority <package>.qtprovider, files-path "/"), which covers the whole
* workspace and the session store. */
QJniObject contentUriFor(const QString &path, QString *err) {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
*err = QStringLiteral("no Android context");
return QJniObject();
}
QJniObject pkg = ctx.callObjectMethod("getPackageName", "()Ljava/lang/String;");
const QString authority = pkg.toString() + QStringLiteral(".qtprovider");
QJniObject file("java/io/File", "(Ljava/lang/String;)V",
QJniObject::fromString(path).object<jstring>());
QJniObject uri = QJniObject::callStaticObjectMethod(
"androidx/core/content/FileProvider", "getUriForFile",
"(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;",
ctx.object<jobject>(),
QJniObject::fromString(authority).object<jstring>(),
file.object<jobject>());
if (!uri.isValid())
*err = QStringLiteral("the FileProvider did not hand out a uri for ") + path;
return uri;
}
bool shareFile(const QString &path, const QString &text, QString *err) {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
*err = QStringLiteral("no Android context");
return false;
}
QJniObject uri = contentUriFor(path, err);
if (!uri.isValid())
return false;
QJniObject action = QJniObject::fromString(QStringLiteral("android.intent.action.SEND"));
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
action.object<jstring>());
intent.callObjectMethod("setType",
"(Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(mimeOf(path)).object<jstring>());
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.STREAM"))
.object<jstring>(),
uri.object<jobject>());
if (!text.isEmpty())
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
.object<jstring>(),
QJniObject::fromString(text).object<jstring>());
intent.callObjectMethod(
"addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000 /* NEW_TASK */ | 0x00000001 /* GRANT_READ_URI_PERMISSION */));
QJniObject sheet = QJniObject::callStaticObjectMethod(
"android/content/Intent", "createChooser",
"(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
intent.object<jobject>(),
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
QJniObject toStart = sheet.isValid() ? sheet : intent;
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000));
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
toStart.object<jobject>());
return true;
}
int shareRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ud;
const char *text = argOf(call, "text");
const char *file = argOf(call, "file");
const char *subject = argOf(call, "subject");
if ((!text || !text[0]) && (!file || !file[0])) {
setOut(r, QByteArrayLiteral("error: share needs text or file"), 1);
return 0;
}
QString path;
if (file && file[0]) {
path = resolveInWorkspace(ctx, file);
if (path.isEmpty() || !QFileInfo::exists(path)) {
setOut(r, QByteArrayLiteral("error: ") + QByteArray(file) +
QByteArrayLiteral(" is not inside the workspace"),
1);
return 0;
}
}
const QString body = text ? QString::fromUtf8(text) : QString();
const QString subjectLine = subject ? QString::fromUtf8(subject) : QString();
QString err;
bool ok = false;
onGuiThread([&] {
ok = path.isEmpty() ? shareText(body, subjectLine, &err)
: shareFile(path, body, &err);
});
if (!ok) {
setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1);
return 0;
}
setOut(r, path.isEmpty()
? QByteArrayLiteral("the share sheet is open with the text")
: QByteArrayLiteral("the share sheet is open with ") +
path.toUtf8(),
0);
return 0;
}
int shareProbe(char *why, size_t why_sz) {
if (appContext().isValid())
return 0;
snprintf(why, why_sz, "no Android activity to share from");
return -1;
}
/* Registered once; the engine keeps a copy until the owner is unregistered. */
cst_tool_def_t g_notify;
cst_tool_def_t g_clip_read;
cst_tool_def_t g_clip_write;
cst_tool_def_t g_share;
} // namespace
/* What main() asks for (ToolProvider.h): the reading itself is the JNI helper
* above, next to the Android context it needs. It sits outside the anonymous
* namespace so main.cpp can link to it. */
QStringList activityArguments() { return intentArguments(); }
/* A file card's "open": whatever app the phone has for the type, with the read
* permission granted to the app that picks it up. */
QString openLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("opened %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
QString err;
bool ok = false;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
err = QStringLiteral("no Android context");
return;
}
QJniObject uri = contentUriFor(path, &err);
if (!uri.isValid())
return;
QJniObject action =
QJniObject::fromString(QStringLiteral("android.intent.action.VIEW"));
QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V",
action.object<jstring>());
intent.callObjectMethod(
"setDataAndType",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;",
uri.object<jobject>(),
QJniObject::fromString(mimeOf(path)).object<jstring>());
intent.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000 /* NEW_TASK */ |
0x00000001 /* GRANT_READ_URI */));
/* Nothing installed for the type: say so rather than fail silently. */
QJniObject pm = ctx.callObjectMethod(
"getPackageManager", "()Landroid/content/pm/PackageManager;");
QJniObject found =
pm.isValid()
? pm.callObjectMethod(
"resolveActivity",
"(Landroid/content/Intent;I)Landroid/content/pm/ResolveInfo;",
intent.object<jobject>(),
static_cast<jint>(0x00010000) /* MATCH_DEFAULT_ONLY */)
: QJniObject();
if (!found.isValid()) {
err = QStringLiteral("no app on the phone opens %1").arg(name);
return;
}
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
intent.object<jobject>());
ok = true;
});
if (!ok)
return err.isEmpty() ? QStringLiteral("cannot open %1").arg(name) : err;
if (status)
*status = QStringLiteral("opened %1").arg(name);
return QString();
}
/* The share sheet, the same way the share tool opens it. */
QString shareLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("shared %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
QString err;
bool ok = false;
onGuiThread([&] { ok = shareFile(path, QString(), &err); });
if (!ok)
return err.isEmpty() ? QStringLiteral("cannot share %1").arg(name) : err;
if (status)
*status = QStringLiteral("share sheet: %1").arg(name);
return QString();
}
/* An Intent aimed at one of our own classes, by name: the Java side is
* reached through an explicit component, so no other app can call it (and no
* intent filter has to exist). Empty action means "start". */
QJniObject serviceIntent(const QJniObject &ctx, const QString &action) {
QJniObject pkg = ctx.callObjectMethod("getPackageName",
"()Ljava/lang/String;");
if (!pkg.isValid())
return QJniObject();
QJniObject comp(
"android/content/ComponentName", "(Ljava/lang/String;Ljava/lang/String;)V",
pkg.object<jstring>(),
QJniObject::fromString(
QStringLiteral("xyz.whcself.cstagent.CstService"))
.object<jstring>());
if (!comp.isValid())
return QJniObject();
QJniObject intent("android/content/Intent");
intent.callObjectMethod(
"setComponent",
"(Landroid/content/ComponentName;)Landroid/content/Intent;",
comp.object<jobject>());
if (!action.isEmpty())
intent.callObjectMethod("setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(action).object<jstring>());
return intent;
}
/* A turn can outlive the screen, and a phone freezes or kills an app it
* cannot see: CstService (app/android/src/xyz/whcself/cstagent/CstService.java)
* is started as a foreground service for as long as one runs, and it takes the
* partial wake lock that keeps the CPU awake with the screen off. The explicit
* component means nothing outside the app can start it. */
QString holdForeground(const QString &text) {
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
return QString(); /* a test run leaves the phone alone */
QString err;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
err = QStringLiteral("no Android context");
return;
}
QJniObject intent = serviceIntent(ctx, QString());
if (!intent.isValid()) {
err = QStringLiteral("no ComponentName for CstService");
return;
}
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("text")).object<jstring>(),
QJniObject::fromString(text).object<jstring>());
QJniObject started =
sdkInt() >= 26
? ctx.callObjectMethod(
"startForegroundService",
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
intent.object<jobject>())
: ctx.callObjectMethod(
"startService",
"(Landroid/content/Intent;)Landroid/content/ComponentName;",
intent.object<jobject>());
if (!started.isValid())
err = QStringLiteral("the phone refused the foreground service");
});
return err;
}
void releaseForeground() {
if (qEnvironmentVariableIsSet("CST_APP_NO_SERVICE"))
return;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid())
return;
QJniObject intent = serviceIntent(
ctx, QStringLiteral("xyz.whcself.cstagent.STOP"));
if (intent.isValid())
ctx.callMethod<jboolean>("stopService",
"(Landroid/content/Intent;)Z",
intent.object<jobject>());
});
}
/* What the picker gave us -> a path the engine can open. A SAF URI is copied
* into the app's own cache (the URI's grant covers the read; no permission is
* involved), a plain path is left alone. */
QString materializeAttachment(const QString &pathOrUri, QString *err) {
if (err)
err->clear();
if (pathOrUri.isEmpty())
return QString();
if (!pathOrUri.startsWith(QLatin1String("content://"))) {
QString p = pathOrUri;
if (p.startsWith(QLatin1String("file://")))
p = p.mid(7);
return p;
}
const QString dest = QStandardPaths::writableLocation(
QStandardPaths::CacheLocation) +
QStringLiteral("/picked");
QString result, failure;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid()) {
failure = QStringLiteral("no Android context to copy the file with");
return;
}
QJniObject out = QJniObject::callStaticObjectMethod(
"xyz/whcself/cstagent/AttachFile", "materialize",
"(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)"
"Ljava/lang/String;",
ctx.object<jobject>(), QJniObject::fromString(pathOrUri).object<jstring>(),
QJniObject::fromString(dest).object<jstring>());
if (!out.isValid()) {
failure = QStringLiteral("the phone could not hand that file over");
return;
}
const QString s = out.toString();
if (s.startsWith(QLatin1String("error: ")))
failure = s.mid(7);
else if (!s.isEmpty())
result = s;
/* "" means it was a path all along; the caller keeps what it had */
});
if (!failure.isEmpty()) {
if (err)
*err = failure;
return QString();
}
return result.isEmpty() ? pathOrUri : result;
}
bool hasAllFilesAccess() {
bool granted = true;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid())
return;
granted = QJniObject::callStaticMethod<jboolean>(
"xyz/whcself/cstagent/AttachFile", "hasAllFilesAccess",
"(Landroid/content/Context;)Z", ctx.object<jobject>());
});
return granted;
}
QString requestAllFilesAccess() {
if (qEnvironmentVariableIsSet("CST_APP_NO_PERM"))
return QString(); /* a test run touches no settings */
QString err;
onGuiThread([&] {
QJniObject ctx = appContext();
if (!ctx.isValid())
return;
QJniObject out = QJniObject::callStaticObjectMethod(
"xyz/whcself/cstagent/AttachFile", "requestAllFilesAccess",
"(Landroid/content/Context;)Ljava/lang/String;",
ctx.object<jobject>());
if (out.isValid()) {
const QString s = out.toString();
if (!s.isEmpty())
err = s;
}
});
return err;
}
/* The status bar -- and on a tablet in desktop mode the taskbar, which is a
* status bar full of icons -- is drawn over the window, and this window draws
* from its own first pixel, so the bars land on the app's top row and the
* controls there cannot be touched. Which call hides them depends on the API
* level: WindowInsetsController from Android 11 (API 30) on, the older
* systemUiVisibility flags before it. Measured on the tablet: the app's own
* title row was under the taskbar, this puts the window on the whole screen
* and the row is touchable again.
*
* Nothing here is fatal: an invalid context, a missing class or a null insets
* controller comes back as a reason (which main.cpp logs) and the window keeps
* working with the bars over it. */
QString requestFullscreen() {
QString err;
onGuiThread([&] {
QJniObject activity = appContext();
if (!activity.isValid()) {
err = QStringLiteral("no activity");
return;
}
QJniObject window =
activity.callObjectMethod("getWindow", "()Landroid/view/Window;");
if (!window.isValid()) {
err = QStringLiteral("no window");
return;
}
if (sdkInt() >= 30) {
/* Edge to edge plus the bars hidden: the window is told to fill the
* screen itself rather than lay out inside the bars. */
window.callMethod<void>("setDecorFitsSystemWindows", "(Z)V", false);
QJniObject control = window.callObjectMethod(
"getInsetsController", "()Landroid/view/WindowInsetsController;");
if (!control.isValid()) {
err = QStringLiteral("no insets controller");
return;
}
const jint bars = QJniObject::callStaticMethod<jint>(
"android/view/WindowInsets$Type", "systemBars", "()I");
control.callMethod<void>("hide", "(I)V", bars);
/* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE: a swipe from the edge brings
* the bars back for a moment and they leave again by themselves. */
control.callMethod<void>("setSystemBarsBehavior", "(I)V", 2);
return;
}
QJniObject decor =
window.callObjectMethod("getDecorView", "()Landroid/view/View;");
if (!decor.isValid()) {
err = QStringLiteral("no decor view");
return;
}
/* SYSTEM_UI_FLAG_HIDE_NAVIGATION | FULLSCREEN | LAYOUT_STABLE |
* LAYOUT_HIDE_NAVIGATION | LAYOUT_FULLSCREEN | IMMERSIVE_STICKY */
const jint flags = 0x00000002 | 0x00000004 | 0x00000100 | 0x00000200 |
0x00000400 | 0x00001000;
decor.callMethod<void>("setSystemUiVisibility", "(I)V", flags);
});
return err;
}
int platformRegisterAndroid(AgentCore *core) {
if (!core)
return 0;
int n = 0;
{
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"message", "string", 1, "text of the notification"},
{"title", "string", 0, "title line (default: CST-agent)"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_notify, 0, sizeof(g_notify));
setMeta(&g_notify, "notification_send",
"Show a notification in the phone's shade (what the user sees "
"outside the app).",
CST_TP_ASK, "notify", kAndroidOnly, notifyProbe);
memcpy(g_notify.params, params, sizeof(params));
g_notify.run_ex = notifyRun;
g_notify.owner = "app:android";
n += core->registerTool(&g_notify) ? 1 : 0;
}
{
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"max_bytes", "integer", 0, "cap on the returned text (default 65536)"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_clip_read, 0, sizeof(g_clip_read));
setMeta(&g_clip_read, "clipboard_read",
"Read the text on the phone's clipboard — what the user copied "
"outside the workspace. Returns UTF-8 text.",
CST_TP_READ, "read", kAndroidOnly, clipboardProbe);
memcpy(g_clip_read.params, params, sizeof(params));
g_clip_read.run_ex = clipboardReadRun;
g_clip_read.owner = "app:android";
n += core->registerTool(&g_clip_read) ? 1 : 0;
}
{
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"text", "string", 1, "text to put on the clipboard"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_clip_write, 0, sizeof(g_clip_write));
setMeta(&g_clip_write, "clipboard_write",
"Replace the phone's clipboard with text, so the user can paste it "
"into another app. Overwrites what they had copied.",
CST_TP_WRITE, "write", kAndroidOnly, clipboardProbe);
memcpy(g_clip_write.params, params, sizeof(params));
g_clip_write.run_ex = clipboardWriteRun;
g_clip_write.owner = "app:android";
n += core->registerTool(&g_clip_write) ? 1 : 0;
}
{
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"text", "string", 0, "text to share"},
{"file", "string", 0, "workspace-relative file to share"},
{"subject", "string", 0, "subject line of the shared item"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_share, 0, sizeof(g_share));
setMeta(&g_share, "share",
"Hand text or a workspace file to the system share sheet, so the "
"user can send it to another app.",
CST_TP_ASK, "share", kAndroidOnly, shareProbe);
memcpy(g_share.params, params, sizeof(params));
g_share.run_ex = shareRun;
g_share.owner = "app:android";
n += core->registerTool(&g_share) ? 1 : 0;
}
return n;
}
} // namespace app
@@ -0,0 +1,184 @@
/* Platform tools on Linux, implemented by the app (not by the engine): they
* exist exactly where the desktop service does, and the engine only ever sees
* the capability -- platforms + probe decide whether the tool is offered. */
#include <QByteArray>
#include <QClipboard>
#include <QDesktopServices>
#include <QFileInfo>
#include <QGuiApplication>
#include <QProcess>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <cstdio>
#include <cstring>
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
namespace app {
namespace {
/* whereis-style check without a shell: the same rule the engine uses for the
* clipboard helpers. */
bool haveProgram(const char *name) {
QByteArray path = qgetenv("PATH");
const QList<QByteArray> dirs = path.split(':');
for (const QByteArray &d : dirs) {
QByteArray dir = d.isEmpty() ? QByteArray(".") : d;
QByteArray cand = dir + "/" + name;
if (QFileInfo::exists(QString::fromLocal8Bit(cand)) &&
QFileInfo(QString::fromLocal8Bit(cand)).isExecutable())
return true;
}
return false;
}
/* 0 when notifications can be shown; -1 with the reason otherwise. */
int notifyProbe(char *why, size_t why_sz) {
if (haveProgram("notify-send"))
return 0;
if (haveProgram("gdbus") && !qgetenv("DBUS_SESSION_BUS_ADDRESS").isEmpty())
return 0;
snprintf(why, why_sz,
"no notification service (no notify-send, no D-Bus session bus)");
return -1;
}
const char *argOf(const ctl_tool_call_t *call, const char *key) {
for (int i = 0; i < call->nargs; i++)
if (strcmp(call->args[i].key, key) == 0)
return call->args[i].value;
return nullptr;
}
/* Runs on the turn's worker thread: no GUI objects here. */
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *title = argOf(call, "title");
const char *message = argOf(call, "message");
if (!message || !message[0]) {
const char *msg = "error: notification_send needs a message";
r->out = strdup(msg);
r->out_len = r->out ? strlen(msg) : 0;
r->exit_code = 1;
return 0;
}
QStringList args;
if (title && title[0])
args << QString::fromUtf8(title);
else
args << QStringLiteral("CST-agent");
args << QString::fromUtf8(message);
const int rc = QProcess::execute(QStringLiteral("notify-send"), args);
const QByteArray text =
(rc == 0 ? QByteArrayLiteral("notification sent")
: QByteArrayLiteral("notify-send failed (rc=") +
QByteArray::number(rc) + QByteArrayLiteral(")"));
r->out = strdup(text.constData());
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = rc == 0 ? 0 : 1;
return 0;
}
/* Registered once; the engine keeps a copy until the owner is unregistered. */
cst_tool_def_t g_notify;
} // namespace
/* A file card's "open": xdg-open through QDesktopServices, so the desktop's
* own application for the type comes up. */
QString openLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("opened %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
return QStringLiteral("no application on this desktop opens %1").arg(name);
if (status)
*status = QStringLiteral("opened %1").arg(name);
return QString();
}
/* No share sheet here: the path is what another program (or another window)
* needs, so it goes to the clipboard. */
QString shareLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("shared %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
QGuiApplication::clipboard()->setText(path);
if (status)
*status = QStringLiteral("%1: the path is on the clipboard").arg(name);
return QString();
}
/* A desktop has nothing to hold: the window is the app, and a turn runs as
* long as it is open -- the machine does not freeze a process it can see.
* Kept here so the front end calls one API on every platform. */
QString holdForeground(const QString &text) {
(void)text;
return QString();
}
void releaseForeground() {}
/* A desktop picker hands over a path, so there is nothing to turn into one:
* a file:// URL loses its scheme and a content:// URI (a portal that never
* gave us a path) is refused with what the user should do instead. */
QString materializeAttachment(const QString &pathOrUri, QString *err) {
QString s = pathOrUri;
if (s.startsWith(QLatin1String("file://")))
s = s.mid(7);
if (s.startsWith(QLatin1String("content://"))) {
if (err)
*err = QStringLiteral("that picker handed over a content:// URI this "
"desktop cannot open; pick the file by path "
"instead");
return QString();
}
return s;
}
/* Every desktop process reads the files its user can. */
bool hasAllFilesAccess() { return true; }
QString requestAllFilesAccess() { return QString(); }
/* A desktop window is not drawn under anything of the desktop's own: the bars
* a panel draws are the panel's, and nothing here can be asked for them. */
QString requestFullscreen() { return QString(); }
int platformRegisterLinux(AgentCore *core) {
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"message", "string", 1, "text of the notification"},
{"title", "string", 0, "title line (default: CST-agent)"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_notify, 0, sizeof(g_notify));
setMeta(&g_notify, "notification_send",
"Show a desktop notification (what the user sees outside the app).",
CST_TP_ASK, "notify", kDesktopOnly, notifyProbe);
memcpy(g_notify.params, params, sizeof(params));
g_notify.run = nullptr;
g_notify.run_ex = notifyRun;
g_notify.ud = nullptr;
g_notify.owner = "app:linux";
return core->registerTool(&g_notify) ? 1 : 0;
}
} // namespace app
@@ -0,0 +1,196 @@
/* Platform tools on Windows, implemented by the app (not by the engine): they
* exist exactly where the desktop service does, and the engine only ever sees
* the capability -- platforms + probe decide whether the tool is offered.
*
* The Linux file next door is the same shape; what differs is the service
* behind each call. A notification is the scripting host's popup (Wscript.Shell
* through PowerShell) because Windows has no notify-send and a toast needs the
* WinRT API; it carries the same two arguments and answers the same way. */
#include <QByteArray>
#include <QClipboard>
#include <QDesktopServices>
#include <QFileInfo>
#include <QGuiApplication>
#include <QProcess>
#include <QString>
#include <QStringList>
#include <QUrl>
#include <cstdio>
#include <cstring>
#include "core/AgentCore.h"
#include "core/ToolProvider.h"
namespace app {
namespace {
/* whereis-style check without a shell: PATH is split on the Windows separator
* and a name may need one of the executable suffixes. */
bool haveProgram(const char *name) {
const QByteArray path = qgetenv("PATH");
const QList<QByteArray> dirs = path.split(';');
const QList<QByteArray> exts = {QByteArray(), QByteArray(".exe"),
QByteArray(".cmd"), QByteArray(".bat")};
for (const QByteArray &d : dirs) {
if (d.isEmpty())
continue;
for (const QByteArray &ext : exts) {
const QString cand = QFileInfo(QString::fromLocal8Bit(d + "\\" + name +
ext))
.absoluteFilePath();
if (QFileInfo::exists(cand) && QFileInfo(cand).isExecutable())
return true;
}
}
return false;
}
/* 0 when notifications can be shown; -1 with the reason otherwise. */
int notifyProbe(char *why, size_t why_sz) {
if (haveProgram("powershell") || haveProgram("pwsh"))
return 0;
snprintf(why, why_sz, "no powershell on PATH to show a popup with");
return -1;
}
const char *argOf(const ctl_tool_call_t *call, const char *key) {
for (int i = 0; i < call->nargs; i++)
if (strcmp(call->args[i].key, key) == 0)
return call->args[i].value;
return nullptr;
}
/* Runs on the turn's worker thread: no GUI objects here. The popup is started
* detached -- it waits for its own timeout, and the turn must not. */
int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)ud;
const char *title = argOf(call, "title");
const char *message = argOf(call, "message");
if (!message || !message[0]) {
const char *msg = "error: notification_send needs a message";
r->out = strdup(msg);
r->out_len = r->out ? strlen(msg) : 0;
r->exit_code = 1;
return 0;
}
const QString shown =
title && title[0] ? QString::fromUtf8(title) : QStringLiteral("CST-agent");
/* The popup's own text is single-quoted for PowerShell; a quote inside it is
* doubled, which is how PowerShell escapes one. */
QString text = QString::fromUtf8(message);
text.replace(QLatin1Char('\''), QLatin1String("''"));
QString heading = shown;
heading.replace(QLatin1Char('\''), QLatin1String("''"));
const QString script =
QStringLiteral("$w = New-Object -ComObject Wscript.Shell; "
"$w.Popup('%1', 8, '%2', 64) | Out-Null")
.arg(text, heading);
const QString exe = haveProgram("pwsh") ? QStringLiteral("pwsh")
: QStringLiteral("powershell");
QStringList args;
args << QStringLiteral("-NoProfile") << QStringLiteral("-NonInteractive")
<< QStringLiteral("-Command") << script;
const bool started = QProcess::startDetached(exe, args);
const QByteArray out = started
? QByteArrayLiteral("notification shown")
: QByteArrayLiteral("could not start ") +
exe.toLocal8Bit();
r->out = strdup(out.constData());
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = started ? 0 : 1;
return 0;
}
/* Registered once; the engine keeps a copy until the owner is unregistered. */
cst_tool_def_t g_notify;
} // namespace
/* A file card's "open": ShellExecute through QDesktopServices, so the file's
* own application comes up. */
QString openLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("opened %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
return QStringLiteral("no application on this machine opens %1").arg(name);
if (status)
*status = QStringLiteral("opened %1").arg(name);
return QString();
}
/* No share sheet here: the path is what another program needs, so it goes to
* the clipboard. */
QString shareLocalFile(const QString &path, const QString &title,
QString *status) {
const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title;
if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) {
if (status)
*status = QStringLiteral("shared %1 (test mode)").arg(name);
return QString();
}
if (!QFileInfo::exists(path))
return QStringLiteral("the file is gone: %1").arg(name);
QGuiApplication::clipboard()->setText(path);
if (status)
*status = QStringLiteral("%1: the path is on the clipboard").arg(name);
return QString();
}
/* Windows does not freeze a process whose window is open, and the app's own
* window is what keeps it alive: nothing to hold. */
QString holdForeground(const QString &text) {
(void)text;
return QString();
}
void releaseForeground() {}
/* A native file dialog hands over a path, so there is nothing to turn into
* one: a file:// URL loses its scheme and anything else is taken as a path. */
QString materializeAttachment(const QString &pathOrUri, QString *err) {
(void)err;
QString s = pathOrUri;
if (s.startsWith(QLatin1String("file://")))
s = s.mid(7);
return s;
}
/* Every process on Windows reads the files its user can. */
bool hasAllFilesAccess() { return true; }
QString requestAllFilesAccess() { return QString(); }
/* A Windows window has its own frame; there is no system bar drawn over it. */
QString requestFullscreen() { return QString(); }
int platformRegisterWindows(AgentCore *core) {
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"message", "string", 1, "text of the notification"},
{"title", "string", 0, "title line (default: CST-agent)"},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr},
{nullptr, nullptr, 0, nullptr}};
memset(&g_notify, 0, sizeof(g_notify));
setMeta(&g_notify, "notification_send",
"Show a notification outside the window (a popup that goes away by "
"itself).",
CST_TP_ASK, "notify", kDesktopOnly, notifyProbe);
memcpy(g_notify.params, params, sizeof(params));
g_notify.run = nullptr;
g_notify.run_ex = notifyRun;
g_notify.ud = nullptr;
g_notify.owner = "app:windows";
return core->registerTool(&g_notify) ? 1 : 0;
}
} // namespace app
+65
View File
@@ -0,0 +1,65 @@
#include "ui/BackKey.h"
#include <QEvent>
#include <QKeyEvent>
#include <QMetaObject>
#include <QObject>
#include <QVariant>
namespace app {
namespace {
class BackKeyFilter : public QObject {
public:
BackKeyFilter(QObject *window, QObject *root)
: QObject(window), m_window(window), m_root(root) {}
protected:
bool eventFilter(QObject *watched, QEvent *event) override {
if (!m_root || watched != m_window ||
(event->type() != QEvent::KeyPress &&
event->type() != QEvent::KeyRelease))
return QObject::eventFilter(watched, event);
QKeyEvent *key = static_cast<QKeyEvent *>(event);
if (key->key() != Qt::Key_Back)
return QObject::eventFilter(watched, event);
/* The key is down and up: a press that was taken is followed by a release
* that goes with it, so the platform never sees half of a key it did not
* get. */
if (event->type() == QEvent::KeyRelease)
return m_tookPress ? (m_tookPress = false, true)
: QObject::eventFilter(watched, event);
QVariant layer;
if (!QMetaObject::invokeMethod(m_root, "backLayer",
Q_RETURN_ARG(QVariant, layer)))
return QObject::eventFilter(watched, event);
if (layer.toString().isEmpty())
return QObject::eventFilter(watched, event); /* nothing open: not ours */
/* One layer back, on the next turn of the event loop (the dialogs insist,
* see Main.qml's stepBack). The key is spent: the window stays. */
QMetaObject::invokeMethod(m_root, "stepBack");
m_tookPress = true;
return true;
}
private:
QObject *m_window; /* whose keys are read; outlives this filter */
QObject *m_root; /* the window's QML root */
bool m_tookPress = false;
};
} // namespace
void installBackKey(QObject *window, QObject *root) {
if (!window || !root)
return;
/* On the application, not on the window: a key press arrives here as an
* event sent to the window, but a filter installed on the window itself
* never sees one (measured -- a window-installed filter stayed silent while
* the same key closed the drawer through a QML Shortcut). The filter keeps
* to `window`'s own events, so nothing else in the process is touched. */
QCoreApplication::instance()->installEventFilter(
new BackKeyFilter(window, root));
}
} // namespace app
+33
View File
@@ -0,0 +1,33 @@
/* The phone's back key, read before anything else sees the event.
*
* Android hands its back button to the window as a Qt::Key_Back key press, and
* the platform leaves the activity (the window goes away) when nothing takes
* the key. What takes it here is one layer at a time -- the dialog on top,
* then the sliding sidebar -- and only while there is a layer: the window's
* own QML answers which (Main.qml's backLayer()) and closes it (stepBack()).
*
* This lives in C++ rather than in a QML Shortcut because a window-level
* shortcut never sees the key while a modal popup is up: the dialog keeps it
* (a desktop run that presses Back with the settings dialog open shows the
* shortcut does not fire, while the drawer -- no popup -- closes fine). A
* filter on the window runs before the shortcut map, the popup and the focused
* item, so the same key does the same thing on every platform; with nothing
* open the key is passed on untouched, which is what leaves the window on a
* phone and does nothing on a desktop.
*
* A window must not be given a second one: installBackKey() installs once. */
#ifndef CST_APP_BACK_KEY_H
#define CST_APP_BACK_KEY_H
class QObject;
namespace app {
/* `window` is the QQuickWindow (the object key events are sent to), `root` the
* QML root object of that window (the one carrying backLayer()/stepBack()).
* The filter is owned by the window. */
void installBackKey(QObject *window, QObject *root);
} // namespace app
#endif // CST_APP_BACK_KEY_H
+81
View File
@@ -0,0 +1,81 @@
#include "ui/SessionImages.h"
#include <QFileInfo>
#include <QImageReader>
#include <QQmlApplicationEngine>
#include "cst_attach.h"
namespace app {
namespace {
/* "img-1a2b3c4d.png?w=160": the stored name, then what the view asked for.
* The size comes from the QML side as sourceSize, which Qt passes in as
* requestedSize; the query string is accepted too, so a plain URL can ask. */
QString relOf(const QString &id) {
const int q = id.indexOf(QLatin1Char('?'));
return q < 0 ? id : id.left(q);
}
} // namespace
SessionImages::SessionImages()
: QQuickImageProvider(QQuickImageProvider::Image) {}
void SessionImages::setDir(const QString &dir) {
QMutexLocker lock(&m_mu);
m_dir = dir;
}
QImage SessionImages::requestImage(const QString &id, QSize *size,
const QSize &requestedSize) {
const QString rel = relOf(id);
if (!cst_attach_rel_ok(rel.toUtf8().constData()))
return QImage(); /* QML sees Image.Error and falls back to the name */
QString dir;
{
QMutexLocker lock(&m_mu);
dir = m_dir;
}
if (dir.isEmpty())
return QImage();
const QString path = dir + QLatin1Char('/') + rel;
if (!QFileInfo::exists(path))
return QImage();
QImageReader reader(path);
reader.setAutoTransform(true);
const QSize full = reader.size();
if (!full.isValid()) {
/* No size in the header (or not a picture at all): hand back what Qt
* makes of it and let the view scale it. */
const QImage img = reader.read();
if (size)
*size = img.size();
return img;
}
/* Shrink to what the view shows, never up: a screenshot is decoded at the
* size of its thumbnail instead of at 4K. */
const bool smaller = requestedSize.isValid() && requestedSize.width() > 0 &&
requestedSize.height() > 0 &&
(full.width() > requestedSize.width() ||
full.height() > requestedSize.height());
if (smaller) {
const QSize scaled = full.scaled(requestedSize, Qt::KeepAspectRatio);
reader.setScaledSize(scaled);
if (size)
*size = scaled;
} else if (size) {
*size = full;
}
return reader.read();
}
SessionImages *installSessionImages(QQmlApplicationEngine *engine) {
SessionImages *images = new SessionImages;
if (engine)
engine->addImageProvider(QStringLiteral("cstattach"), images);
return images;
}
} // namespace app
+45
View File
@@ -0,0 +1,45 @@
/* The session's stored images as a QML image provider:
*
* image://cstattach/<stored name> (see cst_attach.h: "img-1a2b3c4d.png")
*
* The files live in the current session's attachment directory, which is what
* AgentCore::attachmentDir() names. Only that directory is served, and only
* names that pass the engine's own cst_attach_rel_ok() -- a transcript can
* carry hand-edited JSON, and nothing in it may reach outside the store.
*
* requestImage() runs on Quick's render thread while setDir() comes from the
* window's thread, hence the mutex. */
#ifndef CST_APP_SESSION_IMAGES_H
#define CST_APP_SESSION_IMAGES_H
#include <QImage>
#include <QMutex>
#include <QQuickImageProvider>
#include <QString>
class QQmlApplicationEngine;
namespace app {
class SessionImages : public QQuickImageProvider {
public:
SessionImages();
/* The directory holding the current session's images; "" disables serving. */
void setDir(const QString &dir);
QImage requestImage(const QString &id, QSize *size,
const QSize &requestedSize) override;
private:
mutable QMutex m_mu;
QString m_dir;
};
/* Registers image://cstattach/ on the engine and hands the provider back, so
* the caller can keep its directory in step with the agent's session. */
SessionImages *installSessionImages(QQmlApplicationEngine *engine);
} // namespace app
#endif // CST_APP_SESSION_IMAGES_H
+136
View File
@@ -0,0 +1,136 @@
#include "ui/ThemeIcons.h"
#include <QDir>
#include <QGuiApplication>
#include <QIcon>
#include <QPainter>
#include <QQmlApplicationEngine>
#include <QScreen>
#include <QStandardPaths>
#include <QStringList>
#include <QUrl>
namespace app {
namespace {
/* What a usable theme has to have; every window in the app needs it. */
const char kProbe[] = "mail-attachment";
/* The desktop's own theme first (whatever the platform theme set), then
* Breeze: this window is dark, so the dark variant, and Breeze proper as
* Qt's fallback for the odd name that only lives there. Before any of that
* the search paths have to be filled in -- Qt only builds them from XDG once
* a theme name was set, and a shell that was started outside the desktop
* session (nix-shell) has no XDG_DATA_DIRS pointing at the icon packages,
* hence the system profile. */
void adoptIconTheme() {
const QStringList known = QStandardPaths::standardLocations(
QStandardPaths::GenericDataLocation);
QStringList paths;
for (const QString &d : known)
paths << d + QStringLiteral("/icons");
paths << QDir::homePath() + QStringLiteral("/.icons");
const QString system = QStringLiteral("/run/current-system/sw/share/icons");
if (!paths.contains(system) && QDir(system).exists())
paths << system;
paths << QStringLiteral(":/icons");
QIcon::setThemeSearchPaths(paths);
QStringList candidates;
if (!QIcon::themeName().isEmpty())
candidates << QIcon::themeName();
candidates << QStringLiteral("breeze-dark") << QStringLiteral("breeze")
<< QStringLiteral("hicolor");
for (const QString &name : candidates) {
QIcon::setThemeName(name);
if (QIcon::hasThemeIcon(QLatin1String(kProbe)))
return;
}
}
/* The bundled set: app/icons/*.svg, compiled in as :/cst-icons/<name>.svg by
* app/CMakeLists.txt. A machine without an icon theme -- Android, where the
* Qt theme lookup finds nothing and QML was left showing its glyph fallback,
* which the phone fonts cannot draw -- gets the same icons from here. */
QIcon bundledIcon(const QString &name) {
return QIcon(QStringLiteral(":/cst-icons/") + name + QStringLiteral(".svg"));
}
QIcon iconForName(const QString &name) {
const QIcon themed = QIcon::fromTheme(name);
if (!themed.isNull())
return themed;
return bundledIcon(name);
}
/* "?color=%23eff0f1": the tint, and nothing else so far. */
QColor colorOption(const QString &id, QString *name) {
QColor color;
*name = id;
const int q = id.indexOf(QLatin1Char('?'));
if (q < 0)
return color;
*name = id.left(q);
const QStringList options = id.mid(q + 1).split(QLatin1Char('&'));
for (const QString &opt : options) {
if (!opt.startsWith(QLatin1String("color=")))
continue;
const QColor c(QUrl::fromPercentEncoding(opt.mid(6).toUtf8()));
if (c.isValid())
color = c;
}
return color;
}
qreal targetRatio() {
if (const QScreen *s = QGuiApplication::primaryScreen())
return qMax<qreal>(1.0, s->devicePixelRatio());
return 1.0;
}
} // namespace
ThemeIconProvider::ThemeIconProvider()
: QQuickImageProvider(QQuickImageProvider::Image) {}
QImage ThemeIconProvider::requestImage(const QString &id, QSize *size,
const QSize &requestedSize) {
QString name;
const QColor color = colorOption(id, &name);
if (name.isEmpty())
return QImage();
const QIcon icon = iconForName(name);
if (icon.isNull())
return QImage(); /* QML shows its fallback glyph */
const int px = requestedSize.isValid() && requestedSize.width() > 0
? requestedSize.width()
: 22;
const qreal ratio = targetRatio();
QPixmap pm = icon.pixmap(QSize(px, px), ratio, QIcon::Normal, QIcon::Off);
if (pm.isNull())
return QImage();
if (color.isValid()) {
QPainter p(&pm);
p.setCompositionMode(QPainter::CompositionMode_SourceIn);
p.fillRect(pm.rect(), color);
}
QImage img = pm.toImage();
img.setDevicePixelRatio(ratio);
if (size)
*size = QSize(px, px);
return img;
}
void installThemeIcons(QQmlApplicationEngine *engine) {
adoptIconTheme();
if (engine)
engine->addImageProvider(QStringLiteral("csttheme"), new ThemeIconProvider);
}
QString iconThemeName() { return QIcon::themeName(); }
bool themeIconAvailable(const QString &name) { return !iconForName(name).isNull(); }
} // namespace app
+47
View File
@@ -0,0 +1,47 @@
/* Icons from the desktop's icon theme (Breeze on KDE).
*
* The window did its icons with unicode glyphs (a clip emoji for attach, a
* gear, a hamburger): they render in whatever font happens to cover them --
* colour emoji in a grey toolbar -- and on a machine without that glyph they
* are a box. Every other desktop app asks the icon theme instead, and Qt
* Quick has no image provider for it without Kirigami, so this is one:
*
* image://csttheme/<icon-name>?color=%23rrggbb
*
* The icon is looked up with QIcon::fromTheme() in the desktop's theme
* (breeze-dark on a KDE desktop, Breeze as fallback), tinted to the asked-for
* color and rendered at the requested size. An unknown name yields a null
* image, which QML sees as Image.Error -- that is the hook for a glyph
* fallback, used on a machine with no icon theme at all. */
#ifndef CST_APP_THEMEICONS_H
#define CST_APP_THEMEICONS_H
#include <QImage>
#include <QQuickImageProvider>
#include <QString>
class QQmlApplicationEngine;
namespace app {
class ThemeIconProvider : public QQuickImageProvider {
public:
ThemeIconProvider();
QImage requestImage(const QString &id, QSize *size,
const QSize &requestedSize) override;
};
/* Points Qt at the desktop's icon theme and registers image://csttheme/.
* Call it on every engine that loads the window (main.cpp and --uitest). */
void installThemeIcons(QQmlApplicationEngine *engine);
/* The theme Qt was left on, for --selftest and the README. */
QString iconThemeName();
/* Whether the theme has this icon (what the fallback glyphs are for). */
bool themeIconAvailable(const QString &name);
} // namespace app
#endif /* CST_APP_THEMEICONS_H */
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
/* The --uitest entry point: see UiTest.cpp for what it drives. */
#ifndef CST_APP_UITEST_H
#define CST_APP_UITEST_H
#include <QString>
#include "core/AgentCore.h"
namespace app {
/* Loads the window, drives its controls with synthesized mouse/touch input
* (and a resize to the compact layout), prints one line per check and returns
* how many failed as the process exit code. `size` is the usual WxH string,
* empty for the default. */
int runUiTest(AgentCore &core, const QString &size);
} // namespace app
#endif /* CST_APP_UITEST_H */
+158
View File
@@ -0,0 +1,158 @@
/* The app's C smoke test: does the engine embed the way app/ needs it to?
*
* It links libcstcore.a (or the cstcore target) and runs without Qt, so the
* C side of the GUI stays verifiable on any machine -- including CI and the
* Android cross-build. `cst-agent-app --selftest` runs the same checks inside
* the real process. */
#define _POSIX_C_SOURCE 200809L
#include "cst_agent.h"
#include "cst_tools.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <process.h>
#include <direct.h>
#define getpid _getpid
#else
#include <sys/stat.h>
#include <unistd.h>
#endif
static int failures;
#define CHECK(cond, what) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, what); \
failures++; \
} \
} while (0)
/* A tool the app would implement (the Linux build uses notify-send; here it
* only has to prove the plumbing). */
static int ctx_runs;
static int app_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call,
cst_tool_result_t *r, void *ud) {
(void)ctx;
(void)call;
(void)ud;
ctx_runs++;
r->out = strdup("app tool ran");
r->out_len = r->out ? strlen(r->out) : 0;
r->exit_code = 0;
return 0;
}
static int probe_missing(char *why, size_t why_sz) {
snprintf(why, why_sz, "the desktop service is not running");
return -1;
}
int main(void) {
/* The scratch workspace goes where this host can actually write: /tmp on a
* desktop, the app's own directory on Android (where /tmp is off limits,
* so TMPDIR is pointed at it -- see app/src/main.cpp). */
char tmp[1024] = "";
if (os_temp_dir(tmp, sizeof(tmp)) != 0 || !tmp[0])
snprintf(tmp, sizeof(tmp), "%s", "/tmp");
char ws[1200];
snprintf(ws, sizeof(ws), "%s/cst-app-selftest-%d", tmp, (int)getpid());
#ifdef _WIN32
_mkdir(ws); /* the engine wants its workspace to exist */
#else
mkdir(ws, 0700);
#endif
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", ws);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", ws);
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
snprintf(cfg.api_base, sizeof(cfg.api_base), "https://api.deepseek.com/v1");
snprintf(cfg.api_key, sizeof(cfg.api_key), "test-key");
snprintf(cfg.mode, sizeof(cfg.mode), "general");
cfg.max_steps = 1;
cst_agent_t *a = cst_agent_create(&cfg);
CHECK(a != NULL, "the app can start an engine in its own process");
if (!a) {
fprintf(stderr, "cst-app-selftest: cannot start the engine\n");
return 1;
}
/* 1. a tool that exists here (no probe) is offered to the model */
cst_tool_def_t plain;
memset(&plain, 0, sizeof(plain));
plain.name = "app_ping";
plain.desc = "A tool the app provides.";
plain.perm = CST_TP_READ;
plain.run_ex = app_tool_run;
plain.owner = "app:selftest";
CHECK(cst_tool_register(&plain) == 0, "register an app tool");
const cst_tool_def_t *t = cst_tool_find("app_ping");
CHECK(t != NULL, "it is findable");
CHECK(t && cst_tool_active(cst_agent_config(a), t), "and it is offered");
ctl_tool_call_t call;
memset(&call, 0, sizeof(call));
snprintf(call.name, sizeof(call.name), "app_ping");
cst_tool_result_t res;
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = ws;
ctx.agent = a;
cst_tool_run(&ctx, &call, &res);
CHECK(ctx_runs == 1, "the app callback ran");
CHECK(res.out && strcmp(res.out, "app tool ran") == 0, "its result came back");
cst_tool_result_free(&res);
/* 2. a tool whose platform service is missing is never offered ... */
cst_tool_def_t missing;
memset(&missing, 0, sizeof(missing));
missing.name = "app_no_service";
missing.desc = "Needs a service that is not here.";
missing.perm = CST_TP_ASK;
missing.perm_name = "notify";
missing.available = probe_missing;
missing.run_ex = app_tool_run;
missing.owner = "app:selftest";
CHECK(cst_tool_register(&missing) == 0, "register a tool with a probe");
const cst_tool_def_t *mt = cst_tool_find("app_no_service");
char why[160] = "";
CHECK(mt && cst_tool_available(mt, why, sizeof(why)) != 0,
"the probe says it cannot run");
CHECK(strstr(why, "desktop service") != NULL, "and passes its reason on");
CHECK(mt && !cst_tool_active(cst_agent_config(a), mt),
"so it stays out of tools[] and the prompt");
snprintf(call.name, sizeof(call.name), "app_no_service");
cst_tool_run(&ctx, &call, &res);
CHECK(res.out && strstr(res.out, "cannot run here") != NULL,
"asking for it anyway is answered with the reason");
CHECK(ctx_runs == 1, "and its callback never ran");
cst_tool_result_free(&res);
/* 3. the app can drop its tools again */
CHECK(cst_tool_unregister_owner("app:selftest") == 2, "unregister both");
CHECK(cst_tool_find("app_ping") == NULL, "app_ping is gone");
CHECK(cst_tool_find("read_file") != NULL, "the built-ins are untouched");
cst_agent_destroy(a);
{
char cmd[1200];
snprintf(cmd, sizeof(cmd), "rm -rf %s", ws);
if (system(cmd) != 0)
fprintf(stderr, "note: could not clean %s\n", ws);
}
if (failures) {
fprintf(stderr, "cst-app-selftest: %d failure(s)\n", failures);
return 1;
}
printf("cst-app-selftest: OK\n");
return 0;
}