17 Commits
Author SHA1 Message Date
WHC 235652604d feat: add Kimi Code and web APIs 2026-10-19 10:41:00 +08:00
WHC 60047ca5ce the session readers use getline(3), which a Windows CRT does not have
Building the Windows TUI by hand -- the first local build since the CI file
went away -- stopped in src/tools/session_tools.c: `getline` was an implicit
declaration there, which this GCC treats as an error. src/session.c had a
private fgetc() shim for exactly that; the session-list scan is newer and used
the POSIX call directly, and only the Windows build notices.

The shim moves to os_compat as os_getline(): getline(3) itself on Unix, the
fgetc() loop on Windows, one prototype in the header so a caller reads the same
everywhere. src/session.c drops its copy and calls it; the scan does too (its
`ssize_t` becomes `long long`, which Windows has).

make check is green on Linux, the Windows cross build now finishes and
packages (dist/cst-agent-0.9.7-windows-x86_64.zip plus the NSIS setup), and the
static Linux tarball was rebuilt with it.
2026-10-11 21:54:32 +08:00
WHC b7034baec4 build: no CI; the packages are made here
The release files are built on this machine from now on, so the workflow that
did the same steps on a runner -- TinyCC build and unit tests, the static musl
Linux tarballs, the mingw Windows zip, the Gitea release upload -- is gone.
.gitea/ held nothing else.

Everything the workflow called stays and is runnable by hand exactly as it ran
there: scripts/ci_linux_toolchain.sh is what package_linux.sh uses to pick its
musl toolchain, scripts/ci_tcc.sh builds the second compiler the test job used,
scripts/ci_windows_cross.sh and scripts/bootstrap_windows.sh are the mingw
build, and scripts/release.sh is the upload (GITEA_TOKEN=... scripts/release.sh
FILE...).
2026-10-11 21:52:08 +08:00
WHC db8cdd7dd1 the back key: one C++ path, so a dialog closes too
A phone's back key did not close the settings dialog. A window-level QML
Shortcut never sees the key while a modal popup is up, and a desktop run says
so plainly: with the dialog open, Back left backLayer() at "settings", while
invokeMethod(win, "stepBack") closed it on the spot and Escape -- the popup's
own CloseOnEscape -- closed it too. So the key reaches the popup and never
reaches the shortcut map, which is why the drawer (no popup) closed fine and
hid the problem on a phone.

A Keys handler on the window's root item is no help either: nothing reached
it, the focused TextField keeps the key. So the key is read in C++, on the
application object but only for this window's events -- a filter installed on
the window itself was never called once, while the same key closed the drawer
through the shortcut, hence the application object.

With a layer open, the window's own backLayer() names it and stepBack() closes
it, press and release both taken; with nothing open the key is passed on
untouched, which is what leaves the window on a phone and does nothing on a
desktop. Main.qml keeps only those two functions: the Shortcut and
backHasLayer are gone, --uitest installs the same handler main() does, and the
back checks now drive the code the phone runs instead of a shortcut only the
test had.

The last check asks the phone's own question and can end the run without the
closing line -- the activity leaving takes the process with it, and every line
is flushed as it is written.

Desktop: 106 ok, 0 FAIL. Phone: 104 ok, 0 FAIL (two clipboard checks are
skipped on Android), and "after the last back: window visible 0".
2026-10-11 21:44:33 +08:00
WHC 81aeee7d1e uitest: scroll a card into view before clicking it
Open and Share failed on the phone for a reason that was in the harness,
not in the app: the card was where isVisible() says yes, but the transcript
clips it away -- the run's own note says it found contentY at -14 with the
card's row below the visible part -- so the click (and the tap after it)
landed on whatever was at that point instead, and the header stayed empty.

The run now stops the view following its tail (ChatView.stickBottom, which
the replay sets), scrolls the card's row into view with the numbers in a
note, and only then clicks. ChatView gets an objectName for that.

The checks also say what the card hands the engine (the name the store
keeps the file under, from the strip's own model) and what the card itself
answers -- which is what turned "it did nothing" into this answer:

  note the card is out of the viewport (contentY -14, card at 433 of 615):
       scrolling to it
  ok   Open hands the file to the system (opened report.md (test mode);
       cards hold 'img-...png, art-....md', on screen, card says '')
  ok   Share names the file (shared report.md (test mode); card says '')

The whole device run is green now: 97 ok, 0 FAIL (it was 88/17 when this
session's work started, and every one of those was real).
2026-10-11 21:14:54 +08:00
WHC a99f78134a uitest: no clipboard on Android, and say which event got through
The two clipboard-image checks failed on the phone for a reason that is not
a defect: Android's clipboard carries text and URIs, not images -- setImage
does nothing there -- so they are skipped on Android with a note saying so,
instead of reporting a phone that is broken because it is a phone.

Open and Share still fail on the device, and now say how far they got:
neither the synthesized mouse click nor a touch tap leaves a line on the
card's buttons, while the same click reaches the header and the sidebar.
The engine's own path is fine (openArtifact() called directly answers as it
should), and the only way for the header to stay empty is openArtifact()'s
early return for a name it cannot resolve -- so what is left to look at is
the name the card hands over (the model's `rel`) and whether the card is
inside the visible part of the scrolled transcript at that moment, since
QQuickItem::isVisible() does not account for clipping.

Device run after this: 95 ok, 2 FAIL (was 17 before this session's fixes).
2026-10-11 21:05:21 +08:00
WHC 8e5bdd0c9d the header is a Rectangle, and the phone puts it where it is
A Controls ToolBar hands its children to a style-owned contentItem, and on
the NX789J that item sat 35 px below the bar itself: the header was at y 0
h 42 while its own gear was at y 56 -- drawn on top of the sidebar's first
lines, and clicked there too, so the settings dialog could not be opened
from the gear at all (eleven checks that follow it failed with it). A
desktop never showed it: same style (Basic), same numbers, gear at y 21.

A plain Rectangle has no such indirection -- its children are where it is.
Nothing else changed; the background and the border line are the same.

--uitest prints what it sees at that point now (style, window, header,
gear, safe area), which is how this was found:

  note style Basic, window 1280x853, header y 0 h 42, gear centre y 21

Before: gear centre y 56, `FAIL the gear opens the settings dialog`, 17
failures on the device. After: 95 ok, 4 FAIL (Open/Share and the two
clipboard-image checks, which Android has no answer for).
2026-10-11 21:00:11 +08:00
WHC f8030ec201 a device run can be looked at: --shots, and Qt's messages in the log
The settings dialog check failed only on the phone, with no QML error and
nothing to read: this build's Qt messages never reach logcat (verified: a
plain qInfo from the app is not in the buffer). Two ways in, then:

- --uitest installs a message handler that writes Qt's own warnings into
  uitest.log, next to the checks. What the phone did say, it says there.
- --uitest --shots saves window grabs at the interesting points into the
  workspace. A device run cannot be handed CST_APP_SHOT (an app started
  from adb gets no environment), so what a desktop run asks for with an
  environment variable has to be an argument here. main.cpp takes it, so
  `--uitest --workspace DIR --shots` works from adb.

What they said about the phone (NX789J, 1280x853):

  ok ... the gear is reachable, centre 1191,56 of 1280x853
  ok ... and openSettings() opens it when called directly

so the button is there and the handler works: the synthesized click is what
does not open the dialog. The run's own picture of that moment shows why --
the header is drawn over the sidebar, so the click lands on what is drawn
above the gear. On a desktop the same window has the header above the
sidebar and the click lands. Not fixed yet (nor the Open/Share checks on the
device, which are the other two failures that are not about the dialog).

The settings checks now go on either way, so a phone run reports the dialog
instead of eleven lines of "not there".
2026-10-11 20:53:16 +08:00
WHC a34c74e453 the tool card counts, and a device run leaves a log
A tool that takes minutes -- a build, a wide search, a command nobody can
predict -- read as a window that had hung: the card said "running" and
nothing else, forever. The chip now counts from the point the card starts
running (from five seconds on, before that it is noise), which is where a
person looks when a call seems stuck; the status line and the Stop button
were already there. A card that appears already running (a replayed
transcript) starts its clock too.

--uitest: two checks for it (a card four minutes eighteen in says
"running · 4:18"; a finished one stops counting).

And the run now writes uitest.log in its workspace, every line and the
verdict. That is the only way to read a run *on a phone*: stdout reaches
nobody there, an app started from adb cannot be handed CST_APP_SHOT or any
other environment (so the shots a desktop run can take are out), and logcat
does not carry this build's Qt messages at all -- checked: a plain
`qInfo` from the app is not in the buffer.

First device run (NX789J, 105 checks): 88 ok, 17 FAIL, every failure
pre-existing and unrelated to this work -- the settings dialog does not
open from the gear (so nothing in it is reachable: the five dialog checks,
the two provider-form ones and the four clicks that follow), Open/Share
leave no status line on the device, and the clipboard-image checks have no
clipboard. All the md_to_pdf and card checks pass on the phone, including
"black ink on paper (darkest pixel 0)" from 4c69ef3.
2026-10-11 20:41:58 +08:00
WHC 9d7ad9433e android: the arguments arrive as a String[], not an ArrayList
The hook added in da04913 asked for getStringArrayListExtra, but `am
start --esa applicationArguments "--uitest,--workspace,/sdcard/x"`
stores a String[] -- that call answers null for an array, so the window
still started with no arguments at all. Ask for the array first, then
the list (Qt's own loader, putStringArrayListExtra), then the single
comma-separated string.

With this the uitest runs from adb on the NX789J: the fixtures and
selftest.pdf land in --workspace, which is how a device round trip is
possible without touch injection (adb cannot tap this window).
2026-10-11 20:35:41 +08:00
WHC 4c69ef39ab pdf: text that names no colour comes out black, not the palette
A QTextDocument takes its default text colour from the application
palette, and markdown names no colour at all: in a window running a dark
palette -- which is what a phone in dark mode hands Qt -- every plain
paragraph was laid out #eeeeee on a white page. Printed, that is a blank
document; on screen it reads as "the font is oddly faint". The first
PDFs off the phone were exactly that.

- PdfTools: paperDocument() sets the default stylesheet (body colour
  #000000) before setHtml, and writePdf() lays that out. A colour the
  document does name still wins, so an .html handed to the tool keeps its
  own -- checked with a #cc0000 document under both palettes.
- --uitest: draws that document on a white image under a dark palette and
  looks for ink (238 before the fix, 0 after).

Verified on the NX789J: md_to_pdf on the device now writes CID TrueType
NotoSansCJKHK-Regular/-Bold text whose page renders at min=0.
2026-10-11 20:35:41 +08:00
WHC da04913071 android: read the activity's own arguments, since Qt does not deliver them
`am start -n <pkg>/org.qtproject.qt.android.bindings.QtActivity --esa
applicationArguments "--workspace,/sdcard/x"` left the window on its default
workspace on the NX789J (the plain-string extra too), so the extra is read
from the intent directly. That is what makes `cst-agent-app --uitest
--workspace DIR` reachable on a device with no keyboard -- and adb cannot
inject touch into this window either (tap, touchscreen tap, mouse tap and
motionevent all do nothing; key events do work), so it is the only way in.

Everything degrades to "no arguments": an invalid object, a missing method or
a context that is not an activity all come back empty.
2026-10-11 20:22:50 +08:00
WHC 7b2e7a3dbd fix: the window's approval switch never reached the engine
needs_confirm() (src/agent_loop.c) decides whether a tool asks by reading
cfg->confirm_shell / confirm_write / confirm_net; the approval mode only
relaxes those. The CLI fills them in (main.c: parse_confirm), the TUI and the
WebUI do too -- the Qt window never did: AgentCore::open() memset the whole
config and cst_agent_create() keeps what it is given, so every tool ran
without asking while the sidebar said "ask". That is why nothing was ever
asked on the phone (or on a desktop).

- AgentCore: one mapping, applyApproval(), used by open() and setApproval();
  the mode is remembered like the TUI's /approval and read back on start
  (else "ask", the only mode where the window's consent dialog is reached).
- --uitest: two checks that the mode reaches the engine as those flags.

Version 0.9.7 (local).
2026-10-11 20:22:50 +08:00
WHC 4e990c8e35 docs/android.md: what a turn does when nobody answers, and md_to_pdf
The SAF picker is not 'still to come' any more (v0.9.6 did it): the list keeps
the back gesture and the icon glyphs. In its place, what v0.9.7 changed on the
phone -- a consent question refused in the background and bounded in the
foreground, the bounded GUI hop, the phase in the header, and the window's own
md_to_pdf.
2026-10-11 20:04:24 +08:00
WHC dae51e06c7 fix the session-list scan's line count, and pin both new paths with tests
The rewritten cst_agent_store_info() counted a transcript line that ran past a
whole 64 KiB read block a second time, when the newline ending its tail
arrived: a session with one very long line (a tool that read a file back)
reported one message too many -- exactly what the sidebar shows the person.

- src/agent_store.c: the tail's newline ends a line that was already counted.
- tests/test_store.c: test_info_scan() holds the scan down -- a 70 KiB line is
  one line, the first user message comes from the head of the file, and a user
  line past the window is not called the first one.
- tests/test_tools_ext.c: a byte budget stops the session_search walk, says so
  in the result, and never reports a match it did not reach; the same query
  without a budget finds it.

make check and make asan are green.
2026-10-11 20:03:39 +08:00
WHC 042d7f6999 v0.9.7: a turn that cannot hang, a session list that reads nothing twice, and PDFs
- consent: on_permission / on_ask_user wait at most two / five minutes, and in
  the background (a phone off screen, a window that is gone) the answer is
  refused at once with a reason -- a dialog nobody can see blocked the worker
  for good, which is what "the app hangs while the agent works" looked like.
- finishTurn() no longer joins the thread it is called from; the event loop
  reaps the Turn instead.
- AndroidTools: the hop to the GUI thread pings first and gives up after 3 s
  instead of waiting forever on a busy GUI thread.
- the session list stopped reading every transcript: cst_agent_store_info()
  counts newlines in 64 KiB blocks (first_user only within the first 256 KiB),
  and AgentCore::sessions() reuses a row while (mtime, size) is unchanged.
- session_search gets a budget (max_bytes 32 MB, max_seconds 5 s), skips lines
  over 64 KiB, and says when the budget stopped the walk.
- the header shows the phase ("shell: make -j8"), so a long turn no longer
  looks like a stuck one.
- md_to_pdf: the front end's own Qt tool (owner app:qt, every platform):
  markdown or a workspace .md/.html becomes an A4 PDF with 15 mm margins,
  paginated by hand, written into the workspace and delivered as a file card.
  No PrintSupport, so the phone gets it too.
- --uitest runs md_to_pdf through the registry with Chinese text and checks the
  bytes are a PDF; app/CMakeLists.txt no longer drops src/web_md.c from the
  standalone engine build, which the window needs to render markdown.

Version 0.9.7.
2026-10-11 19:26:36 +08:00
WHC 2ffe74b37c desktop: the window installs itself (launcher entry, icon, default.nix)
app/linux/cst-agent-app.{desktop,svg} plus one CMake component ("app") in
app/CMakeLists.txt: `cmake --install --component app` puts the binary in bin/,
the entry in share/applications/ and the icon in hicolor/scalable/apps/. The
entry's file name stays the app_id Qt sets (QGuiApplication::applicationName),
or the launcher draws a generic icon; only the front end installs, the CLI
keeps coming from scripts/bootstrap_linux.sh.

default.nix is that install for nix users: nixpkgs' Qt 6 (qtwayland for a
Wayland session, qtsvg for Breeze's SVG icons), wrapQtAppsHook so the plugins
and QML modules resolve outside a nix-shell and survive nix-collect-garbage,
a filtered src (third_party/ and the build directories are gigabytes this
build never reads), and the version read from include/cst_version.h. doCheck
runs ctest minus test_sandbox, whose Landlock assertion cannot hold inside the
nix build sandbox (the other 40 pass there; make check runs all 41).

Verified: nix-build, nix profile add --file ., then --selftest and --uitest in
a clean environment (no nix-shell, no XDG_* from the session).
2026-10-11 18:44:31 +08:00
42 changed files with 2983 additions and 317 deletions
-175
View File
@@ -1,175 +0,0 @@
name: ci
on:
push:
branches: [main]
tags: ['v*']
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Verification: TinyCC builds and tests everything in seconds. tcc warns on
# `#pragma GCC`, so no -Werror here; the gcc release builds below carry it.
tcc-check:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
env:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Install dependencies
run: |
SUDO=$(command -v sudo || true)
$SUDO bash scripts/ci_pkg_mirror.sh
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
build-essential pkg-config python3 curl ca-certificates \
libcurl4-openssl-dev libncurses-dev
- name: Build TinyCC
run: bash scripts/ci_tcc.sh
- name: Build
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)"
- name: Unit tests
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
- name: Smoke test
run: |
./cst-agent --help
./cst-agent --workspace "$PWD" --list-sessions
./cst-agent --demo-ctl --workspace .
- name: WebUI end-to-end (recorded stream, no API key)
run: sh scripts/e2e_webui.sh
# Release: static musl binaries, gcc with -Werror.
# dist/cst-agent-VERSION-linux-ARCH.tar.gz
linux:
needs: tcc-check
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
check: 1
- arch: x86
check: 1
- arch: aarch64
check: 0
steps:
- name: Checkout
env:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Install host tools
run: |
SUDO=$(command -v sudo || true)
$SUDO bash scripts/ci_pkg_mirror.sh
$SUDO apt-get update
# ncurses-bin/-term: tic, infocmp and the fallback terminal entries
$SUDO apt-get install -y --no-install-recommends \
build-essential cmake ninja-build pkg-config python3 perl \
curl ca-certificates xz-utils bzip2 file ncurses-bin ncurses-term
if [ "${{ matrix.arch }}" = aarch64 ]; then
$SUDO apt-get install -y --no-install-recommends qemu-user-static
fi
- name: Build (static, -Werror)
env:
CST_LINUX_CHECK: ${{ matrix.check }}
run: bash scripts/package_linux.sh ${{ matrix.arch }}
- name: Smoke test
run: |
d=$(ls -d dist/cst-agent-*-linux-${{ matrix.arch }})
file "$d/cst-agent"
run=""
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
$run "$d/cst-agent" --version
$run "$d/cst-agent" --help >/dev/null
$run "$d/cst-agent" --workspace "$PWD" --list-sessions
cat dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz.sha256
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
# secret RELEASE_TOKEN (write:repository); fall back to the job token.
- name: Publish Gitea Release
if: github.event_name == 'push'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CST_GITEA: ${{ github.server_url }}
CST_REPO: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }}
CI: "true"
run: |
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -z "${GITEA_TOKEN}" ]]; then
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
exit 0
fi
bash scripts/release.sh dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz
# Release: mingw-w64 UCRT gcc (Debian trixie) with -Werror.
# dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS)
windows:
needs: tcc-check
runs-on: ubuntu-latest
timeout-minutes: 120
container: debian:trixie
steps:
- name: Install tools
run: |
# No CA certificates in the image yet, so the mirror is plain http.
sed -i 's#https\?://deb.debian.org#http://mirrors.tuna.tsinghua.edu.cn#g' \
/etc/apt/sources.list.d/debian.sources
apt-get update
apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential cmake ninja-build make \
pkg-config python3 perl zip unzip xz-utils patch file \
gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis
- name: Checkout
env:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Cross-build zip and installer
env:
CST_WIN_CFLAGS: -Wall -Wextra -Werror
run: bash scripts/ci_windows_cross.sh
- name: Verify
run: |
ls -lah dist/windows-x86_64/
file dist/windows-x86_64/cst-agent.exe dist/cst-agent-*-windows-x86_64-setup.exe
cat dist/cst-agent-*-windows-x86_64.zip.sha256 dist/cst-agent-*-windows-x86_64-setup.exe.sha256
unzip -l dist/cst-agent-*-windows-x86_64.zip
- name: Publish Gitea Release
if: github.event_name == 'push'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CST_GITEA: ${{ github.server_url }}
CST_REPO: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }}
CI: "true"
run: |
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -z "${GITEA_TOKEN}" ]]; then
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
exit 0
fi
bash scripts/release.sh dist/cst-agent-*-windows-x86_64.zip \
dist/cst-agent-*-windows-x86_64-setup.exe
+7
View File
@@ -28,6 +28,10 @@ third_party/busybox-src/
third_party/prefix-mingw/ third_party/prefix-mingw/
third_party/prefix-linux-*/ third_party/prefix-linux-*/
third_party/build-linux-*/ third_party/build-linux-*/
# the Windows app package (scripts/package_windows_app.sh): MSYS2's Qt and the
# host tools it is built with, both fetched/unpacked by the script
third_party/msys-qt6/
third_party/prefix-qt6-host
third_party/prefix-android-*/ third_party/prefix-android-*/
third_party/build-android-*/ third_party/build-android-*/
third_party/src/ third_party/src/
@@ -49,3 +53,6 @@ app/android/local.properties
# the engine as a static library (app/ links it; built by `make`) # the engine as a static library (app/ links it; built by `make`)
libcstcore.a libcstcore.a
# nix-build
result
+25 -2
View File
@@ -119,6 +119,21 @@ cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
``` ```
依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。 依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
### 桌面 GUI(Qt/QML,可选)
把引擎放进同一个进程的窗口前端(无端口、无第二个进程):`app/`,细节见 [app/README.md](app/README.md)。
```bash
# NixOS / nix:装进用户 profile(PATH + 菜单项 + 图标),卸载 nix profile remove cst-agent
nix profile add --file .
# 其他发行版(需要 Qt 6 的 Quick/Qml/QuickControls2 与 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
```
菜单里启动时工作区=启动目录(即 `$HOME`);在项目目录里 `cst-agent-app` 启动则工作区就是那个目录。
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。 Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。 API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。 `--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
@@ -213,6 +228,8 @@ Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
|---|---|---|---| |---|---|---|---|
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` | | `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` | | `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` |
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | | `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` | | `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
@@ -225,7 +242,11 @@ Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` | | 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` |
“上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。 “上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。
推理强度以 `reasoning_effort` 发送给 DeepSeek 和 OpenAI 推理模型(o 系列、gpt-5,`max` 映射为 `high`),其他模型不发送。 推理强度会按 provider 适配:DeepSeek/OpenAI 使用 `reasoning_effort`,Kimi K3 使用同名字段,Kimi K2 使用 `thinking` 并保留 `reasoning_content`。Kimi 默认开启 1 小时隐式 prompt cache,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭提示。
Kimi 与 DeepSeek 还提供官方联网工具:`web_search` 调用 provider 的 Search API,`web_fetch` 调用 provider 的 Fetch API;其它 provider 继续使用通用 HTTP 抓取。
`kimi` 是按量计费的 Kimi 开放平台,`kimi-code` 是会员订阅的 Kimi Code API;两者的 Base URL 和 API Key 不通用。
详见 [docs/env_openai.md](docs/env_openai.md)。 详见 [docs/env_openai.md](docs/env_openai.md)。
@@ -300,6 +321,7 @@ cst-agent --headless --prompt 'List files'
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 | | `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 | | `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 | | `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
| `web_search` | 网络 | 使用 Kimi 或 DeepSeek 的官方 Search API 搜索实时网页 |
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 | | `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB | | `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) | | `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
@@ -310,7 +332,8 @@ cst-agent --headless --prompt 'List files'
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 | | `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 | | `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 | | `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) | | `md_to_pdf` | 文档 | 图形前端(桌面 / APK)自带:把 markdown 或工作区 `.md` / `.html` 排成 A4 PDF(15 mm 边距,自动分页)写进工作区并交付成文件卡;不需要 Qt PrintSupport,手机上也能出 |
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文);有预算:`max_bytes`(默认 32 MB)、`max_seconds`(默认 5 s),超 64 KiB 的单行跳过,被预算截断时结果里写清楚 |
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) | | `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。 插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
+31 -4
View File
@@ -14,6 +14,10 @@ project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0)
# Inside the repo build (the parent defines the cstcore target, shared with the CLI): # 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 # 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: # 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) # -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
@@ -39,8 +43,10 @@ if(NOT TARGET cstcore)
list(REMOVE_ITEM CST_APP_CORE_SRC list(REMOVE_ITEM CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/net.c" "${CST_APP_ROOT}/src/net.c"
"${CST_APP_ROOT}/src/http_srv.c" "${CST_APP_ROOT}/src/http_srv.c"
"${CST_APP_ROOT}/src/webui.c" "${CST_APP_ROOT}/src/webui.c")
"${CST_APP_ROOT}/src/web_md.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") if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
include_directories("${CST_APP_ROOT}/.gen") include_directories("${CST_APP_ROOT}/.gen")
endif() endif()
@@ -85,10 +91,12 @@ if(CST_APP_GUI)
qt_policy(SET QTP0004 NEW) qt_policy(SET QTP0004 NEW)
# The front end's platform tools: one file per platform, the same split # The front end's platform tools: one file per platform, the same split
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner # core/ToolProvider.cpp makes. Android brings its own (JNI, owner
# "app:android"); the other platforms still use the Linux file, whose # "app:android"), Windows its own (PowerShell popup, owner "app:windows");
# registration entry point is simply not called there. # the rest use the Linux file.
if(ANDROID) if(ANDROID)
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp) set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
elseif(WIN32)
set(CST_APP_PLATFORM_SRC src/platform/windows/NotifyTool.cpp)
else() else()
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp) set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
endif() endif()
@@ -96,9 +104,11 @@ if(CST_APP_GUI)
src/main.cpp src/main.cpp
src/core/AgentCore.cpp src/core/AgentCore.cpp
src/core/ToolProvider.cpp src/core/ToolProvider.cpp
src/core/PdfTools.cpp
src/ui/UiTest.cpp src/ui/UiTest.cpp
src/ui/ThemeIcons.cpp src/ui/ThemeIcons.cpp
src/ui/SessionImages.cpp src/ui/SessionImages.cpp
src/ui/BackKey.cpp
${CST_APP_PLATFORM_SRC}) ${CST_APP_PLATFORM_SRC})
target_include_directories(cst-agent-app PRIVATE target_include_directories(cst-agent-app PRIVATE
src "${CMAKE_CURRENT_SOURCE_DIR}/../include") src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
@@ -119,6 +129,23 @@ if(CST_APP_GUI)
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
endif() 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) ----------------------------------------------------- # --- Android (APK) -----------------------------------------------------
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF, # 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 # -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
+53
View File
@@ -45,6 +45,31 @@ 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 change. Point the app at a Kirigami outside the Qt prefix with
`-DCST_APP_QML_IMPORT=/path/to/qml`. `-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 ## Why in one process
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
@@ -125,6 +150,13 @@ 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 `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. 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 ## Provider, model, key
The sidebar shows where the engine points (`provider · model`); the gear in the The sidebar shows where the engine points (`provider · model`); the gear in the
@@ -271,6 +303,26 @@ and two buttons.
buttons, clicks Open (the header must say `opened report.md`), clicks Share buttons, clicks Open (the header must say `opened report.md`), clicks Share
and checks a name the session does not own is refused. 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 ## Transcript, tool calls, streaming text
- A tool call is a card of its own, and the call **id** is what pairs it with - A tool call is a card of its own, and the call **id** is what pairs it with
@@ -408,6 +460,7 @@ so nothing about it is visible when the app is idle.
file card's open/share through the app's FileProvider file card's open/share through the app's FileProvider
- [x] a turn keeps running with the app off screen (Android foreground service - [x] a turn keeps running with the app off screen (Android foreground service
+ wake lock; nothing to do on a desktop) + 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) - [ ] a real turn driven through the window (needs a provider key)
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone - [ ] SAF file picker, the back gesture, the icon glyphs on the phone
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray, - [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
+15
View File
@@ -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

+53 -3
View File
@@ -201,18 +201,25 @@ ApplicationWindow {
} }
// ---- the shell ------------------------------------------------------- // ---- the shell -------------------------------------------------------
header: ToolBar { /* 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 id: appBar
objectName: "appBar"
implicitHeight: Theme.headerHeight implicitHeight: Theme.headerHeight
background: Rectangle {
color: Theme.surface color: Theme.surface
Rectangle { Rectangle {
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
width: parent.width width: parent.width
height: 1 height: 1
color: Theme.border color: Theme.border
} }
}
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
anchors.leftMargin: Theme.pad anchors.leftMargin: Theme.pad
@@ -367,6 +374,7 @@ ApplicationWindow {
ChatView { ChatView {
id: chat id: chat
objectName: "chatView"
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
model: transcript model: transcript
@@ -499,6 +507,42 @@ ApplicationWindow {
win.statusText = "" 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 ---------------------------------------------------- // ---- engine -> UI ----------------------------------------------------
/* The signal handlers only forward: every one of them has a function /* The signal handlers only forward: every one of them has a function
@@ -650,6 +694,12 @@ ApplicationWindow {
ask.open() 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 onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
function onRunningChanged() { function onRunningChanged() {
+51 -3
View File
@@ -29,6 +29,55 @@ Rectangle {
: state === "unfinished" ? "no result" : state === "unfinished" ? "no result"
: hasResult && exitCode !== 0 ? "failed" : "done" : 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() { function lines() {
const all = output.split("\n") const all = output.split("\n")
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
@@ -92,10 +141,9 @@ Rectangle {
implicitHeight: chip.implicitHeight + 2 implicitHeight: chip.implicitHeight + 2
Text { Text {
id: chip id: chip
objectName: "toolChip"
anchors.centerIn: parent anchors.centerIn: parent
/* "no result" is not an outcome: no exit code belongs on it. */ text: card.chipText()
text: card.stateText + (card.hasResult && card.state !== "unfinished"
? " · exit " + card.exitCode : "")
color: card.stateColor color: card.stateColor
font.pixelSize: Theme.smallSize font.pixelSize: Theme.smallSize
} }
+158 -15
View File
@@ -45,6 +45,24 @@ class Turn : public QThread {
QByteArray m_prompt; QByteArray m_prompt;
}; };
/* The engine asks for a tool only when its own confirm_shell / confirm_write /
* confirm_net flag says so (`needs_confirm()`, src/agent_loop.c) -- the
* approval mode on top of that only *relaxes* them (AUTO writes a file
* without asking, YOLO everything). The CLI fills those three in
* (src/main.c: parse_confirm), the TUI and the WebUI do too; the window never
* did: AgentCore::open() zeroed the whole config and cst_agent_create() keeps
* what it is given, so every tool ran without asking while the sidebar said
* "ask". One mapping, used by both open() and setApproval(). */
namespace {
void applyApproval(cst_agent_config_t *cfg, int approval) {
const int ask = (approval == CST_APPROVAL_ASK);
cfg->approval = approval;
cfg->confirm_shell = ask;
cfg->confirm_write = ask;
cfg->confirm_net = ask;
}
} // namespace
/* --- lifetime ------------------------------------------------------------ */ /* --- lifetime ------------------------------------------------------------ */
AgentCore::AgentCore(QObject *parent) : QObject(parent) { AgentCore::AgentCore(QObject *parent) : QObject(parent) {
@@ -141,6 +159,14 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
/* The GUI answers itself: never let the engine decide behind its back. */ /* The GUI answers itself: never let the engine decide behind its back. */
m_cfg.auto_approve = 0; m_cfg.auto_approve = 0;
m_cfg.headless = 0; m_cfg.headless = 0;
/* What the person chose last -- the CLI and the TUI remember it the same
* way and the TUI writes the same file -- else "ask", which is what the
* window's default label promises and the only mode where its consent
* dialog is ever reached. */
int approval = CST_APPROVAL_ASK;
if (have_settings && st.approval[0])
cst_approval_parse(st.approval, &approval);
applyApproval(&m_cfg, approval);
if (!m_cfg.mode[0]) if (!m_cfg.mode[0])
snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE); snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE);
m_agent = cst_agent_create(&m_cfg); m_agent = cst_agent_create(&m_cfg);
@@ -189,6 +215,12 @@ void AgentCore::close() {
/* --- turns --------------------------------------------------------------- */ /* --- turns --------------------------------------------------------------- */
namespace { namespace {
/* How long the worker waits for a person. Long enough to read a file diff and
* decide, short enough that a missed dialog (or a screen that went dark) does
* not leave the turn waiting for good. */
constexpr int kConsentTimeoutMs = 2 * 60 * 1000;
constexpr int kQuestionTimeoutMs = 5 * 60 * 1000;
/* The first line of a prompt, cut short: the line the phone's "working" /* The first line of a prompt, cut short: the line the phone's "working"
* notification carries while the turn runs (app::holdForeground). */ * notification carries while the turn runs (app::holdForeground). */
QString firstLine(const QString &s) { QString firstLine(const QString &s) {
@@ -224,9 +256,28 @@ void AgentCore::cancel() {
cst_agent_request_cancel(m_agent); cst_agent_request_cancel(m_agent);
} }
/* Qt suspends the application when the phone puts it away, and a desktop
* window can be gone too (--uitest runs headless). Either way a consent
* dialog would be invisible. */
bool AgentCore::backgrounded() const {
QGuiApplication *gui =
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
if (!gui)
return false;
if (gui->applicationState() == Qt::ApplicationSuspended)
return true;
return gui->allWindows().isEmpty() && gui->topLevelWindows().isEmpty();
}
void AgentCore::finishTurn(int rc, const QString &err) { void AgentCore::finishTurn(int rc, const QString &err) {
Turn *t = static_cast<Turn *>(sender()); Turn *t = static_cast<Turn *>(sender());
if (t) {
/* run() is the caller: waiting for the thread here would be waiting for
* this very call to return. Let the event loop reap it instead. */
connect(t, &QThread::finished, t, &QObject::deleteLater);
}
post([this, rc, err] { post([this, rc, err] {
m_turn = nullptr;
setRunning(false); setRunning(false);
app::releaseForeground(); /* the turn is over: let the phone sleep again */ app::releaseForeground(); /* the turn is over: let the phone sleep again */
/* The prompt carried whatever was pending; the engine dropped it. */ /* The prompt carried whatever was pending; the engine dropped it. */
@@ -234,11 +285,6 @@ void AgentCore::finishTurn(int rc, const QString &err) {
emit pendingChanged(); emit pendingChanged();
emit turnFinished(rc, err); emit turnFinished(rc, err);
}); });
if (t) {
t->wait();
t->deleteLater();
}
m_turn = nullptr;
} }
void AgentCore::setRunning(bool on) { void AgentCore::setRunning(bool on) {
@@ -275,16 +321,43 @@ int AgentCore::hookPermission(const char *tool, const char *detail,
return CST_PERM_DENY; return CST_PERM_DENY;
if (self->m_autoApprove) if (self->m_autoApprove)
return CST_PERM_ONCE; return CST_PERM_ONCE;
const QString t = QString::fromUtf8(tool ? tool : "");
/* The turn survives the app leaving the screen (v0.9.4), so a question
* asked there would block the worker on a dialog nobody can see -- which is
* what "the app hangs while the agent works" looks like. In the background
* the answer is no, with a line the person finds in the transcript. */
if (self->backgrounded()) {
self->post([self, t] {
emit self->permissionExpired(t);
emit self->statusChanged(
QStringLiteral("%1: refused — the app was in the background").arg(t));
});
return CST_PERM_DENY;
}
QMutexLocker lock(&self->m_askMu); QMutexLocker lock(&self->m_askMu);
self->m_permAnswer = CST_PERM_DENY; self->m_permAnswer = CST_PERM_DENY;
self->m_permPending = true; self->m_permPending = true;
const QString t = QString::fromUtf8(tool ? tool : "");
const QString d = QString::fromUtf8(detail ? detail : ""); const QString d = QString::fromUtf8(detail ? detail : "");
const QString p = QString::fromUtf8(preview ? preview : ""); const QString p = QString::fromUtf8(preview ? preview : "");
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); }); self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
while (self->m_permPending && !self->m_closing) /* And even in the foreground nobody may hang forever: a dialog can be
self->m_askCv.wait(&self->m_askMu); * missed, a screen can go dark. Two minutes, then the answer is no. */
bool expired = false;
while (self->m_permPending && !self->m_closing) {
if (!self->m_askCv.wait(&self->m_askMu, kConsentTimeoutMs)) {
expired = true;
break;
}
}
self->m_permPending = false; self->m_permPending = false;
if (expired) {
self->post([self, t] {
emit self->permissionExpired(t);
emit self->statusChanged(
QStringLiteral("%1: no answer for two minutes — refused").arg(t));
});
return CST_PERM_DENY;
}
return self->m_permAnswer; return self->m_permAnswer;
} }
@@ -295,16 +368,40 @@ int AgentCore::hookAskUser(const char *question, const char *options, char *ans,
return -1; return -1;
if (self->m_autoApprove) if (self->m_autoApprove)
return -1; return -1;
if (self->backgrounded()) {
self->post([self] {
emit self->questionExpired();
emit self->statusChanged(
QStringLiteral("the question was dropped — the app was in the "
"background"));
});
return -1;
}
QMutexLocker lock(&self->m_askMu); QMutexLocker lock(&self->m_askMu);
self->m_questionPending = true; self->m_questionPending = true;
self->m_answer.clear(); self->m_answer.clear();
const QString q = QString::fromUtf8(question ? question : ""); const QString q = QString::fromUtf8(question ? question : "");
const QString o = QString::fromUtf8(options ? options : ""); const QString o = QString::fromUtf8(options ? options : "");
self->post([self, q, o] { emit self->questionAsked(q, o); }); self->post([self, q, o] { emit self->questionAsked(q, o); });
while (self->m_questionPending && !self->m_closing) bool expired = false;
self->m_askCv.wait(&self->m_askMu); while (self->m_questionPending && !self->m_closing) {
if (!self->m_askCv.wait(&self->m_askMu, kQuestionTimeoutMs)) {
expired = true;
break;
}
}
if (self->m_closing) if (self->m_closing)
return -1; return -1;
self->m_questionPending = false;
if (expired) {
self->post([self] {
emit self->questionExpired();
emit self->statusChanged(
QStringLiteral("no answer for five minutes — the question was "
"dropped"));
});
return -1;
}
snprintf(ans, ans_sz, "%s", self->m_answer.constData()); snprintf(ans, ans_sz, "%s", self->m_answer.constData());
return 0; return 0;
} }
@@ -324,9 +421,12 @@ void AgentCore::setMode(const QString &mode) {
} }
void AgentCore::setApproval(int approval) { void AgentCore::setApproval(int approval) {
m_cfg.approval = approval; applyApproval(&m_cfg, approval);
if (m_agent) if (m_agent)
cst_agent_config_mut(m_agent)->approval = approval; applyApproval(cst_agent_config_mut(m_agent), approval);
/* Remembered like the TUI's /approval: the next start of the window (or of
* the CLI) comes up the way it was left. */
cst_settings_remember_approval(approval);
emit approvalChanged(); emit approvalChanged();
} }
@@ -530,29 +630,56 @@ QString AgentCore::shareArtifact(const QString &rel) {
QVariantList AgentCore::sessions() const { QVariantList AgentCore::sessions() const {
QVariantList out; QVariantList out;
if (m_workspace.isEmpty()) if (m_workspace.isEmpty()) {
m_sessionCache.clear();
return out; return out;
}
const QByteArray ws = m_workspace.toUtf8(); const QByteArray ws = m_workspace.toUtf8();
const int kMax = 200; const int kMax = 200;
char(*ids)[128] = new char[kMax][128]; char(*ids)[128] = new char[kMax][128];
const int n = cst_agent_store_list(ws.constData(), ids, kMax); const int n = cst_agent_store_list(ws.constData(), ids, kMax);
const QString current = sessionId(); const QString current = sessionId();
QHash<QString, SessionCache> fresh;
fresh.reserve(n);
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
const QString id = utf8(ids[i]);
/* Filling one row walks that session's whole transcript, and the sidebar
* asks for the list on every store change: the transcript's own stat is
* what tells "nothing happened to this one" from "it grew", so an
* unchanged session costs a stat instead of a read. */
char path[4096] = "";
const bool have_path = cst_agent_store_path_for_id(
ws.constData(), ids[i], path, sizeof(path)) == 0;
const QFileInfo fi(have_path ? QString::fromUtf8(path) : QString());
const qint64 mtime = fi.lastModified().toSecsSinceEpoch();
const qint64 size = fi.size();
SessionCache entry;
const auto cached = m_sessionCache.constFind(id);
if (cached != m_sessionCache.constEnd() && cached->mtime == mtime &&
cached->size == size) {
entry = *cached;
} else {
cst_session_info_t in; cst_session_info_t in;
memset(&in, 0, sizeof(in)); memset(&in, 0, sizeof(in));
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0) if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
continue; continue;
const QString id = utf8(in.id[0] ? in.id : ids[i]); entry.mtime = mtime;
entry.size = size;
QVariantMap m; QVariantMap m;
m["id"] = id; m["id"] = id;
m["title"] = utf8(in.title); m["title"] = utf8(in.title);
m["firstUser"] = utf8(in.first_user); m["firstUser"] = utf8(in.first_user);
m["messages"] = in.messages; m["messages"] = in.messages;
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */ m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
entry.row = m;
}
QVariantMap m = entry.row;
m["current"] = (id == current); m["current"] = (id == current);
out.append(m); out.append(m);
fresh.insert(id, entry);
} }
delete[] ids; delete[] ids;
m_sessionCache = fresh; /* and the ids that are gone are forgotten */
return out; return out;
} }
@@ -710,6 +837,9 @@ bool AgentCore::deleteSession(const QString &id) {
bool AgentCore::renameSession(const QString &id, const QString &title) { bool AgentCore::renameSession(const QString &id, const QString &title) {
if (id.isEmpty() || m_workspace.isEmpty()) if (id.isEmpty() || m_workspace.isEmpty())
return false; return false;
/* A title is not in the transcript, so the cached rows would not notice the
* rename: drop them and let the next list read them again. */
m_sessionCache.clear();
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(), const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
t = title.toUtf8(); t = title.toUtf8();
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) != if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
@@ -1138,7 +1268,20 @@ void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call,
const QString id = QString::fromUtf8(call && call->id ? call->id : ""); const QString id = QString::fromUtf8(call && call->id ? call->id : "");
const QString n = QString::fromUtf8(call ? call->name : ""); const QString n = QString::fromUtf8(call ? call->name : "");
const QString d = QString::fromUtf8(detail ? detail : ""); const QString d = QString::fromUtf8(detail ? detail : "");
self->post([self, p, id, n, d] { emit self->toolEvent(p, id, n, d); }); self->post([self, p, id, n, d] {
emit self->toolEvent(p, id, n, d);
/* The header says what the turn is doing right now ("shell: make -j8"),
* not just that something is happening: a long turn is otherwise
* indistinguishable from a stuck one. */
if (p == QLatin1String("done")) {
emit self->statusChanged(QString());
return;
}
QString what = d;
if (what.size() > 80)
what = what.left(79) + QStringLiteral("…");
emit self->statusChanged(what.isEmpty() ? n : QStringLiteral("%1: %2").arg(n, what));
});
} }
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out, void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
+19
View File
@@ -9,6 +9,7 @@
#define CST_APP_AGENTCORE_H #define CST_APP_AGENTCORE_H
#include <QByteArray> #include <QByteArray>
#include <QHash>
#include <QMutex> #include <QMutex>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
@@ -204,6 +205,11 @@ class AgentCore : public QObject {
void permissionRequested(const QString &tool, const QString &detail, void permissionRequested(const QString &tool, const QString &detail,
const QString &preview); const QString &preview);
void questionAsked(const QString &question, const QString &options); 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 turnFinished(int rc, const QString &err);
void runningChanged(); void runningChanged();
void workspaceChanged(); void workspaceChanged();
@@ -218,6 +224,10 @@ class AgentCore : public QObject {
void syncConfig(); void syncConfig();
/* Re-read what a session switch changed (id, todo) and say so. */ /* Re-read what a session switch changed (id, todo) and say so. */
void afterSessionChange(); 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). */ /* Hook trampolines (C, run on the worker thread). */
static void hookStatus(const char *msg, void *ud); static void hookStatus(const char *msg, void *ud);
@@ -255,6 +265,15 @@ class AgentCore : public QObject {
bool m_permPending = false; bool m_permPending = false;
QString m_question; QString m_question;
QString m_todo; 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: /* 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. */ * the engine's own description says what it is, not where it came from. */
QStringList m_attachNames; QStringList m_attachNames;
+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
+3
View File
@@ -22,6 +22,8 @@ int registerPlatformTools(AgentCore *core) {
#elif defined(__linux__) #elif defined(__linux__)
n += platformRegisterLinux(core); n += platformRegisterLinux(core);
#endif #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 /* A service may have appeared or gone since the last probe (a desktop
* session starting, the notification daemon coming up). */ * session starting, the notification daemon coming up). */
cst_tool_meta_reload(); cst_tool_meta_reload();
@@ -35,6 +37,7 @@ void unregisterPlatformTools(AgentCore *core) {
core->unregisterOwner(QStringLiteral("app:windows")); core->unregisterOwner(QStringLiteral("app:windows"));
core->unregisterOwner(QStringLiteral("app:macos")); core->unregisterOwner(QStringLiteral("app:macos"));
core->unregisterOwner(QStringLiteral("app:android")); core->unregisterOwner(QStringLiteral("app:android"));
core->unregisterOwner(QStringLiteral("app:qt"));
} }
} // namespace app } // namespace app
+27
View File
@@ -19,6 +19,9 @@
/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer /* The engine wrapper of the front end (core/AgentCore.h). Only a pointer
* travels through this header, so the class stays incomplete here. */ * travels through this header, so the class stays incomplete here. */
class AgentCore; 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 { namespace app {
@@ -44,6 +47,14 @@ extern const char *const kDesktopOnly[];
/* The platform tools this app provides. Called once the engine exists; /* The platform tools this app provides. Called once the engine exists;
* returns how many were registered. */ * returns how many were registered. */
int registerPlatformTools(AgentCore *core); 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:*"). */ /* Drop them again (they belong to the owners "app:*"). */
void unregisterPlatformTools(AgentCore *core); void unregisterPlatformTools(AgentCore *core);
@@ -93,6 +104,22 @@ bool hasAllFilesAccess();
* dialog for it). A desktop, a phone that already has it or one too old to * dialog for it). A desktop, a phone that already has it or one too old to
* know about it returns "" and nothing opens. */ * know about it returns "" and nothing opens. */
QString requestAllFilesAccess(); 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 } // namespace app
+49 -1
View File
@@ -16,6 +16,7 @@
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQmlContext> #include <QQmlContext>
#include <QQuickStyle> #include <QQuickStyle>
#include <QQuickWindow>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QTextStream> #include <QTextStream>
@@ -33,6 +34,7 @@
#include "core/AgentCore.h" #include "core/AgentCore.h"
#include "core/ToolProvider.h" #include "core/ToolProvider.h"
#include "ui/UiTest.h" #include "ui/UiTest.h"
#include "ui/BackKey.h"
#include "ui/SessionImages.h" #include "ui/SessionImages.h"
#include "ui/ThemeIcons.h" #include "ui/ThemeIcons.h"
#include "cst_version.h" #include "cst_version.h"
@@ -193,13 +195,30 @@ int main(int argc, char **argv) {
QCommandLineOption sizeOpt(QStringLiteral("size"), QCommandLineOption sizeOpt(QStringLiteral("size"),
QStringLiteral("initial window size, e.g. 480x900"), QStringLiteral("initial window size, e.g. 480x900"),
QStringLiteral("WxH")); 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(wsOpt);
parser.addOption(providerOpt); parser.addOption(providerOpt);
parser.addOption(modelOpt); parser.addOption(modelOpt);
parser.addOption(planOpt); parser.addOption(planOpt);
parser.addOption(uitestOpt); parser.addOption(uitestOpt);
parser.addOption(sizeOpt); parser.addOption(sizeOpt);
parser.process(app); 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); QString ws = parser.value(wsOpt);
if (ws.isEmpty()) { if (ws.isEmpty()) {
@@ -226,6 +245,12 @@ int main(int argc, char **argv) {
} }
} }
/* 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) #if defined(Q_OS_ANDROID)
/* Before the engine is created: its settings, credentials and session store /* Before the engine is created: its settings, credentials and session store
* all resolve their paths from the environment. */ * all resolve their paths from the environment. */
@@ -326,5 +351,28 @@ int main(int argc, char **argv) {
engine.load(url); engine.load(url);
if (engine.rootObjects().isEmpty()) if (engine.rootObjects().isEmpty())
return 1; 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(); return app.exec();
} }
+162 -3
View File
@@ -25,8 +25,10 @@
#include <QFileInfo> #include <QFileInfo>
#include <QFuture> #include <QFuture>
#include <QGuiApplication> #include <QGuiApplication>
#include <QJniEnvironment>
#include <QJniObject> #include <QJniObject>
#include <QMetaObject> #include <QMetaObject>
#include <QSemaphore>
#include <QStandardPaths> #include <QStandardPaths>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
@@ -36,6 +38,7 @@
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <memory>
#include <utility> #include <utility>
#include "core/AgentCore.h" #include "core/AgentCore.h"
@@ -59,16 +62,34 @@ void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) {
r->exit_code = rc; 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 /* 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. */ * 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> template <typename Fn>
void onGuiThread(Fn &&fn) { bool onGuiThread(Fn &&fn) {
if (!qApp || QThread::currentThread() == qApp->thread()) { if (!qApp || QThread::currentThread() == qApp->thread()) {
fn(); fn();
return; 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), QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
Qt::BlockingQueuedConnection); Qt::BlockingQueuedConnection);
return true;
} }
jint sdkInt() { jint sdkInt() {
@@ -81,6 +102,80 @@ QJniObject appContext() {
return QNativeInterface::QAndroidApplication::context(); 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 --------------------------------------------------- */ /* --- notification_send --------------------------------------------------- */
/* Android 13 (API 33) put notifications behind a runtime permission; before /* Android 13 (API 33) put notifications behind a runtime permission; before
@@ -496,6 +591,11 @@ cst_tool_def_t g_share;
} // namespace } // 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 /* 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. */ * permission granted to the app that picks it up. */
QString openLocalFile(const QString &path, const QString &title, QString openLocalFile(const QString &path, const QString &title,
@@ -746,6 +846,65 @@ QString requestAllFilesAccess() {
return err; 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) { int platformRegisterAndroid(AgentCore *core) {
if (!core) if (!core)
return 0; return 0;
+4
View File
@@ -157,6 +157,10 @@ QString materializeAttachment(const QString &pathOrUri, QString *err) {
bool hasAllFilesAccess() { return true; } bool hasAllFilesAccess() { return true; }
QString requestAllFilesAccess() { return QString(); } 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) { int platformRegisterLinux(AgentCore *core) {
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"message", "string", 1, "text of the notification"}, {"message", "string", 1, "text of the notification"},
+196
View File
@@ -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
+537 -15
View File
@@ -11,25 +11,35 @@
#include <QGuiApplication> #include <QGuiApplication>
#include <QClipboard> #include <QClipboard>
#include <QBuffer> #include <QBuffer>
#include <QDateTime>
#include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QImage> #include <QImage>
#include <QPalette>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QPainter> #include <QPainter>
#include <QPointingDevice> #include <QPointingDevice>
#include <QQuickItem> #include <QQuickItem>
#include <QQuickStyle>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
#include <QQmlContext> #include <QQmlContext>
#include <QQuickWindow> #include <QQuickWindow>
#include <QTest> #include <QTest>
#include <QTextDocument>
#include <QVariantMap> #include <QVariantMap>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <cstring> #include <cstring>
#include <memory>
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "core/ToolProvider.h"
#include "ui/BackKey.h"
#include "ui/SessionImages.h" #include "ui/SessionImages.h"
#include "ui/ThemeIcons.h" #include "ui/ThemeIcons.h"
@@ -38,13 +48,61 @@ namespace {
int g_fail; int g_fail;
/* The run's own log file, opened once the workspace is known (see the top of
* the run below): every line stdout gets is written here as well. On a phone
* stdout reaches nobody, and an app started from adb cannot be handed
* CST_APP_SHOT or any other environment -- so `--uitest --workspace DIR` plus
* `adb pull DIR/uitest.log` is how a device run is read back. Empty when the
* file could not be made, in which case the run only prints. */
QFile g_runLog;
/* Qt's own messages go into the same log as the checks: this is where QML
* puts "TypeError: Cannot read property ... of undefined" and every other
* reason a step did nothing, and a device run has nowhere else to show it
* (logcat does not carry this build's Qt messages -- a plain qInfo from the
* app is not in the buffer). Messages before the log is opened still print. */
void logQtMessage(QtMsgType type, const QMessageLogContext &, const QString &msg) {
const char *kind = type == QtDebugMsg ? "debug"
: type == QtInfoMsg ? "info"
: type == QtWarningMsg ? "warn"
: type == QtCriticalMsg ? "crit"
: "fatal";
const QByteArray line = QByteArray(kind) + " " + msg.toUtf8() + "\n";
if (g_runLog.isOpen()) {
g_runLog.write(line);
g_runLog.flush();
}
fwrite(line.constData(), 1, static_cast<size_t>(line.size()), stderr);
if (type == QtFatalMsg)
abort();
}
void check(bool ok, const QString &what) { void check(bool ok, const QString &what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what)); std::printf("%s %s\n", ok ? "ok " : "FAIL", qPrintable(what));
std::fflush(stdout); std::fflush(stdout);
if (g_runLog.isOpen()) {
g_runLog.write(ok ? "ok " : "FAIL");
g_runLog.write(what.toUtf8());
g_runLog.write("\n");
g_runLog.flush();
}
if (!ok) if (!ok)
g_fail++; g_fail++;
} }
/* A line in the run's log that is not a check: what the window looked like at
* a step, for the cases where "it did nothing" is all the checks can say. */
void note(const QString &what) {
std::printf("note %s\n", qPrintable(what));
std::fflush(stdout);
if (g_runLog.isOpen()) {
g_runLog.write("note ");
g_runLog.write(what.toUtf8());
g_runLog.write("\n");
g_runLog.flush();
}
}
/* Every item with this objectName in the visual tree. A view that has just /* Every item with this objectName in the visual tree. A view that has just
* been replayed can still hold the delegates it is about to drop, so a name * been replayed can still hold the delegates it is about to drop, so a name
* can match more than one item for a moment. */ * can match more than one item for a moment. */
@@ -72,11 +130,20 @@ int countNamed(QQuickItem *root, const QString &prefix) {
/* An item by objectName. The QObject tree is tried first (it holds the named /* An item by objectName. The QObject tree is tried first (it holds the named
* controls); a view's delegates can be owned by the model instead, so the * controls); a view's delegates can be owned by the model instead, so the
* visual tree -- what a finger can actually reach -- is walked as well. */ * visual tree -- what a finger can actually reach -- is walked as well. */
bool onScreen(QQuickItem *it); /* defined below: isVisible() is not enough */
QQuickItem *item(QQuickWindow *win, const QString &name) { QQuickItem *item(QQuickWindow *win, const QString &name) {
QList<QQuickItem *> hits; QList<QQuickItem *> hits;
collectNamed(win->contentItem(), name, &hits); collectNamed(win->contentItem(), name, &hits);
/* What a finger can reach is the one the test means: a delegate on its way /* What a finger can reach is the one the test means. Two things say
* out is still in the tree but invisible. */ * "visible" without being reachable: a delegate on its way out, and a
* second copy of a control that lives in a closed drawer (the sidebar is
* drawn from the window and, when narrow, from a drawer off to the left).
* Which copy comes first in the tree is not the same on every platform, so
* the one whose centre is really on screen wins. */
for (QQuickItem *it : hits)
if (onScreen(it))
return it;
for (QQuickItem *it : hits) for (QQuickItem *it : hits)
if (it->isVisible()) if (it->isVisible())
return it; return it;
@@ -91,12 +158,87 @@ QPoint centre(QQuickItem *it) {
return p.toPoint(); return p.toPoint();
} }
/* Is this item's centre really reachable? isVisible() answers whether hiding
* was asked for, not whether a clipping ancestor (a scrolling view) has moved
* it out of sight -- and an event goes to whatever is at that point instead. */
bool onScreen(QQuickItem *it) {
if (!it || !it->isVisible() || it->width() <= 0 || it->height() <= 0)
return false;
QQuickWindow *win = it->window();
if (!win)
return false;
const QPointF p = it->mapToScene(QPointF(it->width() / 2, it->height() / 2));
if (!QRectF(0, 0, win->width(), win->height()).contains(p))
return false;
for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) {
if (!a->clip())
continue;
if (!a->mapRectToScene(QRectF(0, 0, a->width(), a->height())).contains(p))
return false;
}
return true;
}
/* An event lands on whatever is at that point, and isVisible() does not know
* about a scrolling ancestor's clipping (see onScreen): an item the view has
* scrolled past still says it is visible, and the click goes to the row that
* happens to be at that pixel -- which is a different row on a window whose
* font makes the rows taller (another platform, another Qt). So a click
* scrolls its target in first, which is what a person does. The nearest
* ancestor that both clips and scrolls (a ListView's own item) is the one to
* move; anchored to its middle, so a row at either edge still gets its whole
* centre inside the viewport. */
void scrollIntoView(QQuickItem *it, bool middle = false) {
if (!it || (!middle && onScreen(it)))
return;
for (QQuickItem *a = it->parentItem(); a; a = a->parentItem()) {
const QVariant contentY = a->property("contentY");
if (!a->clip() || !contentY.isValid() ||
!a->property("contentHeight").isValid())
continue;
const double y = it->mapToItem(a, QPointF(0, 0)).y() + contentY.toDouble();
const double lowest = a->property("contentHeight").toDouble() - a->height();
note(QStringLiteral("%1 is out of the viewport (contentY %2, at %3 of %4): "
"scrolling to it")
.arg(it->objectName())
.arg(contentY.toDouble())
.arg(y)
.arg(a->property("contentHeight").toDouble()));
a->setProperty("contentY", qBound(0.0, y - a->height() / 2, lowest));
QTest::qWait(150);
return;
}
}
/* One rendered frame: items are laid out, and a view's delegates are created,
* in the render loop's polish step. A platform whose window is never exposed
* -- offscreen on Windows, which is how this is run on a machine that has no
* Windows -- only runs that step when something asks for a picture, so
* without this the window can be a whole pass behind: a row still 0 wide, a
* card not created yet. A person looking at the window would see it settled,
* which is what the checks below are about. */
void frame(QQuickWindow *win) {
if (win)
win->grabWindow();
QTest::qWait(50);
}
void clickItem(QQuickWindow *win, QQuickItem *it); /* defined below */
void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it);
void clickMouse(QQuickWindow *win, const QString &name) { void clickMouse(QQuickWindow *win, const QString &name) {
QQuickItem *it = item(win, name); QQuickItem *it = item(win, name);
if (!it || !it->isVisible()) { if (!it || !it->isVisible()) {
check(false, QStringLiteral("mouse click %1: not there").arg(name)); check(false, QStringLiteral("mouse click %1: not there").arg(name));
return; return;
} }
clickItem(win, it);
}
/* The same click, on an item the caller already has: a name can match more
* than one item (a view keeps a delegate from the session before), and the
* one the test means is not always the first in the tree. */
void clickItem(QQuickWindow *win, QQuickItem *it) {
QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it)); QTest::mouseClick(win, Qt::LeftButton, Qt::NoModifier, centre(it));
} }
@@ -106,6 +248,10 @@ void tapTouch(QQuickWindow *win, QPointingDevice *dev, const QString &name) {
check(false, QStringLiteral("touch tap %1: not there").arg(name)); check(false, QStringLiteral("touch tap %1: not there").arg(name));
return; return;
} }
tapItem(win, dev, it);
}
void tapItem(QQuickWindow *win, QPointingDevice *dev, QQuickItem *it) {
const QPoint p = centre(it); const QPoint p = centre(it);
QTest::touchEvent(win, dev).press(0, p, win); QTest::touchEvent(win, dev).press(0, p, win);
QTest::touchEvent(win, dev).release(0, p, win); QTest::touchEvent(win, dev).release(0, p, win);
@@ -239,6 +385,11 @@ int runUiTest(AgentCore &core, const QString &size) {
/* A file card hands its file to the system: in a test run nothing may open /* A file card hands its file to the system: in a test run nothing may open
* for real, and the platform layer itself honours this (see ToolProvider.h). */ * for real, and the platform layer itself honours this (see ToolProvider.h). */
qputenv("CST_APP_NO_OPEN", "1"); qputenv("CST_APP_NO_OPEN", "1");
/* From here on the run leaves a log in the workspace next to the files it
* makes, so a device run can be read back without logcat (see g_runLog). */
g_runLog.setFileName(core.workspace() + QStringLiteral("/uitest.log"));
g_runLog.open(QIODevice::WriteOnly | QIODevice::Truncate);
qInstallMessageHandler(logQtMessage);
const QString resumed = const QString resumed =
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on")); writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
check(!resumed.isEmpty() && core.switchSession(resumed), check(!resumed.isEmpty() && core.switchSession(resumed),
@@ -265,6 +416,9 @@ int runUiTest(AgentCore &core, const QString &size) {
std::printf("FAIL the root object is not a window\n"); std::printf("FAIL the root object is not a window\n");
return 1; return 1;
} }
/* The same handler main() installs: every Back key check below goes through
* the code the phone runs, not through a Shortcut that only the test has. */
installBackKey(win, engine.rootObjects().value(0));
QTest::qWait(250); QTest::qWait(250);
/* --- the window opens on the session the engine already had ----------- */ /* --- the window opens on the session the engine already had ----------- */
@@ -332,6 +486,10 @@ int runUiTest(AgentCore &core, const QString &size) {
QStringLiteral("a session fixture with a tool call is in the store")); QStringLiteral("a session fixture with a tool call is in the store"));
QMetaObject::invokeMethod(win, "reloadSessions"); QMetaObject::invokeMethod(win, "reloadSessions");
QTest::qWait(150); QTest::qWait(150);
/* A row is not clickable until the layout pass that gives it its width has
* run (see frame()): a click on a row that is still 0 wide goes to whatever
* is behind it. */
frame(win);
int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:")); int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:"));
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
std::printf("note: %d session rows in the visual tree\n", rowsSeen); std::printf("note: %d session rows in the visual tree\n", rowsSeen);
@@ -377,14 +535,97 @@ int runUiTest(AgentCore &core, const QString &size) {
item(win, QStringLiteral("fileShare0")) != nullptr, item(win, QStringLiteral("fileShare0")) != nullptr,
QStringLiteral("the card carries open and share")); QStringLiteral("the card carries open and share"));
win->setProperty("statusText", QString()); win->setProperty("statusText", QString());
clickMouse(win, QStringLiteral("fileOpen0")); /* A click goes to whatever is at that point, and the transcript replays
* from its bottom: on a phone-sized window the card can be above the
* visible part, where isVisible() still says yes (it does not know about
* the view's clipping). scrollIntoView puts it in the middle of the view --
* and stop the view following its tail (ChatView.stickBottom) first, or
* the scroll is undone by the next content change. */
if (list) {
/* Stop the view following its tail (ChatView.stickBottom) first: the
* scroll to the card below would be undone by the next content
* change. */
if (QQuickItem *chat = item(win, QStringLiteral("chatView")))
chat->setProperty("stickBottom", false);
}
QStringList rels;
QString cardSays;
QList<QQuickItem *> strips;
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &strips);
/* Which card is the session's own. The tree still holds the delegate of
* the session before -- a view keeps a row or two around -- and its card
* names a file of *that* session, so the first "fileOpen0" in the tree is
* not necessarily the one the check means. A rel this session can open is
* one that is really in its attachment directory. */
QQuickItem *openButton = nullptr;
QQuickItem *shareButton = nullptr;
const QString attachDir = core.attachmentDir();
for (QQuickItem *s : strips) {
const QVariantList files = s->property("files").toList();
for (int i = 0; i < files.size(); i++) {
const QVariant &f = files.at(i);
rels << f.toMap().value(QStringLiteral("rel")).toString();
if (openButton) {
continue;
}
const QString rel =
f.toMap().value(QStringLiteral("rel")).toString();
if (rel.isEmpty() ||
!QFileInfo::exists(attachDir + QLatin1Char('/') + rel))
continue;
QList<QQuickItem *> hits;
collectNamed(s, QStringLiteral("fileOpen%1").arg(i), &hits);
if (!hits.isEmpty()) {
openButton = hits.first();
collectNamed(s, QStringLiteral("fileShare%1").arg(i), &hits);
shareButton = hits.isEmpty() ? nullptr : hits.first();
}
}
if (cardSays.isEmpty())
cardSays = s->property("errorText").toString();
}
if (!openButton)
openButton = item(win, QStringLiteral("fileOpen0"));
if (!shareButton)
shareButton = item(win, QStringLiteral("fileShare0"));
/* What the card itself hands the engine ("rel" is the name the store keeps
* the file under), and whether it is really on screen: a card outside the
* viewport takes the events meant for it, and the card's own line is where
* the engine puts its refusal. */
if (openButton) {
scrollIntoView(openButton, true);
frame(win);
clickItem(win, openButton);
} else {
check(false, QStringLiteral("the delivered file has a card to open"));
}
const bool byMouse = !win->property("statusText").toString().isEmpty();
if (!byMouse && openButton)
tapItem(win, touch, openButton);
const QString opened = win->property("statusText").toString(); const QString opened = win->property("statusText").toString();
check(opened.startsWith(QLatin1String("opened ")), check(opened.startsWith(QLatin1String("opened ")),
QStringLiteral("Open hands the file to the system (%1)").arg(opened)); QStringLiteral("Open hands the file to the system (%1%2; cards hold '%3', "
clickMouse(win, QStringLiteral("fileShare0")); "%4, card says '%5')")
.arg(opened, byMouse ? QString() : QStringLiteral(" after a tap"),
rels.join(QLatin1String(", ")),
onScreen(openButton)
? QStringLiteral("on screen")
: QStringLiteral("out of the viewport"),
cardSays));
win->setProperty("statusText", QString());
if (shareButton) {
scrollIntoView(shareButton, true);
frame(win);
clickItem(win, shareButton);
}
const bool shareByMouse = !win->property("statusText").toString().isEmpty();
if (!shareByMouse && shareButton)
tapItem(win, touch, shareButton);
const QString shared = win->property("statusText").toString(); const QString shared = win->property("statusText").toString();
check(shared.contains(QLatin1String("report.md")), check(shared.contains(QLatin1String("report.md")),
QStringLiteral("Share names the file (%1)").arg(shared)); QStringLiteral("Share names the file (%1%2; card says '%3')")
.arg(shared, shareByMouse ? QString() : QStringLiteral(" after a tap"),
cardSays));
const QString refused = const QString refused =
core.openArtifact(QStringLiteral("art-not-one-of-ours.md")); core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
check(refused.contains(QLatin1String("no such file")), check(refused.contains(QLatin1String("no such file")),
@@ -416,6 +657,136 @@ int runUiTest(AgentCore &core, const QString &size) {
QStringLiteral("and the line is where the card is")); QStringLiteral("and the line is where the card is"));
} }
/* The approval switch is not decoration: the engine asks for a tool only
* when its own confirm_* flags say so (needs_confirm() in agent_loop.c),
* and every front end that has a consent dialog has to set them -- the
* window used to leave all three at zero, which is why "ask" never asked. */
{
const int was = core.approval();
core.setApproval(CST_APPROVAL_ASK);
const cst_agent_config_t *live = cst_agent_config(core.agent());
check(live && live->approval == CST_APPROVAL_ASK && live->confirm_shell &&
live->confirm_write && live->confirm_net,
QStringLiteral("'ask' reaches the engine as the three confirm flags"));
core.setApproval(CST_APPROVAL_YOLO);
live = cst_agent_config(core.agent());
check(live && live->approval == CST_APPROVAL_YOLO && !live->confirm_shell &&
!live->confirm_write && !live->confirm_net,
QStringLiteral("and 'yolo' takes them all away"));
core.setApproval(was); /* leave the machine as it was found */
}
/* --- md_to_pdf: the front end's own document writer ------------------- */
{
const cst_tool_def_t *def = cst_tool_find("md_to_pdf");
check(def != nullptr && def->owner != nullptr &&
strncmp(def->owner, "app:qt", 6) == 0,
QStringLiteral("md_to_pdf is registered by the front end"));
if (def && def->run_ex) {
/* Run it the way the agent's turn would: through the registry, with a
* call and the engine's context. */
const QByteArray ws = core.workspace().toUtf8();
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = ws.constData();
ctx.agent = core.agent();
ctl_tool_call_t call;
memset(&call, 0, sizeof(call));
snprintf(call.id, sizeof(call.id), "pdf1");
snprintf(call.name, sizeof(call.name), "md_to_pdf");
static const char *kMd =
"# 设备信息报告\n\n这是一段**中文**正文,用来确认字体和分页。\n\n"
"- 项目一\n- 项目二\n\n```\ncode block\n```\n";
static const char *kOut = "selftest.pdf";
snprintf(call.args[0].key, sizeof(call.args[0].key), "markdown");
call.args[0].value = const_cast<char *>(kMd);
call.args[0].value_len = strlen(kMd);
snprintf(call.args[1].key, sizeof(call.args[1].key), "out");
call.args[1].value = const_cast<char *>(kOut);
call.args[1].value_len = strlen(kOut);
call.nargs = 2;
cst_tool_result_t res;
memset(&res, 0, sizeof(res));
const int rc = cst_tool_run(&ctx, &call, &res);
check(res.exit_code == 0,
QStringLiteral("md_to_pdf writes the document (%1)")
.arg(QString::fromUtf8(res.out ? res.out : "")));
check(rc == 0 && res.nattach == 1,
QStringLiteral("and hands it over as a file for the user"));
QFile f(core.workspace() + QStringLiteral("/selftest.pdf"));
const bool opened = f.open(QIODevice::ReadOnly);
const QByteArray head = opened ? f.read(5) : QByteArray();
check(head == QByteArrayLiteral("%PDF-"),
QStringLiteral("the bytes are a PDF (%1)")
.arg(QString::fromLatin1(head)));
cst_tool_result_free(&res);
}
}
/* Paper is white whatever the window's palette says: markdown carries no
* colour, so a dark window turned every plain paragraph #eeeeee -- a PDF
* that looked blank. Draw it on a white image and look for ink. */
{
const QPalette was = QGuiApplication::palette();
QPalette darkPalette = was;
darkPalette.setColor(QPalette::Text, QColor(0xee, 0xee, 0xee));
darkPalette.setColor(QPalette::WindowText, QColor(0xee, 0xee, 0xee));
QGuiApplication::setPalette(darkPalette);
std::unique_ptr<QTextDocument> page(app::paperDocument(
QStringLiteral("<html><body>text that names no colour of its own"
"</body></html>")));
QImage paper(320, 60, QImage::Format_RGB32);
paper.fill(0xffffffffu);
QPainter ink(&paper);
page->drawContents(&ink);
ink.end();
int darkest = 255;
for (int y = 0; y < paper.height(); y++) {
const QRgb *row = reinterpret_cast<const QRgb *>(paper.constScanLine(y));
for (int x = 0; x < paper.width(); x++)
darkest = qMin(darkest, qGray(row[x]));
}
check(darkest < 64,
QStringLiteral("a dark window still lays out black ink on paper "
"(darkest pixel %1)")
.arg(darkest));
QGuiApplication::setPalette(was);
}
/* A tool that takes minutes -- a build, a wide search -- must not read as a
* window that has hung: while a card runs, its chip counts. */
{
QList<QQuickItem *> cards;
collectNamed(win->contentItem(), QStringLiteral("toolCard"), &cards);
check(!cards.isEmpty(),
QStringLiteral("the fixture session has a tool card to watch"));
if (!cards.isEmpty()) {
QQuickItem *card = cards.first();
card->setProperty("state", "running");
/* four minutes eighteen in, the point where "running" alone stopped
* being an answer */
card->setProperty(
"startedAt",
static_cast<double>(QDateTime::currentMSecsSinceEpoch()) - 258000.0);
card->setProperty("runningFor", 258);
QList<QQuickItem *> chips;
collectNamed(card, QStringLiteral("toolChip"), &chips);
const QString counting = chips.isEmpty()
? QString()
: chips.first()->property("text").toString();
check(counting.contains(QLatin1String("4:18")),
QStringLiteral("a running card says how long it has been going (%1)")
.arg(counting));
card->setProperty("state", "done");
const QString settled = chips.isEmpty()
? QString()
: chips.first()->property("text").toString();
check(!settled.contains(QLatin1String("4:18")),
QStringLiteral("and a finished card stops counting (%1)")
.arg(settled));
}
}
/* new sessions: the engine moves, the view empties, the store grows */ /* new sessions: the engine moves, the view empties, the store grows */
const int before = core.sessions().size(); const int before = core.sessions().size();
clickMouse(win, QStringLiteral("newSessionButton")); clickMouse(win, QStringLiteral("newSessionButton"));
@@ -439,27 +810,46 @@ int runUiTest(AgentCore &core, const QString &size) {
/* and back to the fixture, which still has its six rows */ /* and back to the fixture, which still has its six rows */
QTest::qWait(150); QTest::qWait(150);
/* The sidebar holds two more sessions by now, so the row may sit below the
* fold: brought into view first, or the click goes to the row that happens
* to be there (see scrollIntoView). */
if (QQuickItem *row = item(win, QStringLiteral("sessionRow:") + fixture)) {
scrollIntoView(row);
frame(win);
}
clickMouse(win, QStringLiteral("sessionRow:") + fixture); clickMouse(win, QStringLiteral("sessionRow:") + fixture);
check(core.sessionId() == fixture && list && check(core.sessionId() == fixture && list &&
list->property("count").toInt() == 6, list->property("count").toInt() == 6,
QStringLiteral("switching back replays it again")); QStringLiteral("switching back replays it again"));
{ {
bool promptImage = false; bool promptImage = false;
int promptImageRow = -1;
const QVariantList rows = core.history(); const QVariantList rows = core.history();
for (const QVariant &v : rows) { for (int i = 0; i < rows.size(); i++) {
const QVariant &v = rows.at(i);
const QVariantMap r = v.toMap(); const QVariantMap r = v.toMap();
if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") && if (r.value(QStringLiteral("role")).toString() == QLatin1String("user") &&
r.value(QStringLiteral("images")).toString().contains( r.value(QStringLiteral("images")).toString().contains(
QLatin1String("|"))) QLatin1String("|"))) {
promptImage = true; promptImage = true;
if (promptImageRow < 0)
promptImageRow = i;
}
} }
check(promptImage, check(promptImage,
QStringLiteral("a stored prompt image comes back with its row")); QStringLiteral("a stored prompt image comes back with its row"));
/* The view follows the tail (ChatView.stickBottom) and the picture is in
* the first prompt: on a window that fits fewer rows than this one it is
* above the visible part, where a delegate is not created at all. Put it
* on screen, which is what a person does before looking at it. */
if (list && promptImageRow >= 0)
QMetaObject::invokeMethod(list, "positionViewAtIndex",
Q_ARG(int, promptImageRow), Q_ARG(int, 0));
/* The rows are in the model by now; the cards -- and the thumbnails in /* The rows are in the model by now; the cards -- and the thumbnails in
* them -- are built on the view's next layout pass. A frame forces it, * them -- are built on the view's next layout pass. A frame forces it,
* and that frame is also the picture a person looks at. */ * and that frame is also the picture a person looks at. */
maybeShot(win, QStringLiteral("-images")); maybeShot(win, QStringLiteral("-images"));
QTest::qWait(150); frame(win);
if (qEnvironmentVariableIsSet("CST_APP_DUMP")) { if (qEnvironmentVariableIsSet("CST_APP_DUMP")) {
QVariant v; QVariant v;
QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v), QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
@@ -489,6 +879,52 @@ int runUiTest(AgentCore &core, const QString &size) {
clickMouse(win, QStringLiteral("settingsButton")); clickMouse(win, QStringLiteral("settingsButton"));
QQuickItem *panel = item(win, QStringLiteral("settingsPanel")); QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog")); check(panel && panel->isVisible(), QStringLiteral("the gear opens the settings dialog"));
/* Where the pieces actually are, whether or not that worked: a click goes to
* whatever is on top at that point, and a style lays a header out its own
* way -- the phone runs a different style from a desktop (Universal against
* Basic), which is where the header's content ended up 35 px below the
* header itself. */
{
QQuickItem *bar = item(win, QStringLiteral("appBar"));
const QMargins safe = win->property("safeAreaMargins").value<QMargins>();
note(QStringLiteral("style %1, window %2x%3, header y %4 h %5, "
"gear centre y %6, safe area %7/%8/%9/%10")
.arg(QQuickStyle::name())
.arg(win->width())
.arg(win->height())
.arg(bar ? bar->y() : -1)
.arg(bar ? bar->height() : -1)
.arg(item(win, QStringLiteral("settingsButton"))
? centre(item(win, QStringLiteral("settingsButton"))).y()
: -1)
.arg(safe.left())
.arg(safe.top())
.arg(safe.right())
.arg(safe.bottom()));
}
/* item() answers with an item that exists even when nothing is visible (the
* QObject-tree fallback), so "the dialog is not open" is the invisible case
* too. */
if (!panel || !panel->isVisible()) {
/* Two ways to be wrong -- a click that never lands, and a handler that
* does nothing -- and from the outside they look the same (this failed
* only on the phone, with no QML error at all). Say which, and go on with
* the dialog either way, so the checks below still say something. */
QQuickItem *gear = item(win, QStringLiteral("settingsButton"));
check(gear != nullptr && gear->isVisible(),
QStringLiteral("... the gear is %1, centre %2,%3 of %4x%5")
.arg(gear && gear->isVisible() ? QStringLiteral("reachable")
: QStringLiteral("not there"))
.arg(gear ? centre(gear).x() : -1)
.arg(gear ? centre(gear).y() : -1)
.arg(win->width())
.arg(win->height()));
QMetaObject::invokeMethod(win, "openSettings");
QTest::qWait(120);
panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(),
QStringLiteral("... and openSettings() opens it when called directly"));
}
check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0, check(countNamed(win->contentItem(), QStringLiteral("providerRow:")) > 0,
QStringLiteral("the dialog lists the providers")); QStringLiteral("the dialog lists the providers"));
maybeShot(win, QStringLiteral("-settings")); maybeShot(win, QStringLiteral("-settings"));
@@ -641,6 +1077,7 @@ int runUiTest(AgentCore &core, const QString &size) {
/* the paste path: an image on the clipboard becomes an attachment */ /* the paste path: an image on the clipboard becomes an attachment */
QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance()); QGuiApplication *gui = qobject_cast<QGuiApplication *>(QCoreApplication::instance());
#if !defined(Q_OS_ANDROID)
if (gui && gui->clipboard()) { if (gui && gui->clipboard()) {
QImage img(8, 8, QImage::Format_RGB32); QImage img(8, 8, QImage::Format_RGB32);
img.fill(Qt::red); img.fill(Qt::red);
@@ -653,6 +1090,13 @@ int runUiTest(AgentCore &core, const QString &size) {
} else { } else {
check(false, QStringLiteral("there is a clipboard to paste from")); check(false, QStringLiteral("there is a clipboard to paste from"));
} }
#else
/* Android's clipboard carries text and URIs, not images: setting one there
* does nothing, and a phone that "fails" this is not a phone that is
* broken. The paste path itself is the same code the desktop runs. */
(void)gui;
note(QStringLiteral("clipboard image checks skipped (Android)"));
#endif
/* --- tool calls: two calls of one tool are two cards ------------------ */ /* --- tool calls: two calls of one tool are two cards ------------------ */
const int rows0 = list ? list->property("count").toInt() : -1; const int rows0 = list ? list->property("count").toInt() : -1;
@@ -733,9 +1177,13 @@ int runUiTest(AgentCore &core, const QString &size) {
/* --- the image provider: only the session's own files ----------------- */ /* --- the image provider: only the session's own files ----------------- */
{ {
const QString dir = core.attachmentDir(); const QString dir = core.attachmentDir();
/* one directory per session since v0.9.5: <sessions>/<id>/attachments */ /* one directory per session since v0.9.5: <sessions>/<id>/attachments.
check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) && * The engine builds the path with the separator of the platform it runs
dir.contains(QLatin1String("/sessions/")), * on, so what arrives here is "…/sessions\<id>\attachments" on Windows:
* asked with forward slashes, which is what the check means. */
const QString shown = QDir::fromNativeSeparators(dir);
check(!shown.isEmpty() && shown.endsWith(QLatin1String("/attachments")) &&
shown.contains(QLatin1String("/sessions/")),
QStringLiteral("the agent names its image directory (%1)").arg(dir)); QStringLiteral("the agent names its image directory (%1)").arg(dir));
QImage pic(32, 16, QImage::Format_RGB32); QImage pic(32, 16, QImage::Format_RGB32);
pic.fill(Qt::darkGreen); pic.fill(Qt::darkGreen);
@@ -776,6 +1224,14 @@ int runUiTest(AgentCore &core, const QString &size) {
const int before = list->property("count").toInt(); const int before = list->property("count").toInt();
const int thumbsBefore = const int thumbsBefore =
countNamed(win->contentItem(), QStringLiteral("imageThumb")); countNamed(win->contentItem(), QStringLiteral("imageThumb"));
if (qEnvironmentVariableIsSet("CST_APP_DUMP") && list)
std::printf("note: tool card: model %d, contentY %.1f of %.1f (view %.1f), "
"%d strips, %d thumbs, %d cards\n", before,
list->property("contentY").toDouble(),
list->property("contentHeight").toDouble(), list->height(),
countNamed(win->contentItem(), QStringLiteral("imageStrip")),
thumbsBefore,
countNamed(win->contentItem(), QStringLiteral("card:")));
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"), event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-img"),
QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}")); QStringLiteral("view_image"), QStringLiteral("{\"path\":\"shot.png\"}"));
const QString imgs = const QString imgs =
@@ -784,7 +1240,7 @@ int runUiTest(AgentCore &core, const QString &size) {
Q_ARG(QVariant, QStringLiteral("call-img")), Q_ARG(QVariant, QStringLiteral("call-img")),
Q_ARG(QVariant, imgs)), Q_ARG(QVariant, imgs)),
QStringLiteral("images a tool made reach the view")); QStringLiteral("images a tool made reach the view"));
QTest::qWait(150); frame(win);
const QVariantMap card = rowAt(before); const QVariantMap card = rowAt(before);
check(card.value(QStringLiteral("images")) check(card.value(QStringLiteral("images"))
.toString() .toString()
@@ -794,7 +1250,9 @@ int runUiTest(AgentCore &core, const QString &size) {
QStringLiteral("the card of that call carries the image")); QStringLiteral("the card of that call carries the image"));
check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) > check(countNamed(win->contentItem(), QStringLiteral("imageThumb")) >
thumbsBefore, thumbsBefore,
QStringLiteral("its thumbnail is in the card")); QStringLiteral("its thumbnail is in the card (%1 -> %2)").arg(thumbsBefore)
.arg(countNamed(win->contentItem(),
QStringLiteral("imageThumb"))));
maybeShot(win, QStringLiteral("-toolimage")); maybeShot(win, QStringLiteral("-toolimage"));
} }
@@ -820,7 +1278,7 @@ int runUiTest(AgentCore &core, const QString &size) {
Q_ARG(QVariant, QStringLiteral("call-file")), Q_ARG(QVariant, QStringLiteral("call-file")),
Q_ARG(QVariant, files)), Q_ARG(QVariant, files)),
QStringLiteral("the files a tool delivered reach the view")); QStringLiteral("the files a tool delivered reach the view"));
QTest::qWait(150); frame(win);
const QVariantMap card = rowAt(before); const QVariantMap card = rowAt(before);
check(card.value(QStringLiteral("images")) check(card.value(QStringLiteral("images"))
.toString() .toString()
@@ -842,6 +1300,14 @@ int runUiTest(AgentCore &core, const QString &size) {
clickMouse(win, QStringLiteral("navButton")); clickMouse(win, QStringLiteral("navButton"));
check(navPanel && navPanel->isVisible(), check(navPanel && navPanel->isVisible(),
QStringLiteral("drawer opens over the transcript")); QStringLiteral("drawer opens over the transcript"));
/* A phone closes the drawer with the back key, not with a second tap. */
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(120);
check(navPanel && !navPanel->isVisible(),
QStringLiteral("back closes the drawer"));
clickMouse(win, QStringLiteral("navButton"));
check(navPanel && navPanel->isVisible(),
QStringLiteral("and the drawer button opens it again"));
clickMouse(win, QStringLiteral("navButton")); clickMouse(win, QStringLiteral("navButton"));
check(navPanel && !navPanel->isVisible(), check(navPanel && !navPanel->isVisible(),
QStringLiteral("drawer closes again")); QStringLiteral("drawer closes again"));
@@ -850,10 +1316,66 @@ int runUiTest(AgentCore &core, const QString &size) {
check(docked && docked->isVisible(), check(docked && docked->isVisible(),
QStringLiteral("widening again docks the sidebar back")); QStringLiteral("widening again docks the sidebar back"));
/* --- the phone's back key closes what is open -------------------------- */
/* This one is last on purpose: on the offscreen platform a popup closed by a
* key leaves its modal overlay over the window -- the dialog says it is
* closed while the overlay still eats clicks. Nothing that clicks may come
* after it. */
{
QMetaObject::invokeMethod(win, "openSettings");
QTest::qWait(300);
QQuickItem *panel = item(win, QStringLiteral("settingsPanel"));
check(panel && panel->isVisible(),
QStringLiteral("back: the settings dialog is open to close"));
/* Which layer is open is asked of QML, not of an item's `visible`: the
* dialog's content item lives on after a close and still reports visible
* while it fades. backLayer() is what the Back key reads, so the test asks
* for exactly that. */
QVariant before;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, before));
check(before.toString() == QStringLiteral("settings"),
QStringLiteral("back: the settings layer is the one open"));
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(200);
QVariant after;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, after));
note(QStringLiteral("after back: layer \"%1\"").arg(after.toString()));
check(after.toString().isEmpty(),
QStringLiteral("back closes the dialog instead of the window"));
check(win->isVisible(), QStringLiteral("and the window is still there"));
/* With nothing open the key belongs to the platform: a phone leaves the
* window with it, so this side must not take it. On a desktop nothing
* happens and the window has to still be there; on a phone the window
* going away *is* the hand-over, so what is asked is that no layer was
* touched (whether the activity is already gone is the platform's own
* timing). This is the last thing the run does on a phone: the activity
* leaving takes the process with it, so the log can end on the check below
* without the closing line -- every line is flushed as it is written. */
QTest::keyClick(win, Qt::Key_Back);
QTest::qWait(120);
QVariant idle;
QMetaObject::invokeMethod(win, "backLayer", Q_RETURN_ARG(QVariant, idle));
#if defined(Q_OS_ANDROID)
note(QStringLiteral("after the last back: window visible %1")
.arg(win->isVisible() ? 1 : 0));
check(idle.toString().isEmpty(),
QStringLiteral("back with nothing open is left to the platform"));
#else
check(idle.toString().isEmpty() && win->isVisible(),
QStringLiteral("back with nothing open is left to the platform"));
#endif
}
/* A picture of the last state on request (see maybeShot). */ /* A picture of the last state on request (see maybeShot). */
maybeShot(win, QString()); maybeShot(win, QString());
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed"); std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
if (g_runLog.isOpen()) {
g_runLog.write("--- ");
g_runLog.write(g_fail ? "FAILED" : "all UI checks passed");
g_runLog.write("\n");
g_runLog.close();
}
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }
+99
View File
@@ -0,0 +1,99 @@
# NixOS and other nix users: the desktop front end (Qt Quick) with the engine
# it embeds, built and installed the nix way.
#
# nix-build # ./result/bin/cst-agent-app, run it in place
# nix-env -f . -i cst-agent # into the user profile: PATH, menu entry, icon
#
# The Qt apps hook is what makes the installed binary find Qt's plugins and
# QML modules outside a nix-shell (this is also what keeps them out of
# nix-collect-garbage's reach). Only the window is installed: the CLI has its
# own installer, scripts/bootstrap_linux.sh.
{ pkgs ? import <nixpkgs> { } }:
let
# The version lives in one place: the header the engine and the APK read.
vLine = re:
let
hit = pkgs.lib.findFirst
(l: builtins.match "#define ${re} ([0-9]+)" l != null) ""
(pkgs.lib.splitString "\n" (builtins.readFile ./include/cst_version.h));
in
builtins.head (builtins.match "#define ${re} ([0-9]+)" hit);
in
pkgs.stdenv.mkDerivation {
pname = "cst-agent";
version =
"${vLine "CST_VERSION_MAJOR"}.${vLine "CST_VERSION_MINOR"}.${vLine "CST_VERSION_PATCH"}";
# Not the whole checkout: third_party/ (the static curl/mbedTLS/ncurses
# sources, the NDK builds), the build directories and the session store are
# gigabytes this build never reads -- curl, ncurses and Qt come from nixpkgs.
src = builtins.filterSource
(path: type:
!(builtins.elem (baseNameOf path) [
".agent"
".cursor"
".gen"
".git"
"build-android-arm64-v8a"
"build-apk-android"
"build-win-tui"
"dist"
"result"
"third_party"
]))
./.;
nativeBuildInputs = with pkgs; [ cmake pkg-config qt6.wrapQtAppsHook ];
# qtwayland so a Wayland session gets a native client, qtsvg for the icon
# theme: Breeze's icons are SVG, and without the image format plugin the
# window falls back to text glyphs.
buildInputs = with pkgs; [
qt6.qtbase
qt6.qtdeclarative
qt6.qtwayland
qt6.qtsvg
curl
curl.dev
ncurses
ncurses.dev
];
cmakeFlags = [ "-DCST_BUILD_APP=ON" "-DCMAKE_BUILD_TYPE=Release" ];
# The "app" component is the window, its launcher entry and the icon;
# app/CMakeLists.txt is the one place that knows where each goes.
installPhase = ''
runHook preInstall
cmake --install . --component app --prefix "$out"
runHook postInstall
'';
doCheck = true;
# One test cannot pass here: tests/test_sandbox.c has the engine run a
# sandboxed command and asserts Landlock *denied* the write outside the
# workspace, and Landlock does not work inside the nix build sandbox -- the
# command succeeds and the assertion trips. The other 40 run; the full set
# is `make check` / `ctest` in the dev tree.
checkPhase = ''
runHook preCheck
ctest --output-on-failure -j"$NIX_BUILD_CORES" -E test_sandbox
runHook postCheck
'';
# The tests write settings, sessions, undo files and a scratch workspace:
# give them a home of their own.
preCheck = ''
export HOME="$TMPDIR/home"
export XDG_CONFIG_HOME="$HOME/.config" XDG_CACHE_HOME="$HOME/.cache"
export XDG_DATA_HOME="$HOME/.local/share"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME"
'';
meta = {
description =
"Local coding agent: Qt Quick front end with the engine in-process";
homepage = "https://git.whcself.xyz/WHC/CST-agent";
platforms = pkgs.lib.platforms.linux;
mainProgram = "cst-agent-app";
};
}
+22 -3
View File
@@ -197,6 +197,25 @@ have, so the script rewrites the shebang of the build-tools wrappers.
start of the turn and released at its end, `run_shell sleep 60` finished with start of the turn and released at its end, `run_shell sleep 60` finished with
the screen dark, and the service was gone once the turn was over. the screen dark, and the service was gone once the turn was over.
`CST_APP_NO_SERVICE=1` keeps a test run from touching the phone. `CST_APP_NO_SERVICE=1` keeps a test run from touching the phone.
* Still to come: the SAF picker for attaching a picture from another app, the * **A turn that asked and got no answer**: a consent question cannot outlive
back gesture, and the icon glyphs -- `ThemeIcon` finds no icon files on the the app being off screen. `AgentCore::hookPermission()` / `hookAskUser()`
phone and draws empty boxes where the desktop shows Breeze icons. refuse (or drop the question) at once while the app is in the background --
Qt reports that as `Qt::ApplicationSuspended`, so the worker is never parked
on a dialog nobody can see, and the transcript carries a line saying why. In
the foreground the same two hooks wait two minutes (a permission) or five
(a question) and then answer "no" with a reason instead of waiting for good.
The GUI hop a platform tool makes (`onGuiThread()` in
`app/src/platform/android/AndroidTools.cpp`) pings first and gives up after
three seconds, and the header shows what the turn is doing
(`shell: make -j8`) so a long turn is not mistaken for a stuck one.
`--uitest` covers the window side of all this.
* **Documents**: `md_to_pdf` (the window's own tool,
`app/src/core/PdfTools.cpp`, owner `app:qt`) lays markdown or a workspace
`.md`/`.html` out as an A4 PDF with 15 mm margins and hands it over as a file
card, exactly the way `deliver_file` does; `QPrinter`/PrintSupport is not
used, so the phone gets it too. Qt's PDF engine embeds a font *subset*, which
is why a Chinese page is a few KB rather than the few MB a whole CJK font
would cost (see `app/README.md`).
* Still to come: the back gesture, and the icon glyphs -- `ThemeIcon` finds no
icon files on the phone and draws empty boxes where the desktop shows Breeze
icons.
+22
View File
@@ -12,6 +12,8 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
|---|---|---|---| |---|---|---|---|
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` | | `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` | | `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
| `kimi` | `https://api.moonshot.ai/v1` | `kimi-k3`(另有 `kimi-k2.6`、`kimi-k2.7-code`) | `MOONSHOT_API_KEY` |
| `kimi-code` | `https://api.kimi.ai/coding/v1` | `k3`(另有 `k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`) | `KIMI_API_KEY` |
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | | `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` | | `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
@@ -26,6 +28,12 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 | | Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) | | 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium`(low / medium / high / max) |
Kimi 的 K3 请求使用 `reasoning_effort`;K2.6/K2.7 使用 `thinking`,并在后续请求中回传 `reasoning_content`。默认发送 Kimi 1 小时隐式 prompt cache 提示,可用 `CST_KIMI_CACHE_TTL=5m|1h` 调整,或用 `CST_NO_CACHE_HINTS=1` 关闭。
当 provider 为 Kimi 或 DeepSeek 时,`web_search` 和 `web_fetch` 使用对应的官方联网 Search/Fetch API;其它 provider 使用通用 URL 抓取。
`kimi-code` 使用 Kimi Code 的 OpenAI-compatible API。它和按量计费的 `kimi` 开放平台使用不同的 Base URL 与 API Key,不能混用。
key 按 provider 保存在 credentials 文件(权限 0600),每行 `<provider>.api_key=...`; key 按 provider 保存在 credentials 文件(权限 0600),每行 `<provider>.api_key=...`;
TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显): TUI 里 `/key` 更换,命令行 `--ask-key` 输入(不回显):
- Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`) - Linux/macOS/Termux:`$XDG_CONFIG_HOME/cst-agent/credentials`(默认 `~/.config/cst-agent/`)
@@ -42,6 +50,20 @@ cst-agent --provider deepseek # 首次会询问 key
cst-agent --provider deepseek --model deepseek-v4-pro -e high # 推理内容显示为 Thought cst-agent --provider deepseek --model deepseek-v4-pro -e high # 推理内容显示为 Thought
``` ```
### Kimi
```bash
cst-agent --provider kimi # 默认 kimi-k3
cst-agent --provider kimi --model kimi-k2.6 # K2 thinking 模式
```
### Kimi Code
```bash
cst-agent --provider kimi-code # 默认 k3
cst-agent --provider kimi-code --model k3-256k
```
### OpenAI 官方 ### OpenAI 官方
```bash ```bash
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 9 #define CST_VERSION_MINOR 9
#define CST_VERSION_PATCH 6 #define CST_VERSION_PATCH 8
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+8
View File
@@ -2,6 +2,7 @@
#define CST_OS_COMPAT_H #define CST_OS_COMPAT_H
#include <stddef.h> #include <stddef.h>
#include <stdio.h>
#include <time.h> #include <time.h>
#ifdef __cplusplus #ifdef __cplusplus
@@ -12,6 +13,13 @@ extern "C" {
#define OS_PATH_MAX 4096 #define OS_PATH_MAX 4096
#endif #endif
/* --- text --- */
/* One line of a file into *lineptr, growing it as needed: POSIX getline(3),
* which a MinGW CRT does not have (the session file readers used it directly
* and only the Windows build noticed). Returns the number of bytes read -- 0
* for an empty line -- or -1 at the end of the file. */
long long os_getline(char **lineptr, size_t *n, FILE *stream);
/* --- path --- */ /* --- path --- */
int os_path_is_abs(const char *p); int os_path_is_abs(const char *p);
/* Join with native separator; out always uses '/' on Unix and '\\' on Win for /* Join with native separator; out always uses '/' on Unix and '\\' on Win for
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env bash
# Build and pack the Windows GUI (app/cst-agent-app.exe) from Linux.
#
# scripts/package_windows_app.sh (run it from the repo root)
# nix-shell shell-win.nix --run scripts/package_windows_app.sh
# --run works from anywhere; the script re-enters the shell itself when the
# mingw toolchain is not on PATH.
#
# The toolchain is the same one the TUI cross build uses (shell-win.nix: mingw
# UCRT, x86_64-w64-mingw32). Qt comes from MSYS2, prebuilt for exactly that
# target, and the host (Linux) tools come from nixpkgs' native Qt6: both are
# 6.11.2, which is what Qt's own version check wants.
#
# Why not nixpkgs' mingw Qt: it does not build in this nixpkgs. bash 5.3 fails
# to cross-compile for mingw (`sigset_t` is not in the mingw headers), which
# takes tcl down, which sqlite needs, which qtbase needs; before that, giflib's
# mingw patch no longer applies to its own source. Three separate overlays got
# as far as qtbase before that wall. MSYS2 has the same Qt prebuilt.
#
# MSYS2's Qt ships *Windows* tools (moc.exe, rcc.exe, qmlcachegen.exe, ...);
# the build runs on Linux, so each one is replaced by a one-line shim that
# calls the host tool. windeployqt cannot run here either, so the deployment
# half of this script copies the DLLs, plugins and QML modules by hand: the
# DLLs by walking the exe's imports, the rest from the layout Qt expects.
#
# Output: dist/windows-x86_64-app/ and
# dist/cst-agent-app-VERSION-windows-x86_64.zip (+ .sha256)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
MIRROR="${CST_MSYS_MIRROR:-https://mirror.msys2.org/mingw/ucrt64}"
PKGS="$ROOT/third_party/msys-qt6" # MSYS2 packages, extracted
PREFIX="$PKGS/ucrt64" # what CMAKE_PREFIX_PATH points at
HOST="${CST_QT6_HOST:-$ROOT/third_party/prefix-qt6-host}"
BUILD="$ROOT/build-win-app"
OUT="$ROOT/dist/windows-x86_64-app"
if ! command -v x86_64-w64-mingw32-gcc >/dev/null && [[ -z "${IN_NIX_SHELL:-}" ]] &&
command -v nix-shell >/dev/null; then
exec nix-shell "$ROOT/shell-win.nix" --run "$(printf '%q ' "$0" "$@")"
fi
command -v x86_64-w64-mingw32-gcc >/dev/null || {
echo "need the mingw UCRT toolchain (run through shell-win.nix)" >&2
exit 1
}
OBJDUMP="$(command -v x86_64-w64-mingw32-objdump || echo objdump)"
STRIP="$(command -v x86_64-w64-mingw32-strip || echo strip)"
# --- the MSYS2 packages ----------------------------------------------------
# The Qt modules the app links, plus every package that provides a DLL those
# modules load (found with x86_64-w64-mingw32-objdump -p, then against
# ucrt64.files from the same mirror). Versions are taken from the mirror
# listing, so a new Qt6 release only means a new run of this script.
MSYS_PKGS=(
mingw-w64-ucrt-x86_64-qt6-base
mingw-w64-ucrt-x86_64-qt6-declarative
mingw-w64-ucrt-x86_64-qt6-svg
mingw-w64-ucrt-x86_64-libgcc
mingw-w64-ucrt-x86_64-libstdc++
mingw-w64-ucrt-x86_64-libwinpthread
mingw-w64-ucrt-x86_64-libb2
mingw-w64-ucrt-x86_64-double-conversion
mingw-w64-ucrt-x86_64-icu
mingw-w64-ucrt-x86_64-pcre2
mingw-w64-ucrt-x86_64-zlib
mingw-w64-ucrt-x86_64-zstd
mingw-w64-ucrt-x86_64-freetype
mingw-w64-ucrt-x86_64-harfbuzz
mingw-w64-ucrt-x86_64-md4c
mingw-w64-ucrt-x86_64-libpng
mingw-w64-ucrt-x86_64-brotli
mingw-w64-ucrt-x86_64-bzip2
# harfbuzz reaches for these (and glib2 for libffi/gettext below): they are
# not what the app links directly, but the shaper the font engine loads.
mingw-w64-ucrt-x86_64-glib2
mingw-w64-ucrt-x86_64-graphite2
mingw-w64-ucrt-x86_64-libffi
mingw-w64-ucrt-x86_64-gettext-runtime
mingw-w64-ucrt-x86_64-libiconv
)
listing="$PKGS/listing.html"
mkdir -p "$PKGS"
if [[ ! -s "$listing" || -n "${CST_MSYS_REFRESH:-}" ]]; then
echo "==> mirror listing"
curl -fsSL "$MIRROR/" -o "$listing"
fi
# The newest file of a package (no -debug, no .sig): the mirror keeps every
# revision, and the listing is one HTTP page, so this is the whole lookup. The
# names come out of the links (the text of a link is elided with "..") and are
# matched literally, because a package name may hold regex characters
# ("libstdc++", which the listing writes as %2B and the link as a plus).
allPkgs="$(grep -oE 'href="[^"]+\.pkg\.tar\.zst"' "$listing" |
sed 's/^href="//; s/"$//; s/%2[bB]/+/g' | grep -v -- '-debug-' | sort -V -u)"
newest() { # newest PKGNAME
# ... and the name has to end there: "harfbuzz-" also prefixes
# "harfbuzz-utils", "brotli-" prefixes "brotli-testdata", "zlib-" prefixes
# "zlib-ng-compat". What follows the name is a version, which starts with a
# digit.
printf '%s\n' "$allPkgs" | awk -v pre="$1-" '
index($0, pre) == 1 && substr($0, length(pre) + 1) ~ /^[0-9]/' |
sort -V | tail -1
}
for p in "${MSYS_PKGS[@]}"; do
f="$(newest "$p")"
[[ -n "$f" ]] || { echo "no package $p in the mirror listing" >&2; exit 1; }
if [[ ! -f "$PKGS/$f" ]]; then
echo "==> $f"
curl -fsSL "$MIRROR/$f" -o "$PKGS/$f"
fi
# A package that is already extracted leaves its files in place: tar is
# happy to overwrite, so this just refreshes whatever changed.
tar --zstd -xf "$PKGS/$f" -C "$PKGS"
done
[[ -f "$PREFIX/lib/cmake/Qt6/Qt6Config.cmake" ]] || {
echo "$PREFIX does not look like a Qt6 install" >&2
exit 1
}
# --- the host tools --------------------------------------------------------
# nixpkgs' native Qt 6, in one prefix: moc, rcc, qmlcachegen, qmltyperegistrar,
# qmlimportscanner, qtpaths and their CMake packages (see scripts/qt6-host.nix).
if [[ ! -x "$HOST/libexec/moc" ]]; then
echo "==> host Qt6 tools"
nix build --impure -f "$ROOT/scripts/qt6-host.nix" --out-link "$HOST"
fi
# Every .exe MSYS2 ships is a Windows program: replace each with a shim that
# runs the host tool of the same name. Done in the extracted tree, so the
# Qt6*Tools CMake packages keep pointing at the paths they record.
shim() { # shim FILE HOSTTOOL
printf '#!/bin/sh\nexec %s "$@"\n' "$2" >"$1"
chmod +x "$1"
}
shimmed=0
for f in "$PREFIX"/share/qt6/bin/*.exe "$PREFIX"/bin/*.exe; do
[[ -e "$f" ]] || continue
n="$(basename "$f" .exe)"
for cand in "$HOST/libexec/$n" "$HOST/bin/$n"; do
if [[ -x "$cand" ]]; then
shim "$f" "$cand"
shimmed=$((shimmed + 1))
break
fi
done
done
echo "==> $shimmed Qt tools point at the host ones"
# --- build -----------------------------------------------------------------
echo "==> cmake (Windows, GUI)"
cmake -S "$ROOT" -B "$BUILD" -G Ninja \
-DCMAKE_TOOLCHAIN_FILE="$ROOT/cmake/mingw-w64-x86_64.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DCST_BUILD_TUI=OFF -DCST_BUILD_APP=ON \
-DCMAKE_PREFIX_PATH="$PREFIX" \
-DQT_HOST_PATH="$HOST"
# Only the app: the Win32 test targets are host-unrunnable and some of them are
# POSIX (tests/test_tools_ext.c calls mkdir with two arguments), exactly as in
# the TUI cross build.
cmake --build "$BUILD" -j"$(nproc)" --target cst-agent-app
EXE="$BUILD/app/cst-agent-app.exe"
[[ -f "$EXE" ]] || { echo "no cst-agent-app.exe" >&2; exit 1; }
# --- the package -----------------------------------------------------------
ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' \
"$ROOT/include/cst_version.h" | paste -sd.)"
rm -rf "$OUT"
mkdir -p "$OUT"
cp -f "$EXE" "$OUT/cst-agent-app.exe"
"$STRIP" "$OUT/cst-agent-app.exe" 2>/dev/null || true
# Which DLLs the app needs: the exe's imports, then theirs, and so on. What
# Windows itself provides is skipped; anything else has to come out of the
# extracted packages, which is what decided the list of packages above.
missing=0
isSystem() { # isSystem DLLNAME
case "${1,,}" in
api-ms-win-*|kernel32.dll|user32.dll|advapi32.dll|shell32.dll|ws2_32.dll|\
iphlpapi.dll|bcrypt.dll|crypt32.dll|secur32.dll|ole32.dll|oleaut32.dll|\
gdi32.dll|comdlg32.dll|dwmapi.dll|uxtheme.dll|winmm.dll|dnsapi.dll|\
version.dll|shlwapi.dll|netapi32.dll|userenv.dll|mpr.dll|ntdll.dll|\
authz.dll|d3d11.dll|d3d12.dll|dwrite.dll|dxgi.dll|winhttp.dll|\
rpcrt4.dll|usp10.dll)
return 0 ;;
esac
return 1
}
copyDeps() { # copyDeps FILE...
local queue=("$@") f n src
while ((${#queue[@]})); do
f="${queue[0]}"
queue=("${queue[@]:1}")
while read -r n; do
[[ -n "$n" ]] || continue
isSystem "$n" && continue
[[ -e "$OUT/$n" ]] && continue
src=""
for c in "$PREFIX/bin/$n" "$PREFIX/lib/$n"; do
[[ -e "$c" ]] && src="$c" && break
done
if [[ -z "$src" ]]; then
echo "missing DLL: $n (needed by $(basename "$f"))" >&2
missing=1
continue
fi
cp -f "$src" "$OUT/$n"
queue+=("$OUT/$n")
done < <("$OBJDUMP" -p "$f" | awk '/DLL Name/{print $3}')
done
}
copyDeps "$OUT/cst-agent-app.exe"
# The plugins Qt loads by name: the window itself (and the offscreen one, so a
# headless run is possible), the image formats a transcript can carry, the SVG
# icon engine (the app's icons are .svg through QIcon).
mkdir -p "$OUT/plugins"
for p in platforms/qwindows.dll platforms/qoffscreen.dll \
iconengines/qsvgicon.dll; do
[[ -f "$PREFIX/share/qt6/plugins/$p" ]] || {
echo "no plugin $p" >&2
missing=1
continue
}
mkdir -p "$OUT/plugins/$(dirname "$p")"
cp -f "$PREFIX/share/qt6/plugins/$p" "$OUT/plugins/$p"
done
for p in imageformats/*.dll; do
[[ -e "$PREFIX/share/qt6/plugins/$p" ]] || continue
mkdir -p "$OUT/plugins/imageformats"
cp -f "$PREFIX/share/qt6/plugins/$p" "$OUT/plugins/imageformats/"
done
# The QML modules the window imports (QtQuick, QtQuick.Controls and its Basic
# style, Layouts, Window, ...): the whole tree, which is what a deployed Qt app
# has next to its exe, and what the QML import path picks up from <exe>/qml.
echo "==> QML modules"
cp -r "$PREFIX/share/qt6/qml" "$OUT/qml"
# ... and the plugins those QML modules load (a style, a helper, an image
# provider: each is a DLL with its own imports).
while IFS= read -r d; do
copyDeps "$d"
done < <(find "$OUT/qml" -name '*.dll')
# What the engine's shell tool wants beside it, exactly as the TUI package
# ships it: the static busybox for Windows is the shell the engine runs.
if [[ -f "$ROOT/third_party/busybox/busybox.exe" ]]; then
cp -f "$ROOT/third_party/busybox/busybox.exe" "$OUT/busybox.exe"
cp -f "$ROOT/third_party/busybox/LICENSE.busybox" "$OUT/" 2>/dev/null || true
fi
# Qt finds its plugins and QML imports relative to the exe through this file.
cat >"$OUT/qt.conf" <<'EOF'
[Paths]
Prefix = .
Plugins = plugins
Qml2Imports = qml
EOF
echo "$ver" >"$OUT/VERSION"
if [[ "$missing" != 0 ]]; then
echo "the package is incomplete (see the missing entries above)" >&2
exit 1
fi
zip="$ROOT/dist/cst-agent-app-$ver-windows-x86_64.zip"
rm -f "$zip"
(cd "$ROOT/dist" && zip -qr "$zip" windows-x86_64-app)
(cd "$ROOT/dist" && sha256sum "$(basename "$zip")" >"$(basename "$zip").sha256")
du -sh "$OUT"
ls -l "$zip"
echo "$zip"
+38
View File
@@ -0,0 +1,38 @@
/* The host (Linux) side of the Windows GUI cross build: nixpkgs' native Qt 6
* tools in one prefix, for QT_HOST_PATH.
*
* MSYS2's Qt for Windows ships moc.exe, rcc.exe, qmlcachegen.exe and the rest
* as Windows programs, which a Linux build cannot run; Qt wants the host tools
* to be the *same version* as the target Qt (6.11.2 here, which is what
* nixpkgs' Qt 6 is), so they come from nixpkgs rather than from MSYS2.
*
* scripts/package_windows_app.sh builds this and points the cross build at it.
* Not a package: nothing installs it, it is a prefix assembled from the store
* paths of qtbase/qtdeclarative/qtsvg (bin, libexec, lib/cmake, share/qt6).
*/
with import <nixpkgs> {};
let
qtPkgs = [
qt6.qtbase
qt6.qtbase.dev
qt6.qtdeclarative
qt6.qtdeclarative.dev
qt6.qtsvg
qt6.qtsvg.dev
];
script = ''
set -eu
mkdir -p $out
for p in ${builtins.concatStringsSep " " (map toString qtPkgs)}; do
[ -d "$p" ] || continue
for d in bin lib/cmake libexec share/qt6; do
if [ -d "$p/$d" ]; then
mkdir -p "$out/$d"
cp -rs "$p/$d/." "$out/$d/" 2>/dev/null || true
fi
done
done
'';
in
runCommand "qt6-host" { } script
+3
View File
@@ -16,6 +16,9 @@ win.mkShell {
zip zip
curl curl
nsis nsis
# The Windows GUI package unpacks MSYS2's prebuilt Qt6 (.pkg.tar.zst) --
# zstd is what tar needs for that (scripts/package_windows_app.sh).
zstd
]; ];
buildInputs = [ win.windows.pthreads ]; buildInputs = [ win.windows.pthreads ];
# mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly. # mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly.
+53 -20
View File
@@ -444,38 +444,71 @@ int cst_agent_store_info(const char *workspace, const char *id,
FILE *fp = fopen(p, "rb"); FILE *fp = fopen(p, "rb");
if (!fp) if (!fp)
return 0; return 0;
size_t cap = 1 << 16; /* Reading a transcript must stay cheap: the session list asks for this for
char *line = malloc(cap); * every stored session, on the GUI thread, every time the store changes.
int have_first = 0; * So a line is only ever *parsed* while the first user message is still
while (line && fgets(line, (int)cap, fp)) { * missing and only within the first kFirstUserBytes; everything else is
size_t n = strlen(line); * counted by looking for newlines, which costs a memchr per block instead
if (n == cap - 1 && line[n - 1] != '\n') { * of a cJSON tree per line. */
/* very long line: skip the rest, it is not the first user line */ enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 };
int ch; char *buf = malloc(kBlock);
while ((ch = fgetc(fp)) != EOF && ch != '\n') if (!buf) {
; fclose(fp);
info->messages++; return 0;
continue;
} }
if (n <= 1) size_t have = 0; /* bytes in buf that belong to the current line */
continue; size_t seen = 0; /* bytes already consumed by finished lines */
int slice = 0; /* the current line did not fit: its tail is dropped */
int want_first = 1; /* still looking for the first user message */
for (;;) {
size_t got = fread(buf + have, 1, kBlock - have, fp);
size_t total = have + got;
size_t at = 0;
for (;;) {
char *nl = memchr(buf + at, '\n', total - at);
if (!nl)
break;
const size_t len = (size_t)(nl - (buf + at));
/* A line counts once. A line that ran past a whole block was counted
* when its tail was dropped, so the newline that ends it is not a line
* of its own. */
if (len > 0 && !slice)
info->messages++; info->messages++;
if (have_first) if (!slice && len > 0 && want_first && seen < kFirstUserBytes) {
continue; *nl = 0; /* the parse reads a C string; the newline is ours */
cJSON *o = cJSON_Parse(line); cJSON *o = cJSON_Parse(buf + at);
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role"); const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content"); const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
const cJSON *k = cJSON_GetObjectItemCaseSensitive( const cJSON *k = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind"); cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0; const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note && if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
cJSON_IsString(c) && c->valuestring[0]) { cJSON_IsString(c) && c->valuestring[0]) {
clip_line(info->first_user, sizeof(info->first_user), c->valuestring); clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
have_first = 1; want_first = 0;
} }
cJSON_Delete(o); cJSON_Delete(o);
} }
free(line); seen += len + 1;
at += len + 1;
slice = 0;
}
have = total - at;
if (got == 0)
break; /* EOF */
if (have == kBlock) {
/* a line longer than one block: count it, drop its tail, keep going */
info->messages++;
seen += have;
have = 0;
slice = 1;
} else if (have > 0) {
memmove(buf, buf + at, have);
}
}
if (have > 0 && !slice)
info->messages++; /* the last line, without a newline */
free(buf);
fclose(fp); fclose(fp);
return 0; return 0;
} }
+47 -3
View File
@@ -22,6 +22,25 @@
#define ERRBODY_MAX 4096 #define ERRBODY_MAX 4096
#define LLM_MAX_RETRIES 3 #define LLM_MAX_RETRIES 3
static int api_host_has(const char *base, const char *needle) {
return base && needle && strstr(base, needle) != NULL;
}
static int is_kimi_api(const char *base) {
return api_host_has(base, "api.moonshot.ai") ||
api_host_has(base, "api.moonshot.cn") ||
api_host_has(base, "api.kimi.ai") ||
api_host_has(base, "api.kimi.com");
}
static int is_kimi_k2(const char *model) {
return model && strncmp(model, "kimi-k2", 7) == 0;
}
static int is_kimi_k27(const char *model) {
return model && strncmp(model, "kimi-k2.7-code", 14) == 0;
}
static int ca_file_ok(const char *path) { static int ca_file_ok(const char *path) {
if (!path || !path[0]) if (!path || !path[0])
return 0; return 0;
@@ -452,8 +471,31 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
return NULL; return NULL;
cJSON *root = cJSON_CreateObject(); cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "model", req->model); cJSON_AddStringToObject(root, "model", req->model);
if (req->reasoning_effort && req->reasoning_effort[0]) const int kimi = is_kimi_api(req->api_base);
const int kimi_k2 = kimi && is_kimi_k2(req->model);
if (req->reasoning_effort && req->reasoning_effort[0] && !kimi_k2)
cJSON_AddStringToObject(root, "reasoning_effort", req->reasoning_effort); cJSON_AddStringToObject(root, "reasoning_effort", req->reasoning_effort);
if (kimi_k2) {
/* K2.6/K2.7 do not understand K3's top-level reasoning_effort. They
* expose thinking as an object and require preserved reasoning to be
* sent back on the next turn. K2.7-code is always enabled/kept. */
cJSON *thinking = cJSON_AddObjectToObject(root, "thinking");
cJSON_AddStringToObject(thinking, "type", "enabled");
if (req->send_reasoning || is_kimi_k27(req->model))
cJSON_AddStringToObject(thinking, "keep", "all");
}
if (kimi && (!getenv("CST_NO_CACHE_HINTS") ||
!getenv("CST_NO_CACHE_HINTS")[0] ||
getenv("CST_NO_CACHE_HINTS")[0] == '0')) {
/* Kimi's implicit cache defaults to 5m. Agent sessions commonly pause
* longer than that, so opt into the 1h tier; users can select 5m/1h. */
const char *ttl = getenv("CST_KIMI_CACHE_TTL");
if (!ttl || (strcmp(ttl, "5m") != 0 && strcmp(ttl, "1h") != 0))
ttl = "1h";
cJSON *cache = cJSON_AddObjectToObject(root, "prompt_cache_options");
cJSON_AddStringToObject(cache, "mode", "implicit");
cJSON_AddStringToObject(cache, "ttl", ttl);
}
cJSON_AddTrueToObject(root, "stream"); cJSON_AddTrueToObject(root, "stream");
const char *nousage = getenv("CST_NO_STREAM_USAGE"); const char *nousage = getenv("CST_NO_STREAM_USAGE");
if (!nousage || !nousage[0] || nousage[0] == '0') { if (!nousage || !nousage[0] || nousage[0] == '0') {
@@ -586,8 +628,10 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
if (req->send_reasoning && m->reasoning && m->reasoning[0]) if (req->send_reasoning && m->reasoning && m->reasoning[0])
cJSON_AddItemToObject(o, "reasoning_content", cJSON_AddItemToObject(o, "reasoning_content",
clean_string(m->reasoning, 0)); clean_string(m->reasoning, 0));
if (m->prefix) if (m->prefix) {
cJSON_AddTrueToObject(o, "prefix"); /* Kimi calls the OpenAI-style assistant prefill flag `partial`. */
cJSON_AddTrueToObject(o, kimi ? "partial" : "prefix");
}
} }
cJSON_AddItemToArray(msgs, o); cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_TOOL && n_att) if (role == CST_ROLE_TOOL && n_att)
+5
View File
@@ -876,6 +876,11 @@ const char *cst_effort_wire(const char *provider, const char *model,
pthread_mutex_unlock(&g_mu); pthread_mutex_unlock(&g_mu);
if (strcmp(mode, "passthrough") == 0) if (strcmp(mode, "passthrough") == 0)
return effort; return effort;
if (strcmp(mode, "kimi") == 0) {
/* Kimi K3 accepts low/high/max; the older K2 thinking models use the
* separate `thinking` object and only need a valid effort selection. */
return strcmp(effort, "medium") == 0 ? "low" : effort;
}
if (strcmp(mode, "openai") == 0) { if (strcmp(mode, "openai") == 0) {
cst_model_info_t mi; cst_model_info_t mi;
cst_model_info(provider, model, &mi); cst_model_info(provider, model, &mi);
+27
View File
@@ -15,6 +15,23 @@
"prefix_completion": true, "prefix_completion": true,
"effort": "passthrough" "effort": "passthrough"
}, },
"kimi": {
"api_base": "https://api.moonshot.ai/v1",
"key_env": "MOONSHOT_API_KEY",
"default_model": "kimi-k3",
"models": ["kimi-k3", "kimi-k2.6", "kimi-k2.7-code"],
"reasoning_passback": true,
"effort": "kimi"
},
"kimi-code": {
"api_base": "https://api.kimi.ai/coding/v1",
"key_env": "KIMI_API_KEY",
"default_model": "k3",
"models": ["k3", "k3-256k", "kimi-for-coding",
"kimi-for-coding-highspeed"],
"reasoning_passback": true,
"effort": "kimi"
},
"openai": { "openai": {
"api_base": "https://api.openai.com/v1", "api_base": "https://api.openai.com/v1",
"key_env": "OPENAI_API_KEY", "key_env": "OPENAI_API_KEY",
@@ -65,6 +82,16 @@
{"match": "glm", "context": 128000}, {"match": "glm", "context": 128000},
{"match": "glm-*v*", "vision": true}, {"match": "glm-*v*", "vision": true},
{"match": "kimi", "context": 131072}, {"match": "kimi", "context": 131072},
{"match": "kimi-k3", "context": 1048576, "vision": true,
"reasoning": true},
{"match": "kimi-k2.6", "context": 262144, "vision": true,
"reasoning": true},
{"match": "kimi-k2.7-code", "context": 262144, "reasoning": true},
{"match": "k3", "context": 1048576, "vision": true, "reasoning": true},
{"match": "k3-256k", "context": 262144, "reasoning": true},
{"match": "kimi-for-coding", "context": 1048576, "reasoning": true},
{"match": "kimi-for-coding-highspeed", "context": 262144,
"reasoning": true},
{"match": "pixtral", "vision": true}, {"match": "pixtral", "vision": true},
{"match": "llava", "vision": true}, {"match": "llava", "vision": true},
{"match": "*-vl*", "vision": true}, {"match": "*-vl*", "vision": true},
+37
View File
@@ -1217,3 +1217,40 @@ int unsetenv(const char *name) {
} }
#endif /* _WIN32 */ #endif /* _WIN32 */
/* getline(3) itself where the libc has it; the same thing spelled out with
* fgetc() where it does not (Windows), so the callers read the same on every
* platform. */
long long os_getline(char **lineptr, size_t *n, FILE *stream) {
#ifndef _WIN32
return (long long)getline(lineptr, n, stream);
#else
if (!lineptr || !n || !stream)
return -1;
if (!*lineptr || *n == 0) {
*n = 256;
*lineptr = (char *)malloc(*n);
if (!*lineptr)
return -1;
}
size_t len = 0;
int c = 0;
while ((c = fgetc(stream)) != EOF) {
if (len + 2 > *n) {
const size_t nn = (*n) * 2;
char *nb = (char *)realloc(*lineptr, nn);
if (!nb)
return -1;
*lineptr = nb;
*n = nn;
}
(*lineptr)[len++] = (char)c;
if (c == '\n')
break;
}
if (len == 0 && c == EOF)
return -1;
(*lineptr)[len] = 0;
return (long long)len;
#endif
}
+2 -34
View File
@@ -15,39 +15,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#ifdef _WIN32 #include "os_compat.h"
typedef long long cst_ssize_t;
static cst_ssize_t cst_getline(char **lineptr, size_t *n, FILE *stream) {
if (!lineptr || !n || !stream)
return -1;
if (!*lineptr || *n == 0) {
*n = 256;
*lineptr = (char *)malloc(*n);
if (!*lineptr)
return -1;
}
size_t len = 0;
int c = 0;
while ((c = fgetc(stream)) != EOF) {
if (len + 2 > *n) {
size_t nn = (*n) * 2;
char *nb = (char *)realloc(*lineptr, nn);
if (!nb)
return -1;
*lineptr = nb;
*n = nn;
}
(*lineptr)[len++] = (char)c;
if (c == '\n')
break;
}
if (len == 0 && c == EOF)
return -1;
(*lineptr)[len] = 0;
return (cst_ssize_t)len;
}
#define getline cst_getline
#endif
struct cst_session { struct cst_session {
char path[4096]; char path[4096];
@@ -158,7 +126,7 @@ static void load_existing(cst_session_t *s) {
return; return;
char *line = NULL; char *line = NULL;
size_t cap = 0; size_t cap = 0;
while (getline(&line, &cap, rp) != -1) { while (os_getline(&line, &cap, rp) != -1) {
cJSON *o = cJSON_Parse(line); cJSON *o = cJSON_Parse(line);
if (!o || !cJSON_IsObject(o)) { if (!o || !cJSON_IsObject(o)) {
cJSON_Delete(o); cJSON_Delete(o);
+14 -1
View File
@@ -159,6 +159,14 @@ static const cst_tool_def_t TOOLS[] = {
NULL, NULL,
{{"todos", S, 1, "the whole list, one item per line"}}, {{"todos", S, 1, "the whole list, one item per line"}},
CST_TP_META, 0, cst_tool_todo_write, NULL, NO_DYN, NO_META}, CST_TP_META, 0, cst_tool_todo_write, NULL, NO_DYN, NO_META},
{"web_search",
"Search the live web through the current Kimi or DeepSeek provider and "
"return titles, URLs and snippets. Use web_fetch to read a result.",
NULL,
{{"query", S, 1, "search query"},
{"count", I, 0, "number of results (default 5, max 20)"},
{"include_content", B, 0, "include provider-returned page content"}},
CST_TP_NET, 0, cst_tool_web_search, NULL, NO_DYN, NO_META},
{"web_fetch", {"web_fetch",
"Fetch an http(s) URL (docs, issues, APIs) and return its text; HTML " "Fetch an http(s) URL (docs, issues, APIs) and return its text; HTML "
"is reduced to readable text.", "is reduced to readable text.",
@@ -204,7 +212,12 @@ static const cst_tool_def_t TOOLS[] = {
"Search earlier and current sessions for text.", "Search earlier and current sessions for text.",
{{"query", S, 1, "text to look for"}, {{"query", S, 1, "text to look for"},
{"max_matches", I, 0, "stop after this many matches (default 20)"}, {"max_matches", I, 0, "stop after this many matches (default 20)"},
{"ignore_case", B, 0, "case-insensitive (default true)"}}, {"ignore_case", B, 0, "case-insensitive (default true)"},
{"max_bytes", I, 0,
"stop after this many bytes of transcripts (default 32 MB, newest "
"sessions first); 0 or unset means the default"},
{"max_seconds", I, 0,
"stop after this many seconds of searching (default 5)"}},
CST_TP_READ, 0, cst_tool_session_search, NULL, NO_DYN, NO_META}, CST_TP_READ, 0, cst_tool_session_search, NULL, NO_DYN, NO_META},
{"device_info", {"device_info",
"Where this agent is running: system and CPU count, workspace, sandbox, " "Where this agent is running: system and CPU count, workspace, sandbox, "
+52 -7
View File
@@ -83,12 +83,30 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
if (want > 100) if (want > 100)
want = 100; want = 100;
int icase = tool_arg_bool(c, "ignore_case", 1); int icase = tool_arg_bool(c, "ignore_case", 1);
/* A store grows without bound and this walks all of it, so it gets a
* budget: the walk stops when it is spent and says so, instead of making a
* turn sit for minutes on a workspace with a few hundred sessions. */
long budget_bytes = tool_arg_long(c, "max_bytes", 32L << 20);
if (budget_bytes <= 0)
budget_bytes = 32L << 20;
long budget_ms = tool_arg_long(c, "max_seconds", 5);
if (budget_ms <= 0)
budget_ms = 5;
budget_ms *= 1000;
const double t_start = os_now_ms();
long scanned_bytes = 0;
int cut = 0; /* the budget stopped the walk */
static char ids[200][128]; static char ids[200][128];
int n = cst_agent_store_list(ctx->workspace_root, ids, 200); int n = cst_agent_store_list(ctx->workspace_root, ids, 200);
cst_buf_t hits = {0}; cst_buf_t hits = {0};
int matches = 0, in_sessions = 0, scanned_sessions = 0; int matches = 0, in_sessions = 0, scanned_sessions = 0;
for (int i = 0; i < n && matches < (int)want; i++) { for (int i = 0; i < n && matches < (int)want; i++) {
if (scanned_bytes >= budget_bytes ||
os_now_ms() - t_start > (double)budget_ms) {
cut = 1;
break;
}
char path[OS_PATH_MAX]; char path[OS_PATH_MAX];
if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path, if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path,
sizeof(path)) != 0) sizeof(path)) != 0)
@@ -103,11 +121,27 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
char *line = NULL; char *line = NULL;
size_t cap = 0; size_t cap = 0;
cst_buf_t one = {0}; cst_buf_t one = {0};
ssize_t got; long long got;
while ((got = getline(&line, &cap, fp)) > 0) { while ((got = os_getline(&line, &cap, fp)) > 0) {
lineno++; lineno++;
if (got <= 1) scanned_bytes += got;
/* A tool output line can be megabytes (a whole file read back): such a
* line is not scanned -- one cJSON tree per block of it would be the
* slowest thing in the turn. */
if (got <= 1 || got > (1 << 16)) {
if (scanned_bytes >= budget_bytes) {
cut = 1;
break;
}
continue; continue;
}
/* the clock is asked every so often: a clock_gettime per line would be
* the next slowest thing after the parse */
if ((lineno & 0xFF) == 0 && ((scanned_bytes >= budget_bytes ||
os_now_ms() - t_start > (double)budget_ms))) {
cut = 1;
break;
}
cJSON *o = cJSON_Parse(line); cJSON *o = cJSON_Parse(line);
if (!o) if (!o)
continue; continue;
@@ -159,10 +193,14 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
if (matches == 0) { if (matches == 0) {
cst_buf_free(&hits); cst_buf_free(&hits);
return tool_set_resultf(r, 0, return tool_set_resultf(r, 0,
"no match for \"%.80s\" in %d stored session%s " "no match for \"%.80s\" in %d of %d stored session%s"
"(the store holds %d)\n", " (%.1f MB scanned%s)\n",
query, scanned_sessions, query, scanned_sessions, n,
scanned_sessions == 1 ? "" : "s", n); scanned_sessions == 1 ? "" : "s",
(double)scanned_bytes / (1024.0 * 1024.0),
cut ? "; the budget stopped the walk -- raise "
"max_bytes or max_seconds"
: "");
} }
cst_buf_t out = {0}; cst_buf_t out = {0};
cst_buf_printf(&out, "%d match%s for \"%.80s\" in %d of %d stored session%s\n", cst_buf_printf(&out, "%d match%s for \"%.80s\" in %d of %d stored session%s\n",
@@ -172,6 +210,13 @@ int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
if (matches >= (int)want) if (matches >= (int)want)
cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the " cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the "
"query)\n"); "query)\n");
if (cut)
cst_buf_printf(&out,
"\n(the walk stopped after %.1f MB / %.1f s of a %d MB store: "
"older sessions were not looked at -- raise max_bytes or "
"max_seconds to go further)\n",
(double)scanned_bytes / (1024.0 * 1024.0),
(os_now_ms() - t_start) / 1000.0, n);
cst_buf_free(&hits); cst_buf_free(&hits);
return tool_take_buf(r, &out, 0); return tool_take_buf(r, &out, 0);
} }
+2
View File
@@ -55,6 +55,8 @@ int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, char **out); const ctl_tool_call_t *c, char **out);
int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r); cst_tool_result_t *r);
int cst_tool_web_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_tool_download(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, int cst_tool_download(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r); cst_tool_result_t *r);
int cst_tool_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, int cst_tool_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
+209
View File
@@ -6,6 +6,7 @@
#include <curl/curl.h> #include <curl/curl.h>
#include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "cst_tools.h" #include "cst_tools.h"
@@ -23,6 +24,211 @@ typedef struct {
int overflow; int overflow;
} fetch_t; } fetch_t;
static size_t on_body(char *p, size_t sz, size_t n, void *ud);
static const cst_agent_config_t *web_cfg(const cst_tool_ctx_t *ctx) {
return ctx && ctx->agent ? cst_agent_config(ctx->agent) : NULL;
}
static int provider_api(const cst_tool_ctx_t *ctx, const char **kind,
const char **base, const char **key) {
const cst_agent_config_t *cfg = web_cfg(ctx);
if (!cfg || !cfg->api_key[0])
return 0;
if (strcmp(cfg->provider, "deepseek") == 0 ||
(cfg->api_base[0] && strstr(cfg->api_base, "api.deepseek.com"))) {
*kind = "deepseek";
} else if (strcmp(cfg->provider, "kimi") == 0 ||
(cfg->api_base[0] && strstr(cfg->api_base, "moonshot"))) {
*kind = "kimi";
} else {
return 0;
}
*base = cfg->api_base;
*key = cfg->api_key;
return 1;
}
static int api_post_json(const char *url, const char *key, const char *body,
cst_buf_t *out, long *http) {
CURL *curl = curl_easy_init();
if (!curl)
return -1;
char auth[700];
snprintf(auth, sizeof(auth), "Authorization: Bearer %s", key);
struct curl_slist *hdrs = NULL;
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
hdrs = curl_slist_append(hdrs, "Accept: application/json");
hdrs = curl_slist_append(hdrs, auth);
fetch_t f = {0};
cst_curl_apply_ca(curl);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 90L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_body);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &f);
CURLcode rc = curl_easy_perform(curl);
*http = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http);
if (f.body.buf)
cst_buf_append(out, f.body.buf, f.body.len);
cst_buf_free(&f.body);
curl_slist_free_all(hdrs);
curl_easy_cleanup(curl);
return rc == CURLE_OK ? 0 : -1;
}
static void api_path(char *out, size_t n, const char *base, const char *path) {
size_t len = strlen(base);
while (len && base[len - 1] == '/')
len--;
snprintf(out, n, "%.*s/%s", (int)len, base, path);
}
static int api_error(cst_tool_result_t *r, long http, const cst_buf_t *raw) {
cJSON *j = cJSON_Parse(raw->buf ? raw->buf : "");
const cJSON *e = j ? cJSON_GetObjectItemCaseSensitive(j, "error") : NULL;
const cJSON *m = e ? cJSON_GetObjectItemCaseSensitive(e, "message") : NULL;
int rc;
if (cJSON_IsString(m))
rc = tool_set_resultf(r, 1, "error: HTTP %ld: %s", http, m->valuestring);
else
rc = tool_set_resultf(r, 1, "error: HTTP %ld: %.400s", http,
raw->buf ? raw->buf : "no response");
cJSON_Delete(j);
return rc;
}
static int online_fetch(const cst_tool_ctx_t *ctx, const char *url,
long max_out, cst_tool_result_t *r) {
const char *kind, *base, *key;
if (!provider_api(ctx, &kind, &base, &key))
return 0;
if (max_out <= 0)
max_out = FETCH_DEFAULT_OUT;
if (max_out > FETCH_MAX_OUT)
max_out = FETCH_MAX_OUT;
char endpoint[1024];
api_path(endpoint, sizeof(endpoint), base, "tools/fetch");
cJSON *body = cJSON_CreateObject();
cJSON_AddStringToObject(body, "url", url);
char *json = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
cst_buf_t raw = {0};
long http = 0;
int rc = json ? api_post_json(endpoint, key, json, &raw, &http) : -1;
free(json);
if (rc != 0) {
cst_buf_free(&raw);
return tool_set_result(r, "error: online fetch request failed", 1);
}
if (http >= 400) {
int e = api_error(r, http, &raw);
cst_buf_free(&raw);
return e;
}
cJSON *doc = cJSON_Parse(raw.buf ? raw.buf : "");
if (!doc) {
cst_buf_free(&raw);
return tool_set_result(r, "error: online fetch returned invalid JSON", 1);
}
const cJSON *text = cJSON_GetObjectItemCaseSensitive(
doc, strcmp(kind, "kimi") == 0 ? "markdown" : "content");
const cJSON *title = cJSON_GetObjectItemCaseSensitive(doc, "title");
if (!cJSON_IsString(text)) {
cJSON_Delete(doc);
cst_buf_free(&raw);
return tool_set_result(r, "error: online fetch returned no readable content", 1);
}
cst_buf_t out = {0};
cst_buf_printf(&out, "%s\n\n%s", cJSON_IsString(title) ? title->valuestring : url,
cJSON_IsString(text) ? text->valuestring : "");
size_t keep = out.len > (size_t)max_out ? cst_utf8_floor(out.buf, (size_t)max_out)
: out.len;
cst_buf_t clipped = {0};
cst_buf_append(&clipped, out.buf ? out.buf : "", keep);
if (keep < out.len)
cst_buf_puts(&clipped, "\n...(truncated)\n");
int result = tool_take_buf(r, &clipped, 0);
cst_buf_free(&out);
cJSON_Delete(doc);
cst_buf_free(&raw);
return result;
}
int cst_tool_web_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
const char *query = tool_arg(c, "query");
const char *kind, *base, *key;
if (!query || !query[0])
return tool_set_result(r, "error: missing query", 1);
if (!provider_api(ctx, &kind, &base, &key))
return tool_set_result(
r, "error: web_search needs the kimi or deepseek provider", 1);
long count = tool_arg_long(c, "count", 5);
if (count < 1) count = 1;
if (count > 20) count = 20;
int content = tool_arg_bool(c, "include_content", 0);
cJSON *body = cJSON_CreateObject();
cJSON_AddStringToObject(body, strcmp(kind, "kimi") == 0 ? "text_query" : "query", query);
cJSON_AddNumberToObject(body, strcmp(kind, "kimi") == 0 ? "limit" : "count", count);
if (strcmp(kind, "kimi") == 0)
cJSON_AddBoolToObject(body, "include_content", content);
else
cJSON_AddBoolToObject(body, "need_content", content);
char *json = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
char endpoint[1024];
api_path(endpoint, sizeof(endpoint), base, "tools/search");
cst_buf_t raw = {0};
long http = 0;
int rc = json ? api_post_json(endpoint, key, json, &raw, &http) : -1;
free(json);
if (rc != 0) {
cst_buf_free(&raw);
return tool_set_result(r, "error: web search request failed", 1);
}
if (http >= 400) {
int e = api_error(r, http, &raw);
cst_buf_free(&raw);
return e;
}
cJSON *doc = cJSON_Parse(raw.buf ? raw.buf : "");
if (!doc) {
cst_buf_free(&raw);
return tool_set_result(r, "error: web search returned invalid JSON", 1);
}
const cJSON *arr = cJSON_GetObjectItemCaseSensitive(
doc, strcmp(kind, "kimi") == 0 ? "search_results" : "results");
if (!cJSON_IsArray(arr)) {
cJSON_Delete(doc);
cst_buf_free(&raw);
return tool_set_result(r, "error: web search returned no result list", 1);
}
cst_buf_t out = {0};
int n = cJSON_GetArraySize(arr);
cst_buf_printf(&out, "web search (%s): %d result%s\n", kind, n, n == 1 ? "" : "s");
for (int i = 0; i < n; i++) {
const cJSON *item = cJSON_GetArrayItem(arr, i);
const cJSON *title = cJSON_GetObjectItemCaseSensitive(item, "title");
const cJSON *url = cJSON_GetObjectItemCaseSensitive(item, "url");
const cJSON *snippet = cJSON_GetObjectItemCaseSensitive(
item, strcmp(kind, "kimi") == 0 ? (content ? "text" : "snippet") :
(content ? "summary" : "snippet"));
cst_buf_printf(&out, "\n[%d] %s\n%s\n%s\n", i + 1,
cJSON_IsString(title) ? title->valuestring : "(untitled)",
cJSON_IsString(url) ? url->valuestring : "",
cJSON_IsString(snippet) ? snippet->valuestring : "");
}
int result = tool_take_buf(r, &out, 0);
cJSON_Delete(doc);
cst_buf_free(&raw);
return result;
}
static size_t on_body(char *p, size_t sz, size_t n, void *ud) { static size_t on_body(char *p, size_t sz, size_t n, void *ud) {
fetch_t *f = ud; fetch_t *f = ud;
size_t len = sz * n; size_t len = sz * n;
@@ -206,6 +412,9 @@ int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
if (strncasecmp(url, "http://", 7) != 0 && if (strncasecmp(url, "http://", 7) != 0 &&
strncasecmp(url, "https://", 8) != 0) strncasecmp(url, "https://", 8) != 0)
return tool_set_result(r, "error: only http:// and https:// URLs", 1); return tool_set_result(r, "error: only http:// and https:// URLs", 1);
const char *online_kind, *online_base, *online_key;
if (provider_api(ctx, &online_kind, &online_base, &online_key))
return online_fetch(ctx, url, tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT), r);
long max_out = tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT); long max_out = tool_arg_long(c, "max_bytes", FETCH_DEFAULT_OUT);
if (max_out <= 0) if (max_out <= 0)
max_out = FETCH_DEFAULT_OUT; max_out = FETCH_DEFAULT_OUT;
+72
View File
@@ -298,6 +298,77 @@ static void test_cache_hints(void) {
cJSON_Delete(j); cJSON_Delete(j);
} }
static void test_kimi_wire_format(void) {
const char *old_no_cache = getenv("CST_NO_CACHE_HINTS");
const char *old_ttl = getenv("CST_KIMI_CACHE_TTL");
char *saved_no_cache = old_no_cache ? strdup(old_no_cache) : NULL;
char *saved_ttl = old_ttl ? strdup(old_ttl) : NULL;
setenv("CST_NO_CACHE_HINTS", "0", 1);
unsetenv("CST_KIMI_CACHE_TTL");
cst_message_t m[3];
m[0] = msg(CST_ROLE_USER, "think");
m[1] = msg(CST_ROLE_ASSISTANT, "answer");
m[1].reasoning = "private reasoning";
m[2] = msg(CST_ROLE_ASSISTANT, "continue");
m[2].prefix = 1;
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.api_base = "https://api.moonshot.ai/v1";
r.model = "kimi-k2.6";
r.msgs = m;
r.nmsgs = 3;
r.send_reasoning = 1;
cJSON *j = build_req(&r);
const cJSON *thinking = cJSON_GetObjectItemCaseSensitive(j, "thinking");
EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(thinking, "type")) &&
strcmp(cJSON_GetObjectItemCaseSensitive(thinking, "type")->valuestring,
"enabled") == 0,
"Kimi K2 thinking enabled");
EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(thinking, "keep")) &&
strcmp(cJSON_GetObjectItemCaseSensitive(thinking, "keep")->valuestring,
"all") == 0,
"Kimi K2 keeps reasoning");
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort"),
"Kimi K2 omits reasoning_effort");
const cJSON *cache =
cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_options");
EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(cache, "ttl")) &&
strcmp(cJSON_GetObjectItemCaseSensitive(cache, "ttl")->valuestring,
"1h") == 0,
"Kimi implicit cache uses the long TTL by default");
const cJSON *partial =
cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "partial");
EXPECT(cJSON_IsTrue(partial) &&
!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "prefix"),
"Kimi assistant prefill uses partial");
cJSON_Delete(j);
r.model = "kimi-k3";
r.reasoning_effort = "high";
j = build_req(&r);
EXPECT(cJSON_IsString(cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort")) &&
strcmp(cJSON_GetObjectItemCaseSensitive(j, "reasoning_effort")->valuestring,
"high") == 0,
"Kimi K3 uses reasoning_effort");
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "thinking"),
"Kimi K3 omits K2 thinking object");
cJSON_Delete(j);
if (saved_no_cache) {
setenv("CST_NO_CACHE_HINTS", saved_no_cache, 1);
free(saved_no_cache);
} else {
unsetenv("CST_NO_CACHE_HINTS");
}
if (saved_ttl) {
setenv("CST_KIMI_CACHE_TTL", saved_ttl, 1);
free(saved_ttl);
} else {
unsetenv("CST_KIMI_CACHE_TTL");
}
}
static void test_tools_snapshot(void) { static void test_tools_snapshot(void) {
cst_message_t m[1] = {msg(CST_ROLE_USER, "go")}; cst_message_t m[1] = {msg(CST_ROLE_USER, "go")};
cst_llm_request_t r; cst_llm_request_t r;
@@ -559,6 +630,7 @@ int main(void) {
test_document_parts(); test_document_parts();
test_reasoning_and_prefix(); test_reasoning_and_prefix();
test_cache_hints(); test_cache_hints();
test_kimi_wire_format();
test_tools_snapshot(); test_tools_snapshot();
test_gen_params(); test_gen_params();
test_escaping(); test_escaping();
+93
View File
@@ -267,6 +267,98 @@ static void test_old_store(void) {
EXPECT(!exists(nested) && !exists(new_payload), "takes the payload with it"); EXPECT(!exists(nested) && !exists(new_payload), "takes the payload with it");
} }
/* The scan behind the session list (cst_agent_store_info): the count is a
* count of transcript lines, a line longer than one read block is still one
* line, and the first user message is only looked for in the head of the file
* -- a sidebar refresh is not allowed to read a whole transcript of tool
* output to find a greeting. */
static void test_info_scan(void) {
char id[64] = "";
EXPECT(cst_agent_store_new_id(id, sizeof(id)) == 0, "an id for the scan");
char path[1200];
EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0,
"a session path to write into");
cst_session_t *s = cst_session_open(path);
EXPECT(s != NULL, "its transcript opens");
if (!s)
return;
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_USER;
m.content = (char *)"the first user line";
EXPECT(cst_session_append(s, &m) == 0, "the first user message goes in");
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = (char *)"an answer";
EXPECT(cst_session_append(s, &m) == 0, "and an answer");
/* One line that does not fit a read block: counted once, not once per block
* of it (which is what a wrong block count would report). */
const size_t big = 70 * 1024;
char *huge = malloc(big + 1);
EXPECT(huge != NULL, "a buffer for one very long line");
if (!huge)
return;
memset(huge, 'x', big);
huge[big] = 0;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = huge;
EXPECT(cst_session_append(s, &m) == 0, "a line longer than a block goes in");
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = (char *)"a short one after it";
EXPECT(cst_session_append(s, &m) == 0, "and a short line after it");
cst_session_close(s);
cst_session_info_t in;
memset(&in, 0, sizeof(in));
EXPECT(cst_agent_store_info(g_ws, id, &in) == 0, "its info reads back");
EXPECT(in.messages == 4, "four lines counted, the long one once");
EXPECT(strcmp(in.first_user, "the first user line") == 0,
"and the first user line is the one it found");
/* Now a head of tool output wider than the window the search gets, with no
* user line in it: the greeting below stays unseen (and nothing read
* megabytes of transcript to look for it). */
char id2[64] = "";
EXPECT(cst_agent_store_new_id(id2, sizeof(id2)) == 0, "an id for the tail");
char path2[1200];
EXPECT(cst_agent_store_path_for_id(g_ws, id2, path2, sizeof(path2)) == 0,
"a second session path");
s = cst_session_open(path2);
EXPECT(s != NULL, "the second transcript opens");
if (!s) {
free(huge);
return;
}
const size_t mid = 60 * 1024;
char *chunk = malloc(mid + 1);
if (chunk) {
memset(chunk, 'y', mid);
chunk[mid] = 0;
for (int i = 0; i < 6; i++) { /* 360 KiB of tool output, no user line */
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_TOOL;
m.content = chunk;
if (cst_session_append(s, &m) != 0)
break;
}
free(chunk);
}
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_USER;
m.content = (char *)"a greeting far down the file";
EXPECT(cst_session_append(s, &m) == 0, "a user line after all that");
cst_session_close(s);
memset(&in, 0, sizeof(in));
EXPECT(cst_agent_store_info(g_ws, id2, &in) == 0, "its info reads too");
EXPECT(in.messages == 7, "seven lines, one per line");
EXPECT(in.first_user[0] == 0,
"a user line past the window is not called the first one");
free(huge);
}
int main(void) { int main(void) {
if (test_tmpdir(g_ws, sizeof(g_ws), "cst-store") != 0) { if (test_tmpdir(g_ws, sizeof(g_ws), "cst-store") != 0) {
perror("tmpdir"); perror("tmpdir");
@@ -280,6 +372,7 @@ int main(void) {
test_ids(); test_ids();
test_new_session(); test_new_session();
test_old_store(); test_old_store();
test_info_scan();
test_rmrf(g_ws); test_rmrf(g_ws);
if (nfail) { if (nfail) {
+44
View File
@@ -236,6 +236,50 @@ static void test_session_search(cst_tool_ctx_t *ctx) {
c = mk("session_search", NULL); c = mk("session_search", NULL);
EXPECT(run(ctx, c, &r) == 1, "a query is required"); EXPECT(run(ctx, c, &r) == 1, "a query is required");
cst_tool_result_free(&r); cst_tool_result_free(&r);
/* A store is not read to the end on a whim: a byte budget stops the walk
* and says so, and it never reports a match it did not get to. */
{
char path2[512];
EXPECT(cst_agent_store_path_for_id(g_ws, "20260101-130000-big", path2,
sizeof(path2)) == 0,
"a second session path");
cst_session_t *s2 = cst_session_open(path2);
EXPECT(s2 != NULL, "its transcript opens");
if (s2) {
cst_message_t m2;
memset(&m2, 0, sizeof(m2));
m2.role = CST_ROLE_USER;
m2.content = (char *)"the start of a long one";
EXPECT(cst_session_append(s2, &m2) == 0, "a first line goes in");
for (int i = 0; i < 600; i++) {
memset(&m2, 0, sizeof(m2));
m2.role = CST_ROLE_ASSISTANT;
m2.content = (char *)"a line of tool output that says nothing special";
if (cst_session_append(s2, &m2) != 0)
break;
}
memset(&m2, 0, sizeof(m2));
m2.role = CST_ROLE_ASSISTANT;
m2.content = (char *)"the needle is only at the very end";
EXPECT(cst_session_append(s2, &m2) == 0, "the needle goes in last");
cst_session_close(s2);
}
/* Enough lines that a byte budget is spent before the end is reached. */
c = mk("session_search", "query", "needle", "max_bytes", "4096", NULL);
EXPECT(run(ctx, c, &r) == 0 && r.out != NULL, "a bounded search runs");
EXPECT(r.out && strstr(r.out, "budget stopped the walk") != NULL,
"the byte budget stops the walk and says so");
EXPECT(r.out == NULL || strstr(r.out, "the needle is only") == NULL,
"and it does not report a match it never got to");
cst_tool_result_free(&r);
/* The same query without a budget reaches the end and finds it. */
c = mk("session_search", "query", "needle", NULL);
EXPECT(run(ctx, c, &r) == 0 && r.out &&
strstr(r.out, "the needle is only") != NULL,
"the unbounded search finds it");
cst_tool_result_free(&r);
}
} }
/* --- device_info: the environment, without a shell ---------------------- */ /* --- device_info: the environment, without a shell ---------------------- */