52 Commits
Author SHA1 Message Date
WHCandCursor ea38247161 feat: Hermit 1.1.0 — HERMIT_* env, Tauri desktop shell
ci / tcc-check (push) Failing after 42s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
Wire hermit_getenv across the engine, rebrand WebUI/systemd/docs, and
add apps/desktop (Tauri 2 + React) with a DeepSeek-Harness-like UI.
Qt app/ is frozen; full libcstcore FFI is scaffolded behind --features engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 13:15:47 +08:00
WHCandCursor 15c6c4a02a fix: align topbar/sidebar so the top-right is not a dead hole
ci / tcc-check (push) Failing after 39s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
Topbar spans the full width; seat deck starts under it. Compact badge
and a seat chip on the right of the chrome. Hermit 1.0.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 22:08:58 +08:00
WHCandCursor f29c1000b6 fix: ship hermit-update (and hermit-service) as 1.0.1
ci / tcc-check (push) Failing after 39s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
Install hermit-update / hermit-service from make install and bootstrap;
keep cst-agent-* as compat symlinks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 22:00:07 +08:00
WHCandCursor 41f4cf85ee release: Hermit 1.0.0 — home layout, icon font, install
ci / tcc-check (push) Failing after 41s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
Compact the home parliament chip, animate crab states like the HTML kit,
ship hermit-icons.ttf (sbix) + frames, and install the font from make/
bootstrap/package paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 21:57:46 +08:00
WHCandCursor 8fcbd886c3 docs: final CST-agent → Hermit migration notice (0.10.1)
ci / tcc-check (push) Failing after 40s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
Point readers at WHC/Hermit as canonical; add MIGRATION.md and
migrate_remote_to_hermit.sh so existing checkouts can retarget origin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 21:46:23 +08:00
WHCandCursor f30b4ede48 feat: rebrand as Hermit — TUI palette, dual glyphs, binary 0.10.0
ci / tcc-check (push) Failing after 41s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
Ship hermit binary (cst-agent symlink), ~/.config/hermit with legacy fallback,
Hermit color theme and mini crab frames, plus SVG→PUA font build pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 21:42:45 +08:00
WHCandCursor 2602806aea docs: product-facing README; archive CST theory notes (0.9.34)
ci / tcc-check (push) Failing after 43s
ci / linux (aarch64, 0) (push) Skipped
ci / linux (x86, 1) (push) Skipped
ci / linux (x86_64, 1) (push) Skipped
ci / windows (push) Skipped
Rewrite the homepage for day-to-day use, move paper/eval drafts under
docs/archive, and add a docs index so theory no longer leads the project.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 20:00:22 +08:00
WHCandCursor 814a31c9c8 feat: restore CI/release, prompt contracts, faster install (0.9.33)
Bring back Gitea CI + auto-publish; share release helpers; strengthen multi-turn
tool/job prompts with a unit contract; ccache/make help and --release bootstrap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:56:42 +08:00
WHCandCursor f00aad9e3e feat: solo default, job completion reports, tools wave-1 (0.9.32)
Password Realm no longer auto-enables parliament (keeps task/worker tools);
finished jobs fan out to agent notes + TUI; raise PAR_MAX/grep caps and tag truncations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:48:02 +08:00
WHCandCursor 5917c48bd0 feat: TUI completeness — identity, Seat Deck empty states, lean chrome (0.9.31)
Sanitize session titles, stamp CST on the topbar, fill work/sync/keys so
the sidebar never looks unfinished, drop duplicate ctx from the foot, and
tone down the scroll banner plus composer mode line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:35:21 +08:00
WHCandCursor 2ccad623e4 fix: drop stranded topbar chair chip; blend chrome into panel (0.9.30)
Seat role already lives in the Seat Deck. Remove the lonely right-edge
chip and paint the session strip on panel ground so it no longer reads
as a raised bar with a hairline seam.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:18:55 +08:00
WHCandCursor 67b9d6683f fix: keep stream write-head on tip glyph, not next-line col0 (0.9.29)
Appending the scanning caret after trailing newlines parked a lonely
block at the left margin. Overlay the tip character instead and let the
decode crest sweep through the wake.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:15:01 +08:00
WHCandCursor 6d2e106e26 feat: decode-wave stream glitch — plasma tip, braille wake, caret (0.9.28)
Replace noisy glyph spam with a layered frontier: bright cyan plasma at
the tip, a traveling braille wake that ghosts the real text, a lock flash
as glyphs settle, and a scanning write-head caret.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:08:09 +08:00
WHCandCursor 1b0f14533a feat: adaptive drip buffer for stream reveal (0.9.27)
Network tokens fill md_live immediately; the UI reveals md_shown at a
non-linear rate (slow at the tip, accelerates with backlog) so glitch
and text stay readable without lagging far behind a fast model.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:05:20 +08:00
WHCandCursor 7d183d5bba fix: park cursor in composer; glitch stream frontier (0.9.26)
leaveok on chrome panes and always redraw input after side/log motion
so the caret no longer sticks on the Seat Deck version line. Live
assistant text briefly scrambles at the tip then settles to plaintext.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 19:02:05 +08:00
WHCandCursor 6e09f79666 feat: Seat Deck sidebar with parliament telemetry and motion (0.9.25)
Replace the OpenCode-style inspector with a seat/fleet/ctx/work telemetry
rail, default-on for wide sessions, driven by 80ms ASCII micro-motion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 18:50:36 +08:00
WHCandCursor 00288108cd feat: TUI chrome refresh — steel theme, home card, session topbar (0.9.24)
Cooler brand palette, compact CST-agent home with tagline and framed
composer, session topbar (model + chair chip), sidebar off by default.
Screenshot pipeline in tools/tui_shot.py; docs/screenshots for before/after.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 18:27:06 +08:00
WHCandCursor c329db504d perf: accept thread + dial backoff; join/peers.json (0.9.23)
Run inbound peer handling on its own thread so outbound dials cannot starve
:9100. Failed peers cool down exponentially. Also land --join (member seat)
and realm/peers.json seed discovery for Tailscale meshes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 18:16:46 +08:00
WHCandCursor de2eaf87f7 fix: stop parliament accept starvation on outbound dial (0.9.22)
Peer dial used per-step read timeouts so a stuck handshake could block the
single-threaded daemon for tens of seconds; inbound hellos piled up unread
(CLOSE-WAIT). Use a wall-clock dial budget, drain accepts before each dial,
skip self, and only one side initiates idle polls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 13:19:03 +08:00
WHCandCursor 2f96b050d6 perf: faster builds, leaner daemon, smoother install/run (0.9.20)
Reuse libcstcore.a for tests with -MMD incremental deps; idle-backoff the
parliament seat loop; defer curl init and cache settings; add systemd helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 11:41:16 +08:00
WHCandCursor 812e3ee265 feat: parliament seat collaboration loop (0.9.19)
Seats publish via parliament_note, sync pushes on write, member join without
stealing the chair, and worker tools are hard-gated when parliament is active.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 11:29:38 +08:00
WHCandCursor 1605d74f17 fix: sealed peer sync smoke + clearer approval denials (0.9.18)
CLI/Docker assert AEAD sync; status shows sealed channel; headless denials no longer pretend the user refused.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 11:18:04 +08:00
WHCandCursor 5900b53642 feat: seal peer sync with X25519 + XChaCha20-Poly1305 (0.9.17)
Encrypt parliament context sync after Realm mutual auth without pulling in OpenSSL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 11:16:26 +08:00
WHCandCursor a3e115539e feat: /realm in TUI, daemon instance takeover (0.9.16)
Pidfile records instance+port; login restarts the service when the seat
changes. TUI /realm status|login|stop; prompts discourage filler chatter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 11:00:34 +08:00
WHCandCursor e610d70c30 fix: interactive Realm password, recoverable worker budget (0.9.15)
Prompt for Realm password when CST_REALM_PASSWORD is unset; map worker
request-gate exhaustion to a clear budget error with partial progress;
hard-skip identical tool failures after 3 tries. Add docs/parliament.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 10:53:36 +08:00
WHCandCursor 9c6257fc96 fix: parliament UX, prompt alignment, execution nudges (0.9.14)
Inject shared parliament context into the agent environment, switch
prompts away from in-process workers when parliament is active, clear
login tickets on chair sync/new-project, and surface realm/chair/service
in /status. Tighten empty-reply handling and prefer-tool principles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 10:22:31 +08:00
WHCandCursor be8d021480 fix: Alpine parliament Docker build (musl clean rebuild)
Ignore/clean host objects, add linux-headers, and include sys/time.h
so discover builds under musl. Docker chair↔member handshake now passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 10:14:15 +08:00
WHCandCursor e853abac5e feat: parliament daemon, login gate, peer context sync (0.9.13)
Login claims the chair, writes a session ticket, and starts a background
service for discovery plus revision-based context sync. Password Realm
opens require --login (or CST_REALM_PASSWORD); --service/--service-stop
manage the resident agent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 23:11:23 +08:00
WHCandCursor de9ee28773 feat: password Realm, single-chair login, shared parliament context (0.9.12)
Derive Realm keys from a user password (Argon2id, no private key on disk).
Login claims the sole chair with handover; peers share parliament context;
--new-project clears it. CLI peer/discover helpers and Docker alpine recipe
included; headless empty-answer retries raised for unattended runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 23:06:51 +08:00
WHCandCursor 08ca613a0e feat: mutual Realm peer handshake over TCP (0.9.11)
Exchange enrolled certificates and Ed25519 challenge signatures so
same-Realm instances can authenticate each other after discovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 22:49:33 +08:00
WHCandCursor 5ee82f126d feat: same-Realm peer discovery via UDP and local registry (0.9.10)
Advertise instances with a multicast beacon and a config-dir registry so
same-machine and LAN peers that share a Realm fingerprint can find each
other without TOFU pairing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 22:48:04 +08:00
WHCandCursor dade4072cf feat: add Realm Ed25519 keys and instance enrollment (0.9.9)
Introduce a user Realm keypair (Monocypher) that signs instance
certificates so same-domain peers can later auto-discover and connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 22:43:54 +08:00
WHCandCursor acd6f1d998 fix: keep task completion, cancel, pause, and undo honest
Pending earlier-revision jobs no longer skip completion repair; late
cancels preserve real exits; begin clears pause; partial undo keeps
snapshots; permissions and worktree failure paths are hardened.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 22:09:47 +08:00
WHC d971cb02b0 feat: add v2 task control and asynchronous isolated workers 2026-10-20 14:46:57 +08:00
WHC 143dba203b feat: add persistent multi-model workers and orchestration budgets 2026-10-19 11:29:26 +08:00
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
291 changed files with 33113 additions and 1215 deletions
+22
View File
@@ -0,0 +1,22 @@
.git
result
build/
build-*/
dist/
third_party/
backups/
reports/
**/*.o
**/*.a
**/cst-agent
tests/test_*
!tests/test_*.c
app/
.cursor/
*.apk
*.tar.gz
.gen/
vendor/sashidown/*.o
vendor/sashidown/*.a
vendor/cjson/*.o
vendor/monocypher/*.o
+33 -25
View File
@@ -37,14 +37,14 @@ jobs:
- name: Build TinyCC - name: Build TinyCC
run: bash scripts/ci_tcc.sh run: bash scripts/ci_tcc.sh
- name: Build - name: Build
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)" run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)" hermit
- name: Unit tests - name: Unit tests
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
- name: Smoke test - name: Smoke test
run: | run: |
./cst-agent --help ./hermit --help
./cst-agent --workspace "$PWD" --list-sessions ./hermit --workspace "$PWD" --list-sessions
./cst-agent --demo-ctl --workspace . ./hermit --demo-ctl --workspace .
- name: WebUI end-to-end (recorded stream, no API key) - name: WebUI end-to-end (recorded stream, no API key)
run: sh scripts/e2e_webui.sh run: sh scripts/e2e_webui.sh
@@ -86,20 +86,31 @@ jobs:
if [ "${{ matrix.arch }}" = aarch64 ]; then if [ "${{ matrix.arch }}" = aarch64 ]; then
$SUDO apt-get install -y --no-install-recommends qemu-user-static $SUDO apt-get install -y --no-install-recommends qemu-user-static
fi fi
- name: Cache musl toolchain + third_party prefix
uses: actions/cache@v4
with:
path: |
${{ env.HOME }}/.local/toolchains
/opt/*musl*
third_party/prefix-linux-${{ matrix.arch }}
third_party/src
key: linux-${{ matrix.arch }}-tp-v1-${{ hashFiles('scripts/build_third_party_linux.sh', 'scripts/ci_linux_toolchain.sh') }}
restore-keys: |
linux-${{ matrix.arch }}-tp-v1-
- name: Build (static, -Werror) - name: Build (static, -Werror)
env: env:
CST_LINUX_CHECK: ${{ matrix.check }} CST_LINUX_CHECK: ${{ matrix.check }}
run: bash scripts/package_linux.sh ${{ matrix.arch }} run: bash scripts/package_linux.sh ${{ matrix.arch }}
- name: Smoke test - name: Smoke test
run: | run: |
d=$(ls -d dist/cst-agent-*-linux-${{ matrix.arch }}) d=$(ls -d dist/hermit-*-linux-${{ matrix.arch }})
file "$d/cst-agent" file "$d/hermit"
run="" run=""
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static [ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
$run "$d/cst-agent" --version $run "$d/hermit" --version
$run "$d/cst-agent" --help >/dev/null $run "$d/hermit" --help >/dev/null
$run "$d/cst-agent" --workspace "$PWD" --list-sessions $run "$d/hermit" --workspace "$PWD" --list-sessions
cat dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz.sha256 cat dist/hermit-*-linux-${{ matrix.arch }}.tar.gz.sha256
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo # Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
# secret RELEASE_TOKEN (write:repository); fall back to the job token. # secret RELEASE_TOKEN (write:repository); fall back to the job token.
- name: Publish Gitea Release - name: Publish Gitea Release
@@ -110,14 +121,7 @@ jobs:
CST_GITEA: ${{ github.server_url }} CST_GITEA: ${{ github.server_url }}
CST_REPO: ${{ github.repository }} CST_REPO: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }} GITHUB_REF: ${{ github.ref }}
CI: "true" run: bash scripts/ci_publish.sh dist/hermit-*-linux-${{ matrix.arch }}.tar.gz dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz
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. # Release: mingw-w64 UCRT gcc (Debian trixie) with -Werror.
# dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS) # dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS)
@@ -146,6 +150,16 @@ jobs:
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \ curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh -o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh bash /tmp/ci_checkout.sh
- name: Cache mingw third_party
uses: actions/cache@v4
with:
path: |
third_party/prefix-mingw-x86_64
third_party/src
third_party/busybox
key: win-tp-v1-${{ hashFiles('scripts/build_third_party_mingw.sh', 'scripts/bootstrap_windows.sh') }}
restore-keys: |
win-tp-v1-
- name: Cross-build zip and installer - name: Cross-build zip and installer
env: env:
CST_WIN_CFLAGS: -Wall -Wextra -Werror CST_WIN_CFLAGS: -Wall -Wextra -Werror
@@ -164,12 +178,6 @@ jobs:
CST_GITEA: ${{ github.server_url }} CST_GITEA: ${{ github.server_url }}
CST_REPO: ${{ github.repository }} CST_REPO: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }} GITHUB_REF: ${{ github.ref }}
CI: "true"
run: | run: |
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}" bash scripts/ci_publish.sh dist/cst-agent-*-windows-x86_64.zip \
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 dist/cst-agent-*-windows-x86_64-setup.exe
+17
View File
@@ -1,6 +1,8 @@
*.o *.o
*.d
cst-agent cst-agent
cst-agent.exe cst-agent.exe
libcstcore.a
tests/test_* tests/test_*
!tests/test_*.c !tests/test_*.c
session.jsonl session.jsonl
@@ -28,6 +30,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 +55,14 @@ 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
hermit
.peer_probe.out
reports/
# Tauri desktop
apps/desktop/node_modules/
apps/desktop/dist/
apps/desktop/src-tauri/target/
+36 -9
View File
@@ -105,6 +105,8 @@ set(CJSON_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/cjson")
set(CST_CORE_SOURCES set(CST_CORE_SOURCES
vendor/cjson/cJSON.c vendor/cjson/cJSON.c
vendor/monocypher/monocypher.c
vendor/monocypher/monocypher-ed25519.c
src/util.c src/util.c
src/ctl_parse.c src/ctl_parse.c
src/os_compat.c src/os_compat.c
@@ -117,6 +119,14 @@ set(CST_CORE_SOURCES
src/plugins.c src/plugins.c
src/mcp.c src/mcp.c
src/subagent.c src/subagent.c
src/runtime.c
src/worktree.c
src/realm.c
src/discover.c
src/peer_auth.c
src/parliament.c
src/parliament_daemon.c
src/parliament_cli.c
src/attach.c src/attach.c
src/clipboard.c src/clipboard.c
src/pdf_text.c src/pdf_text.c
@@ -142,6 +152,7 @@ set(CST_CORE_SOURCES
src/models.c src/models.c
src/model_registry.c src/model_registry.c
src/metrics.c src/metrics.c
src/hermit_glyph.c
src/net.c src/net.c
src/http_srv.c src/http_srv.c
src/web_md.c src/web_md.c
@@ -216,7 +227,12 @@ target_include_directories(cstcore PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/include"
"${SD_DIR}/include" "${SD_DIR}/include"
"${CJSON_DIR}" "${CJSON_DIR}"
"${CMAKE_CURRENT_SOURCE_DIR}/vendor/monocypher"
) )
set_source_files_properties(
vendor/monocypher/monocypher.c
vendor/monocypher/monocypher-ed25519.c
PROPERTIES COMPILE_OPTIONS "-w")
target_compile_definitions(cstcore PRIVATE _GNU_SOURCE) target_compile_definitions(cstcore PRIVATE _GNU_SOURCE)
if(WIN32) if(WIN32)
target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00) target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00)
@@ -242,6 +258,7 @@ else()
endif() endif()
add_executable(cst-agent ${CST_SOURCES}) add_executable(cst-agent ${CST_SOURCES})
set_target_properties(cst-agent PROPERTIES OUTPUT_NAME hermit)
add_dependencies(cst-agent sashidown) add_dependencies(cst-agent sashidown)
cst_win_exe(cst-agent) cst_win_exe(cst-agent)
@@ -376,7 +393,8 @@ set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
test_shell test_diff test_tools_fs test_tools_ext test_registry test_shell test_diff test_tools_fs test_tools_ext test_registry
test_undo test_config test_models test_model_registry test_sandbox test_undo test_config test_models test_model_registry test_sandbox
test_keymap test_shell_compact test_skills test_keymap test_shell_compact test_skills
test_plugins test_mcp test_subagent test_lowil test_plugins test_mcp test_subagent test_runtime test_realm test_discover
test_peer_auth test_parliament test_lowil
test_win_utf8 test_os_dir test_tmpdir test_win_utf8 test_os_dir test_tmpdir
test_tool_meta test_embed) test_tool_meta test_embed)
if(CST_BUILD_WEBUI) if(CST_BUILD_WEBUI)
@@ -384,17 +402,13 @@ if(CST_BUILD_WEBUI)
list(APPEND _CST_CORE_TESTS test_webui) list(APPEND _CST_CORE_TESTS test_webui)
endif() endif()
foreach(t ${_CST_CORE_TESTS}) foreach(t ${_CST_CORE_TESTS})
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES}) # Link the shared cstcore archive instead of recompiling every core .c.
# The core now contains src/web_md.c, which needs sashidown's header and add_executable(${t} tests/${t}.c)
# archive exactly like the Makefile build does.
target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include") target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include")
target_compile_definitions(${t} PRIVATE _GNU_SOURCE) target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
target_link_libraries(${t} PRIVATE target_link_libraries(${t} PRIVATE cstcore)
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads)
add_dependencies(${t} sashidown) add_dependencies(${t} sashidown)
if(NOT WIN32) if(WIN32)
target_link_libraries(${t} PRIVATE m)
else()
target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32 user32) target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32 user32)
endif() endif()
cst_win_exe(${t}) cst_win_exe(${t})
@@ -414,9 +428,22 @@ endif()
if(WIN32) if(WIN32)
install(TARGETS cst-agent RUNTIME DESTINATION .) install(TARGETS cst-agent RUNTIME DESTINATION .)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf")
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf" DESTINATION .)
endif()
else() else()
include(GNUInstallDirs) include(GNUInstallDirs)
install(TARGETS cst-agent RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) install(TARGETS cst-agent RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf")
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf"
DESTINATION ${CMAKE_INSTALL_DATADIR}/hermit)
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-icons.ttf"
DESTINATION ${CMAKE_INSTALL_DATADIR}/fonts/hermit)
endif()
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-frames")
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/res/hermit-frames/"
DESTINATION ${CMAKE_INSTALL_DATADIR}/hermit/frames)
endif()
endif() endif()
message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER}") message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER}")
+104
View File
@@ -0,0 +1,104 @@
# 从 CST-Agent 迁移到 Hermit
**本仓库 `WHC/CST-agent` 保留为兼容入口。** 产品名与规范克隆地址已改为:
**https://git.whcself.xyz/WHC/Hermit**
二进制、配置与环境变量自 **0.10.0** 起以 Hermit 为准;`CST-agent` 仓库与旧包名仅作过渡。
---
## 1. 换源码远程(推荐)
已有检出:
```bash
# 一键(在仓库根目录)
./scripts/migrate_remote_to_hermit.sh
# 或手动
git remote set-url origin https://git.whcself.xyz/WHC/Hermit.git
git fetch origin
git branch -u origin/main
```
新克隆:
```bash
git clone https://git.whcself.xyz/WHC/Hermit.git
cd Hermit
make -j$(nproc) && make install PREFIX=$HOME/.local
```
旧地址仍可拉代码一段时间:
```bash
git clone https://git.whcself.xyz/WHC/CST-agent.git # 兼容;请改 origin 到 Hermit
```
---
## 2. 二进制与 PATH
| 旧 | 新 |
|---|---|
| `cst-agent` | `hermit`(安装时仍提供 `cst-agent` → `hermit` 软链) |
| `cst-agent-update` | → `hermit-update`(软链;议会助手 → `hermit-service`) |
| Release `cst-agent-VERSION-linux-*.tar.gz` | `hermit-VERSION-linux-*.tar.gz`(过渡期可能双发) |
```bash
hermit --version # hermit 1.0.x
hermit-update # 升级;兼容名 cst-agent-update
cst-agent --version # 同二进制(软链)
```
---
## 3. 配置目录
| 旧 | 新 |
|---|---|
| `~/.config/cst-agent/` | `~/.config/hermit/` |
| Windows `%APPDATA%\cst-agent` | `%APPDATA%\hermit` |
程序若发现 **仅有** 旧目录、尚无新目录,会继续读旧路径。建议主动迁移:
```bash
# Linux / macOS
mkdir -p ~/.config/hermit
# 若尚未迁过:
cp -a ~/.config/cst-agent/. ~/.config/hermit/ 2>/dev/null || true
```
工作区 `.agent/` 会话目录**不变**,无需改动。
---
## 4. 环境变量
`HERMIT_*` 优先;未设置时仍读 `CST_*`。
| 用途 | 新 | 旧(兼容) |
|---|---|---|
| Provider / Key / Model | `HERMIT_PROVIDER` 等 | `CST_PROVIDER` 等 |
| ASCII / Icons | `HERMIT_ASCII` / `HERMIT_ICONS` | `CST_ASCII` / `CST_ICONS` |
| 议会密码等 | `HERMIT_REALM_PASSWORD`(若已接双读) | `CST_REALM_PASSWORD` |
systemd:`EnvironmentFile` 已同时尝试 `~/.config/hermit/parliament.env` 与旧路径。
---
## 5. CI / 脚本 / 文档链接
- 安装脚本、bootstrap、文档中的规范 URL 改为 `…/WHC/Hermit`。
- 本文件与 README 顶栏是 **CST-agent 仓库上的最后一次迁移说明**;后续功能请在 **Hermit** 仓库跟进。
---
## 6. 核对清单
- [ ] `git remote -v` 指向 `WHC/Hermit.git`
- [ ] `hermit --version` 正常;PATH 上可选保留 `cst-agent` 软链
- [ ] `~/.config/hermit/` 有 credentials / settings(或确认仍在用旧目录回退)
- [ ] 书签 / 镜像 / CI 的 clone URL 已更新
- [ ](可选)归档或只读化 `CST-agent` 仓库说明,避免双主开发
+166 -39
View File
@@ -2,6 +2,7 @@ CC ?= gcc
CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g
SD_DIR := vendor/sashidown SD_DIR := vendor/sashidown
CJSON_DIR := vendor/cjson CJSON_DIR := vendor/cjson
MONO_DIR := vendor/monocypher
PKG_CONFIG ?= pkg-config PKG_CONFIG ?= pkg-config
PREFIX ?= /usr/local PREFIX ?= /usr/local
DESTDIR ?= DESTDIR ?=
@@ -9,6 +10,16 @@ UNAME_S := $(shell uname -s 2>/dev/null)
UNAME_O := $(shell uname -o 2>/dev/null) UNAME_O := $(shell uname -o 2>/dev/null)
comma := , comma := ,
# Optional ccache: USE_CCACHE=1 forces it; USE_CCACHE=0 disables; else auto.
USE_CCACHE ?= auto
ifeq ($(USE_CCACHE),1)
CC := $(shell command -v ccache >/dev/null && echo "ccache $(CC)" || echo "$(CC)")
else ifeq ($(USE_CCACHE),auto)
ifneq ($(findstring ccache,$(CC)),ccache)
CC := $(shell command -v ccache >/dev/null && echo "ccache $(CC)" || echo "$(CC)")
endif
endif
# Android (Termux): pthreads live in libc. # Android (Termux): pthreads live in libc.
PTHREAD_LIBS := -lpthread PTHREAD_LIBS := -lpthread
ifeq ($(UNAME_O),Android) ifeq ($(UNAME_O),Android)
@@ -60,6 +71,10 @@ CST_WEBUI ?= 1
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS) PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS) PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
# Upstream Monocypher: keep -Werror builds green without patching the vendor.
$(MONO_DIR)/monocypher.o $(MONO_DIR)/monocypher-ed25519.o: %.o: %.c
$(CC) $(CFLAGS) $(CPPFLAGS) -w -c -o $@ $<
# tcc writes no rpath of its own, so shared libs found via pkg-config -L # tcc writes no rpath of its own, so shared libs found via pkg-config -L
# (Nix store, /opt prefixes) would not load at run time. # (Nix store, /opt prefixes) would not load at run time.
RPATH_LIBS := RPATH_LIBS :=
@@ -70,7 +85,8 @@ endif
endif endif
# Keep POSIX declarations visible for shared test helpers (notably symlink()). # Keep POSIX declarations visible for shared test helpers (notably symlink()).
CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS) CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -I$(MONO_DIR) \
-D_POSIX_C_SOURCE=200809L -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
LDFLAGS += -L$(SD_DIR) LDFLAGS += -L$(SD_DIR)
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) $(RPATH_LIBS) LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) $(RPATH_LIBS)
@@ -87,6 +103,8 @@ endif
CORE_SRC = \ CORE_SRC = \
$(CJSON_DIR)/cJSON.c \ $(CJSON_DIR)/cJSON.c \
$(MONO_DIR)/monocypher.c \
$(MONO_DIR)/monocypher-ed25519.c \
src/util.c \ src/util.c \
src/ctl_parse.c \ src/ctl_parse.c \
src/os_compat.c \ src/os_compat.c \
@@ -97,7 +115,8 @@ CORE_SRC = \
src/skills.c \ src/skills.c \
src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \ src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
src/pdf_text.c \ src/pdf_text.c \
src/session.c \ src/session.c src/runtime.c src/worktree.c src/realm.c src/discover.c \
src/peer_auth.c src/parliament.c src/parliament_daemon.c src/parliament_cli.c \
src/tools/tools.c \ src/tools/tools.c \
src/tools/patch.c \ src/tools/patch.c \
src/tools/registry.c \ src/tools/registry.c \
@@ -117,6 +136,7 @@ CORE_SRC = \
src/mm_tool.c \ src/mm_tool.c \
src/models.c src/model_registry.c \ src/models.c src/model_registry.c \
src/metrics.c \ src/metrics.c \
src/hermit_glyph.c \
$(WEB_SRC) $(WEB_SRC)
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
@@ -125,8 +145,9 @@ OBJ = $(SRC:.c=.o)
# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main. # the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main.
CORE_OBJ = $(CORE_SRC:.c=.o) CORE_OBJ = $(CORE_SRC:.c=.o)
CST_CORE_LIB = libcstcore.a CST_CORE_LIB = libcstcore.a
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \ # Generated embeds; per-file .d files (from -MMD) track the rest.
.gen/models_builtin.inc .gen/web_assets.inc GEN_INCS = .gen/models_builtin.inc .gen/web_assets.inc
DEPFILES = $(OBJ:.o=.d)
TESTS = \ TESTS = \
tests/test_ctl_frontier \ tests/test_ctl_frontier \
@@ -164,6 +185,12 @@ TESTS = \
tests/test_plugins \ tests/test_plugins \
tests/test_mcp \ tests/test_mcp \
tests/test_subagent \ tests/test_subagent \
tests/test_runtime \
tests/test_prompt_contract \
tests/test_realm \
tests/test_discover \
tests/test_peer_auth \
tests/test_parliament \
tests/test_lowil \ tests/test_lowil \
tests/test_win_utf8 \ tests/test_win_utf8 \
tests/test_os_dir \ tests/test_os_dir \
@@ -178,35 +205,53 @@ TEST_LIBS = -L$(SD_DIR) -lsashidown $(CURL_LIBS) $(RPATH_LIBS) $(TEST_SYS_LIBS)
# cst_test_util.h has static os_* helpers; gcc drops them when unused, tcc # cst_test_util.h has static os_* helpers; gcc drops them when unused, tcc
# keeps them, so the small tests link the OS layer too. # keeps them, so the small tests link the OS layer too.
TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c
# hermit_getenv is in os_compat.c (HERMIT_/CST_ dual env).
.PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall .PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang asan install \
uninstall install-service help hermit-font cst-agent
all: sashidown-lib cst-agent $(CST_CORE_LIB) all: sashidown-lib hermit $(CST_CORE_LIB)
$(CST_CORE_LIB): $(CORE_OBJ) FORCE # Compat alias: many scripts still say `make cst-agent`.
cst-agent: hermit
help:
@echo "Hermit build targets (prefer: make -j\$$(nproc))"
@echo " all / hermit incremental build (ccache auto if installed)"
@echo " check build + run unit tests"
@echo " asan AddressSanitizer rebuild + check"
@echo " hermit-font bake PNG frames + hermit-icons.ttf (Pillow+fonttools)"
@echo " install PREFIX=$(PREFIX); hermit + font + cst-agent symlink"
@echo " install-service systemd --user parliament unit"
@echo "Env: USE_CCACHE=0|1|auto STATIC=1 CST_WEBUI=0 CC=tcc"
@echo " HERMIT_ICONS=auto|mini|font HERMIT_*/CST_* env"
@echo "Release: scripts/package_linux.sh ARCH | scripts/ci_windows_cross.sh"
@echo "Fast install: scripts/bootstrap_linux.sh --release"
$(CST_CORE_LIB): $(CORE_OBJ)
rm -f $@ rm -f $@
$(AR) rcs $@ $(CORE_OBJ) $(AR) rcs $@ $(CORE_OBJ)
cmake-gcc: cmake-gcc:
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
cmake --build build-gcc cmake --build build-gcc -j
ctest --test-dir build-gcc --output-on-failure ctest --test-dir build-gcc --output-on-failure -j
cmake-clang: cmake-clang:
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
cmake --build build-clang cmake --build build-clang -j
sashidown-lib: $(SD_DIR)/libsashidown.a # Always ask the sub-make; it no-ops when libsashidown.a is current.
sashidown-lib:
# The sub-make decides whether the archive is stale; FORCE only makes sure it
# is asked, and -j builds wait for it before linking.
$(SD_DIR)/libsashidown.a: FORCE
$(MAKE) -C $(SD_DIR) libsashidown.a $(MAKE) -C $(SD_DIR) libsashidown.a
FORCE: hermit: $(OBJ) sashidown-lib
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS) $(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
@ln -sfr $@ cst-agent 2>/dev/null || ln -sf hermit cst-agent
hermit-font:
@python3 scripts/build_hermit_font.py || \
echo "hermit-font: skipped (need Pillow+fonttools; try: nix-shell -p python3Packages.pillow python3Packages.fonttools)"
# Built-in model data as a C byte list (included by model_registry.c). # Built-in model data as a C byte list (included by model_registry.c).
.gen/models_builtin.inc: src/models_builtin.json .gen/models_builtin.inc: src/models_builtin.json
@@ -218,11 +263,15 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
@mkdir -p .gen @mkdir -p .gen
@sh scripts/embed_web.sh $@ $(WEB_DIR) @sh scripts/embed_web.sh $@ $(WEB_DIR)
%.o: %.c $(HDRS) # Incremental rebuilds: -MMD tracks real includes instead of every header.
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< COMPILE_C = $(CC) $(CFLAGS) $(CPPFLAGS) -MMD -MP -c -o $@ $<
src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h %.o: %.c
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< $(COMPILE_C)
src/model_registry.o: .gen/models_builtin.inc
src/webui.o: .gen/web_assets.inc
src/tui.o: $(SD_DIR)/include/sashidown.h
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC) tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
@@ -230,7 +279,7 @@ tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h
tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC) tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS) $(TEST_OS_SRC) tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
@@ -251,32 +300,110 @@ tests/test_pdf_text: tests/test_pdf_text.c src/pdf_text.c include/cst_pdf.h $(TE
tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS) # Core tests reuse libcstcore.a — avoid recompiling ~35 .c files per binary.
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS) # Do not depend on the phony sashidown-lib target or every test relinks each check.
tests/test_%: tests/test_%.c $(CST_CORE_LIB) $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CST_CORE_LIB) $(TEST_LIBS)
$(TESTS): sashidown-lib $(SD_DIR)/libsashidown.a: sashidown-lib
check: $(TESTS) # Build test binaries with make -j; run them one-by-one for readable logs.
check: sashidown-lib $(TESTS)
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done @set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
# AddressSanitizer + UBSan build of the agent and all tests. # AddressSanitizer + UBSan rebuild (clears objects so sanitizer flags apply).
# Under a ptrace-based sandbox (some Termux setups) LeakSanitizer aborts with
# "Tracer caught signal 11"; add ASAN_OPTIONS=detect_leaks=0 there.
asan: asan:
$(MAKE) clean $(MAKE) clean
$(MAKE) sashidown-lib $(MAKE) CFLAGS="-std=c11 -Wall -Wextra -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer" \
$(MAKE) CFLAGS="-std=c11 -Wall -Wextra -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer" cst-agent check sashidown-lib hermit check
demo: cst-agent demo: hermit
./cst-agent --demo-ctl --workspace . ./hermit --demo-ctl --workspace .
clean: clean:
rm -f $(OBJ) cst-agent $(CST_CORE_LIB) $(TESTS) .gen/models_builtin.inc \ rm -f $(OBJ) $(DEPFILES) hermit cst-agent $(CST_CORE_LIB) $(TESTS) \
.gen/web_assets.inc .gen/models_builtin.inc .gen/web_assets.inc
install: cst-agent # Absolute checkout path for hermit-update (survives cd). Override with HERMIT_SRC.
HERMIT_SRC ?= $(abspath .)
install: hermit
install -d "$(DESTDIR)$(PREFIX)/bin" install -d "$(DESTDIR)$(PREFIX)/bin"
install -m 0755 cst-agent "$(DESTDIR)$(PREFIX)/bin/cst-agent" install -m 0755 hermit "$(DESTDIR)$(PREFIX)/bin/hermit"
ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit" "$(DESTDIR)$(PREFIX)/bin/cst-agent" 2>/dev/null || \
ln -sf hermit "$(DESTDIR)$(PREFIX)/bin/cst-agent"
@# hermit-update / hermit-service (cst-* compat symlinks)
@printf '%s\n' '#!/usr/bin/env bash' \
'export HERMIT_PREFIX="$${HERMIT_PREFIX:-$${CST_AGENT_PREFIX:-$(PREFIX)}}"' \
'export CST_AGENT_PREFIX="$$HERMIT_PREFIX"' \
'export HERMIT_DIR="$${HERMIT_DIR:-$${CST_AGENT_DIR:-$(HERMIT_SRC)}}"' \
'export CST_AGENT_DIR="$$HERMIT_DIR"' \
'exec bash "$$HERMIT_DIR/scripts/bootstrap_linux.sh" "$$HERMIT_DIR" "$$@"' \
> "$(DESTDIR)$(PREFIX)/bin/hermit-update"
@chmod 0755 "$(DESTDIR)$(PREFIX)/bin/hermit-update"
@ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit-update" "$(DESTDIR)$(PREFIX)/bin/cst-agent-update" 2>/dev/null || \
ln -sf hermit-update "$(DESTDIR)$(PREFIX)/bin/cst-agent-update"
@printf '%s\n' '#!/usr/bin/env bash' \
'set -euo pipefail' \
'BIN="$(PREFIX)/bin/hermit"' \
'cmd="$${1:-status}"' \
'case "$$cmd" in' \
'status) "$$BIN" --version; if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active hermit-parliament.service >/dev/null 2>&1; then systemctl --user status hermit-parliament.service --no-pager; elif command -v systemctl >/dev/null 2>&1 && systemctl --user is-active cst-agent-parliament.service >/dev/null 2>&1; then systemctl --user status cst-agent-parliament.service --no-pager; else "$$BIN" --service-stop >/dev/null 2>&1 || true; echo "parliament service: not managed by systemd --user"; fi ;;' \
'install-unit) make -C "$(HERMIT_SRC)" install-service PREFIX="$(PREFIX)" ;;' \
'start) systemctl --user start hermit-parliament.service 2>/dev/null || systemctl --user start cst-agent-parliament.service ;;' \
'stop) systemctl --user stop hermit-parliament.service 2>/dev/null || true; systemctl --user stop cst-agent-parliament.service 2>/dev/null || true; "$$BIN" --service-stop || true ;;' \
'restart) systemctl --user restart hermit-parliament.service 2>/dev/null || systemctl --user restart cst-agent-parliament.service ;;' \
'*) echo "usage: hermit-service {status|install-unit|start|stop|restart}" >&2; exit 2 ;;' \
'esac' \
> "$(DESTDIR)$(PREFIX)/bin/hermit-service"
@chmod 0755 "$(DESTDIR)$(PREFIX)/bin/hermit-service"
@ln -sfr "$(DESTDIR)$(PREFIX)/bin/hermit-service" "$(DESTDIR)$(PREFIX)/bin/cst-agent-service" 2>/dev/null || \
ln -sf hermit-service "$(DESTDIR)$(PREFIX)/bin/cst-agent-service"
@if [ ! -f res/hermit-icons.ttf ]; then $(MAKE) hermit-font || true; fi
@if [ -f res/hermit-icons.ttf ]; then \
install -d "$(DESTDIR)$(PREFIX)/share/hermit"; \
install -d "$(DESTDIR)$(PREFIX)/share/fonts/hermit"; \
install -m 0644 res/hermit-icons.ttf "$(DESTDIR)$(PREFIX)/share/hermit/hermit-icons.ttf"; \
install -m 0644 res/hermit-icons.ttf "$(DESTDIR)$(PREFIX)/share/fonts/hermit/hermit-icons.ttf"; \
if [ -n "$(HOME)" ] && [ "$(DESTDIR)" = "" ]; then \
install -d "$(HOME)/.local/share/fonts"; \
install -m 0644 res/hermit-icons.ttf "$(HOME)/.local/share/fonts/hermit-icons.ttf"; \
command -v fc-cache >/dev/null 2>&1 && fc-cache -f "$(HOME)/.local/share/fonts" >/dev/null 2>&1 || true; \
fi; \
echo "installed Hermit Icons → $(PREFIX)/share/fonts/hermit/hermit-icons.ttf"; \
else \
echo "warning: res/hermit-icons.ttf missing (run: make hermit-font)" >&2; \
fi
@if [ -d res/hermit-frames ]; then \
install -d "$(DESTDIR)$(PREFIX)/share/hermit/frames"; \
cp -a res/hermit-frames/. "$(DESTDIR)$(PREFIX)/share/hermit/frames/"; \
fi
@echo "installed hermit-update → $(PREFIX)/bin/hermit-update"
# Optional systemd --user unit for the parliament seat service.
install-service: install
install -d "$(DESTDIR)$(HOME)/.config/systemd/user"
sed -e 's|@BIN@|$(PREFIX)/bin/hermit|g' \
scripts/hermit-parliament.service.in \
> "$(DESTDIR)$(HOME)/.config/systemd/user/hermit-parliament.service"
ln -sfr "$(DESTDIR)$(HOME)/.config/systemd/user/hermit-parliament.service" \
"$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service" 2>/dev/null || \
ln -sf hermit-parliament.service \
"$(DESTDIR)$(HOME)/.config/systemd/user/cst-agent-parliament.service"
@echo "Installed user unit. Enable with:"
@echo " systemctl --user daemon-reload"
@echo " systemctl --user enable --now hermit-parliament.service"
@echo "(set HERMIT_/CST_ REALM_PASSWORD / INSTANCE_ID in the drop-in or environment)"
uninstall: uninstall:
rm -f "$(DESTDIR)$(PREFIX)/bin/cst-agent" rm -f "$(DESTDIR)$(PREFIX)/bin/hermit" "$(DESTDIR)$(PREFIX)/bin/cst-agent"
rm -f "$(DESTDIR)$(PREFIX)/bin/hermit-update" "$(DESTDIR)$(PREFIX)/bin/cst-agent-update"
rm -f "$(DESTDIR)$(PREFIX)/bin/hermit-service" "$(DESTDIR)$(PREFIX)/bin/cst-agent-service"
rm -f "$(DESTDIR)$(PREFIX)/share/hermit/hermit-icons.ttf"
rm -f "$(DESTDIR)$(PREFIX)/share/fonts/hermit/hermit-icons.ttf"
rm -rf "$(DESTDIR)$(PREFIX)/share/hermit/frames"
rm -f "$(HOME)/.local/share/fonts/hermit-icons.ttf"
rm -f "$(HOME)/.config/systemd/user/hermit-parliament.service"
rm -f "$(HOME)/.config/systemd/user/cst-agent-parliament.service"
-include $(DEPFILES)
+56 -481
View File
@@ -1,519 +1,94 @@
# CST-Agent # Hermit
C11 OpenCode-like carrier that extends paper **CST** (confirmation frontier / irreversible emission) from Markdown→HTML to the **agent tool loop**. > **仓库已更名。** 规范地址:[WHC/Hermit](https://git.whcself.xyz/WHC/Hermit)。
> 本仓库 `CST-agent` 仅作迁移兼容入口。换远程:`./scripts/migrate_remote_to_hermit.sh`
> 完整步骤:[MIGRATION.md](MIGRATION.md)。
See [docs/theory_tool_cst.md](docs/theory_tool_cst.md). 本地 coding agent(原 CST-Agent):终端 TUI、可选 WebUI / 桌面 GUI,在工作区里读改文件、跑命令、管会话。
评测/反哺论文素材:[docs/eval_tool_cst.md](docs/eval_tool_cst.md)。 C11 实现,OpenAI 兼容网关(DeepSeek / Kimi / MiniMax / OpenAI 等)。
## 能不能用来工作? **适合**:有监督的本地改动与仓库探索(权限确认、diff、`/undo`、沙箱)。
**慎用**:完全无人值守的长任务;headless 默认自动批准。
**可以**:有监督的本地改动 / 探索仓库(TUI + 13 个内置工具 + 带 diff 的权限确认 + `/undo`)。 文档索引:[docs/README.md](docs/README.md)。
**不宜**:无人值守长任务或当 CI 主力(网关偶发空答;headless 模式默认自动批准)。
### 5 分钟试用 兼容:安装后提供 `cst-agent` → `hermit` 软链;环境变量 `HERMIT_*` 优先,仍接受 `CST_*`;配置目录优先 `~/.config/hermit`,若无则回退 `~/.config/cst-agent`。
---
## 安装
### Linux / WSL / macOS
```bash ```bash
# Linux / WSL / macOS 一键:克隆到 ~/CST-agent、编译、装到 ~/.local/bin # 预编译包(快;失败则回退源码)— 规范仓库 Hermit
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_linux.sh | bash curl -fsSL https://git.whcself.xyz/WHC/Hermit/raw/branch/main/scripts/bootstrap_linux.sh | bash -s -- --release
# 已有仓库目录内(装到别处:CST_AGENT_PREFIX=/usr/local) # 源码增量编译 → ~/.local/bin/hermit(+ cst-agent 软链)
curl -fsSL https://git.whcself.xyz/WHC/Hermit/raw/branch/main/scripts/bootstrap_linux.sh | bash
```
```bash
./scripts/bootstrap_linux.sh . ./scripts/bootstrap_linux.sh .
hermit --version # hermit 1.0.x
# 以后升级:拉取最新代码、重新编译并安装(本地有未提交改动时跳过 pull) hermit-update # 升级(源码或 --release);兼容 cst-agent-update
cst-agent-update ./scripts/migrate_remote_to_hermit.sh # 已有 CST-agent 检出时改 origin
cst-agent --version
# 在项目目录里直接启动:首次会让你选 provider 并输入 API key(遮罩输入,按 provider 保存)
cd ~/my-project && cst-agent
``` ```
不想编译:[Release](https://git.whcself.xyz/WHC/CST-agent/releases) 里有静态链接的 Linux 单文件 [Release](https://git.whcself.xyz/WHC/Hermit/releases)(旧 [CST-agent releases](https://git.whcself.xyz/WHC/CST-agent/releases) 过渡期可能仍有镜像):`hermit-VERSION-linux-{x86_64,x86,aarch64}.tar.gz`。
(`cst-agent-VERSION-linux-{x86_64,x86,aarch64}.tar.gz`,musl,任何发行版都能直接跑),解压后把 `cst-agent`
放进 `PATH` 即可;每个文件旁边有 `.sha256`。
Windows 10 1903+(PowerShell,不需要管理员): ### Windows
```powershell 见 [docs/README-windows.md](docs/README-windows.md)(产物名逐步改为 `hermit-…`;旧 `cst-agent-…` 包过渡期可能仍有)。
irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_windows.ps1 | iex
# 新开终端后:cd 到项目目录运行 cst-agent;升级用 cst-agent-update
```
也可以下载 Release 里的安装包 `cst-agent-VERSION-windows-x86_64-setup.exe`(按用户安装、自动加 PATH、 ### 第一次用
开始菜单有 WebUI 入口),或便携版 zip。详见 [docs/README-windows.md](docs/README-windows.md)。
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装;
同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。
界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。
完整环境变量说明:[docs/env_openai.md](docs/env_openai.md)。
| 平台 | 一键脚本 |
|---|---|
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
| Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` |
| Linux 静态发行包(musl 交叉) | [`scripts/package_linux.sh`](scripts/package_linux.sh) `x86_64\|x86\|aarch64` → `dist/*.tar.gz` |
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/`、zip 和安装包 |
| Windows 原生构建(MSYS2 UCRT64) | [`scripts/bootstrap_msys2.sh`](scripts/bootstrap_msys2.sh) → `dist/windows-x86_64/` |
### 迷你验收工程(论文案例)
故意带 bug 的小计算器:[`fixtures/mini_calc/`](fixtures/mini_calc/)(任务书 `TASK.md`)。
```bash ```bash
./scripts/e2e_mini_calc.sh --baseline-only # 无 API:证明初始测试 RED cd ~/my-project && hermit
./scripts/e2e_mini_calc.sh --provider deepseek --protocol ctl # 有 key
``` ```
更多夹具(每个都有 `TASK.md` + `check.sh`):`makefile_tab`(写含 TAB 的 Makefile)、 ---
`big_file`(写 5000 字节文件)、`test_fix_test`(先跑测试→修→再跑测试)、
`multi_file_refactor`(跨文件改名 + 修 bug,适合 `edit_file` / `grep`)、 ## 构建
`ask_or_todo`(必须用 `todo_write` 规划并全部完成;无人回答 `ask_user`)。
后两个需要 `--tools default`(脚本默认 `minimal`,即论文用的 4 个工具)。
```bash ```bash
scripts/e2e_fixture.sh makefile_tab --provider deepseek --protocol native-inc make -j$(nproc) # 产出 hermit(并链 cst-agent)
scripts/e2e_fixture.sh multi_file_refactor --provider deepseek --tools default make -j$(nproc) check
# 批量实验:协议 x 模型 x 夹具 x N 次 → results.csv + summary.md make hermit-font # 烘焙 PNG 帧 + hermit-icons.ttf(Pillow + fonttools)
scripts/run_matrix.sh -p "native-eos native-inc ctl ctl-heredoc xml" \ make install PREFIX=$HOME/.local # 同时安装字体到 share/fonts/hermit 与 ~/.local/share/fonts
-m "deepseek:deepseek-flash deepseek:deepseek-v4-pro" -n 3 -r
``` ```
口径:`pass` / `fail` / `agent_error` / `timeout` — 见 [docs/eval_tool_cst.md](docs/eval_tool_cst.md)。 桌面 GUI(Tauri 2 + React,DeepSeek Harness 风格布局):见 [`apps/desktop`](apps/desktop/) 与 [docs/desktop.md](docs/desktop.md)。旧 Qt App(`app/`)已冻结。
## Build TUI 图标:`HERMIT_ICONS=auto|mini|font`(默认色块动画帧,状态与 `icons/agent-motion` HTML 套件一致;`font` 输出 PUA `U+E700+`,需终端加载已安装的 Hermit Icons)。环境变量 **`HERMIT_*` 优先,`CST_*` 仍接受**。
### Make(Linux 默认) ---
## 用法速览
```bash ```bash
cd CST-agent hermit # TUI
make # 依赖 pkg-config 找 libcurl / ncursesw(找不到时退回 ncurses) hermit --web # http://127.0.0.1:8787/
make check # 单元测试 + 截断实验(含真实录制流) hermit --headless --prompt '…'
make asan # AddressSanitizer + UBSan 构建并跑全部测试
make CC=tcc AR=ar check # TinyCC:整套构建 + 测试十几秒(CI 的校验就是这条)
make install PREFIX=$HOME/.local # 或 sudo make install(默认 /usr/local)
``` ```
NixOS:`nix-shell`(仓库自带 [`shell.nix`](shell.nix))后再 `make`。 首页品牌 **HERMIT** + 寄居蟹色块动效;色板:brand `#277F94`、crab `#F68042`、eye `#9957AD`。
macOS:`brew install curl ncurses pkg-config` 后直接 `make`(Makefile 会找 Homebrew 的 keg-only 库)。
各平台细节与验收清单见 [docs/compat.md](docs/compat.md)。
### CMake(推荐跨编译器 / 交叉) 更多:Provider / 工具 / 议会 / WebUI 见 [docs/](docs/)。
```bash ---
# GCC
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
cmake --build build-gcc && ctest --test-dir build-gcc
# Clang ## CI 与发布
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
cmake --build build-clang
# Headless only(无 ncurses) `.gitea/workflows/ci.yml`:TinyCC check → 打 Linux/Windows 包 → `scripts/ci_publish.sh`。
cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd 共用逻辑:[scripts/lib_release.sh](scripts/lib_release.sh)。
# Windows:用一键脚本(见下方“Windows 发行包”),会先编译静态依赖 ## 开发者
./scripts/bootstrap_windows.sh
```
依赖:`gcc`、`clang` 或 `tcc`(0.9.27 mob 以上)、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
## 平台支持矩阵
| OS | Arch | 编译器 | Headless | TUI | Shell |
|---|---|---|---|---|---|
| Linux(glibc / musl) | x86_64 / x86 / aarch64 | GCC, Clang, TinyCC | yes | ncursesw | `/bin/sh` 或旁路 `busybox` |
| WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) |
| macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` |
| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
| Windows 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** |
### WebUI(`--web`)
同一个二进制内置一个 HTTP + SSE 前端:`cst-agent --web`(默认 `http://127.0.0.1:8787/`)。
它和 TUI 是同一引擎的两个前端,浏览器里能流式看回答、点审批、切会话、贴图。
协议是一份稳定的 JSON + SSE 接口,供以后的专用客户端复用。详见 [docs/webui.md](docs/webui.md)。
```bash
cst-agent --web # 本机浏览器打开 http://127.0.0.1:8787/
cst-agent --web --web-port 0 # 端口交给系统,启动日志里给 URL
cst-agent --web --web-dev ./web # 开发前端:直接读目录,改完刷新
cst-agent --web --web-host 0.0.0.0 --web-token 口令 # 手机/局域网访问
sh scripts/e2e_webui.sh # 用录制流跑完整轮次,不需要 API key
```
不带 TUI 的构建也可以开 WebUI(`cmake -DCST_BUILD_WEBUI=ON -DCST_BUILD_TUI=OFF`);
`-DCST_BUILD_WEBUI=OFF` 时 `--web` 只打印一条提示。
### Termux
```bash
# 一键:装依赖、克隆到 ~/CST-agent、编译、安装到 $PREFIX/bin
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_termux.sh | bash
# 以后升级(重跑上面那条也一样)
cst-agent-update
# 或手动
pkg install git clang make pkg-config binutils libcurl ncurses
git clone https://git.whcself.xyz/WHC/CST-agent.git && cd CST-agent
make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录运行 cst-agent
# 可选:旁路 Unix busybox
# ./scripts/build_third_party_mingw.sh busybox-unix
```
环境:`PREFIX` 自动用于找 `sh`;`CST_EXE_DIR` / `argv[0]` 用于定位旁路 busybox(`/proc/self/exe` 受限时)。
临时目录:依次试 `$TMPDIR`、`$PREFIX/tmp`、`/tmp`、`/var/tmp`、`~/.cache/cst-agent/tmp`,用第一个真能建文件的,
并写回 `TMPDIR` 给 shell 子进程和沙箱用(Termux 的 `/tmp` 常常不存在或被 SELinux 拒绝)。
### Linux 静态发行包
```bash
scripts/package_linux.sh x86_64 # 或 x86 / aarch64;CST_LINUX_CHECK=1 顺便跑 make check
```
用 Bootlin 的 musl 交叉 gcc(固定版本、校验 sha256)静态编译 mbedTLS、curl 8.22(只开 HTTP/HTTPS)和宽字符 ncurses,
再以 `STATIC=1` 和 `-Werror` 编出 `cst-agent`,产物是 `dist/cst-agent-VERSION-linux-ARCH.tar.gz`(+ `.sha256`)。
宿主需要 `cmake ninja make perl` 以及 `tic`/`infocmp` 和终端条目(Debian:`ncurses-bin ncurses-term`)。
ncurses 会去常见目录找 terminfo,找不到时用编进去的 `xterm-256color`、`xterm`、`screen-256color`、`tmux-256color`、`linux`、`vt100`。
CA 证书:`CST_CA_BUNDLE` → `SSL_CERT_FILE` → 各发行版的常见位置(Debian/Ubuntu、Fedora/RHEL、openSUSE、Alpine)。
### Windows 发行包
见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。
```bash
./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
```
产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)、
`dist/cst-agent-VERSION-windows-x86_64.zip`,以及装了 `makensis` 时的安装包 `dist/cst-agent-VERSION-windows-x86_64-setup.exe`。
### CI 与 Release
CI 用 TinyCC 校验(构建、`make check`、WebUI 端到端),通过后用 gcc(`-Werror`)出发行包:
Linux x86_64 / x86 / aarch64 静态包(x86 两种跑 `make check`,aarch64 用 qemu 冒烟),
Windows x86_64 的 zip 和安装包(Debian 的 mingw-w64 UCRT gcc + NSIS)。
推送到 `main` 或 `v*` tag 时每个任务把自己的产物上传到 Gitea Release `vVERSION`(同名资源会被替换);
仓库可设 `RELEASE_TOKEN`(`write:repository`),否则用任务自带 token。
本地:`GITEA_TOKEN=... scripts/release.sh dist/cst-agent-*.tar.gz dist/cst-agent-*.zip dist/cst-agent-*-setup.exe`。
环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。
## Defaults (API)
`--provider`(或 `CST_PROVIDER`)选择预设;显式的 `--api-base` / `--model` / 环境变量优先。
| provider | API Base | 默认模型 | 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` |
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
| 用途 | 来源(优先级从高到低) |
|---|---|
| Provider | `--provider` → `CST_PROVIDER` → 上次使用 → `minimax` |
| Key | `--api-key` → 已保存的 key(`credentials`)→ provider 的 Key 变量 → `CST_API_KEY` → TUI 询问 |
| Base(含 `/v1`) | `--api-base` → `CST_API_BASE` → `OPENAI_BASE_URL` → `OPENAI_API_BASE` → 预设 |
| Model | `--model` → `CST_MODEL` → `OPENAI_MODEL` → 该 provider 上次使用的模型 → 预设 |
| 推理强度 | `--effort` → `CST_EFFORT` → 上次使用 → `medium` |
“上次使用”记在配置目录的 `settings.json`(TUI 里切换时更新;headless 运行不改它)。
推理强度以 `reasoning_effort` 发送给 DeepSeek 和 OpenAI 推理模型(o 系列、gpt-5,`max` 映射为 `high`),其他模型不发送。
详见 [docs/env_openai.md](docs/env_openai.md)。
| 变量 | 作用(默认) |
|---|---|
| `CST_PROTOCOL` / `--protocol` | 工具调用协议(`native-eos`) |
| `CST_TOOLS` / `--tools` | 工具集:`default`(全部)/ `minimal`(论文 4 件套) |
| `CST_MODE` / `--mode` | 工作模式:`coding`(默认,代码口径)/ `general`(通用助手口径) |
| `CST_EFFORT` / `--effort` | 推理强度 `low` / `medium` / `high` / `max` |
| `CST_CONFIRM` / `--confirm` | 哪些类别要确认:`shell,write,net`(默认)或 `none` |
| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) |
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) |
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) |
| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 75% 时自动 `/compact`) |
| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt |
| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) |
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限),设了才在超时后整组杀进程,exit=124 |
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) |
| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 |
| `CST_CA_BUNDLE` | HTTPS 用的 CA 证书文件(默认:`SSL_CERT_FILE`、libcurl 自带路径、常见发行版位置;Windows 用系统证书库) |
| `CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR` | 模型列表、自动批准(= yolo)、shell、旁路 busybox |
## Run
```bash
# TUI(工作目录 = 当前目录;Assistant Markdown 经 Sashidown CST 渲染)
cst-agent
# Local CTL demo (no API)
cst-agent --demo-ctl
# 在命令行输入并保存当前 provider 的 key(不回显)
cst-agent --ask-key --provider deepseek
# Headless(默认 auto-approve shell/写文件)
cst-agent --headless --prompt 'List files'
```
会话相关命令行参数:
| 参数 | 作用 |
|---|---|
| `-c` / `--continue` | 打开上次的会话(默认行为) |
| `-N` / `--new` | 开新会话 |
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
| `--migrate-store` | 把旧版本的会话(扁平 `<id>.jsonl`)搬进新布局后退出 |
| `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 |
| `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 |
| `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` |
| `-T PROFILE` / `--tools PROFILE` | `default` 或 `minimal` |
| `-M MODE` / `--mode MODE` | 工作模式 `coding`(默认)/ `general`,也读 `CST_MODE`,并记进 `settings.json` |
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key |
工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。
会话落盘:一个会话一个目录 `<workspace>/.agent/sessions/<id>/`(`session.jsonl` 转录、`title`、`attachments/`、子 agent 的 `sub-N.jsonl`)+ `CURRENT` 指向下次打开的会话;id 是 uuid,两台机器共用一份工作目录也不会撞。旧版本写的扁平会话仍然可读,`--migrate-store` 一次搬进新布局(幂等)。`.agent/` 自带 `.gitignore`,不会被误提交。
### 内置工具
| 工具 | 类别 | 说明 |
|---|---|---|
| `read_file` | 读 | 支持 `offset` / `limit` 分段读和行号 |
| `list_dir` | 读 | 目录树(`depth`),遵守 `.gitignore`,跳过 `.git` / `node_modules` 等 |
| `glob` | 读 | 按通配符找文件(支持 `**`) |
| `grep` | 读 | 正则搜索内容,可限定路径 / glob、忽略大小写、带上下文 |
| `write_file` | 写 | 整文件写入(自动建父目录) |
| `edit_file` | 写 | 精确片段替换(必须唯一,或 `replace_all`) |
| `apply_patch` | 写 | 应用 unified diff,多文件原子生效 |
| `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 |
| `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 |
| `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 |
| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 |
| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB |
| `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) |
| `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) |
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 |
| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) |
| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) |
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
`--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。
TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
### 工作模式(coding / general)
`--mode general`(或 TUI 里 `/mode general`、WebUI 运行设置里的「工作模式」、`CST_MODE=general`)把默认口径
从"代码助手"换成"通用助手":工作流从改代码/跑测试换成"先弄清要什么、再动手、给出处和不确定的地方",
工具说明从"仓库"改成"文件与数据",并说明 `web_fetch` 可以拿外部资料;`/status` 第一行显示当前模式,
切换后 history 里会留一条系统提示。默认仍是 `coding`,选择记在 `settings.json`;
`--tools minimal`(论文口径)的提示词按字节冻结,不受模式影响。
### 剪贴板与文档附件
- **剪贴板**:`clipboard_read` / `clipboard_write` 两个工具(写剪贴板是不可逆发射,要确认);
TUI 里 `/attach paste` 在剪贴板里没有图片时,把剪贴板的**文本**放进输入框(丢掉 CR 与控制字节,尊重输入长度上限)。
- **附件**(`/attach <path>`、拖入终端、剪贴板图片都算):图片按原样存进会话目录,发给模型时是 image part;
**PDF 在摄取时就抽出文本层**(零依赖解析器:Flate / ASCIIHex / ASCII85、对象流、CID 字体 ToUnicode、
内联图像跳过),存的是文本、`mime` 仍是 `application/pdf`;其余文件必须是 UTF-8 文本。
文档以 **text part 内联**发出,所以不受 `CST_NO_IMAGES` 或非视觉模型影响;图片照旧会被换成一行"未发送"说明。
上限:单个附件 ≤16 MB(文档文本 ≤4 MiB),每条消息最多 8 个。
加密 PDF、扫描件(无文本层)会被明确拒绝并说明原因;
`view_image` 仍只收图片,文本/PDF 走 `/attach`。
### 权限、撤销与项目说明
- **审批模式**(Shift+Tab 循环切换,`/approval`,`--approval`,`CST_APPROVAL`):
| 模式 | 写文件 | run_shell | web_fetch |
|---|---|---|---|
| `ask` | 询问 | 询问 | 询问 |
| `auto`(默认) | 直接执行 | 有沙箱 → 在沙箱里直接执行;没有沙箱 → 只读白名单命令直接执行,其余询问 | 询问 |
| `yolo` | 直接执行 | 直接执行(不进沙箱) | 直接执行 |
已保存的规则(`a` / `p`)优先:匹配的命令不进沙箱直接运行。
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
- **沙箱**(C 实现,`src/sandbox_exec.c`):
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
读、执行和联网不受限。
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`。联网不受限。
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,不限制联网。
- Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时
给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网。
`/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。
- 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。
- 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。
- **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree`
和 `git status/diff/log/show/branch`;可以用 `|` `&&` `||` `;` 串联,但每段都必须在白名单里。
`find -exec/-delete`、`rg --pre`、`git branch NAME`、`>` 重定向(`2>&1`、`>/dev/null` 除外)、`$(…)`、反引号、
`<(…)`、`sudo`、`rm` 一律不算安全。
- **确认弹窗**会显示命令或写入的 diff:
`y` 允许一次 · `a` 本工具总是允许 · `p` 总是允许这个命令前缀(如 `make`、`git status`)· `n` / Esc 拒绝。
`a` / `p` 写入 `<workspace>/.agent/permissions.json`,`/permissions` 查看,`/permissions reset` 清空。
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
- **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。
- **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。
- **自动压缩**:上一轮 prompt 用到模型上下文 75% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。
### 扩展:Skills、插件、MCP、子 agent
`--tools minimal` 下这些都不加载(论文口径保持不变)。配置写法与示例见 [docs/plugins.md](docs/plugins.md)。
- **Skills**:`<dir>/SKILL.md`(frontmatter `name` + `description`),放在工作区 `.agent/skills/`、`.claude/skills/`
或配置目录 `skills/`。system prompt 只放名字和一句描述,模型需要时调用 `skill(name)` 读全文;
`/skills` 浏览,`/skill NAME 任务` 直接带着某个 skill 开始一轮。
- **插件**:`plugins/<name>/plugin.json` 声明外部命令工具(参数以 JSON 写到 stdin,stdout 即结果,`perm` 决定审批,
默认 60 s 超时),也可以附带 skills 目录和 MCP 服务器。
- **MCP**:`mcp.json`(`{"mcpServers": {...}}`,与 Claude/Cursor 格式相同)里的 stdio 服务器在后台启动,
工具注册为 `mcp__<server>__<tool>`,默认按网络工具审批(`autoApprove` 可放行)。`/mcp` 看状态,侧栏显示连接数。
- **工作区信任**:工作区里的 `.agent/plugins/` 和 `.agent/mcp.json` 会执行命令,默认不加载;
`/plugins trust` 后才生效(记在 `settings.json`)。配置目录(`~/.config/cst-agent/`)下的始终加载。
- **子 agent**:`task(description, prompt, agent?)` 新开一个独立上下文跑完一轮,把最终报告交回主 agent。
内置 `general`(除 task 外全部工具)、`explore`(只读,可并行)、`plan`(只读,输出计划);
`.agent/agents/*.md` 或配置目录 `agents/*.md` 可自定义角色(frontmatter `name / description / model / tools /
max_steps / readonly`,正文是角色说明)。子 agent 不能再开子 agent;Esc 会一并取消;它的审批弹窗标 `[角色名]`;
文件改动并入当前轮,`/undo` 一起撤销。转录在 `.agent/sessions/<会话>/sub-N.jsonl`,`/agents` 查看。
### TUI 快捷键
默认 leader 是 **Ctrl+X**:先按 Ctrl+X,再按后面的字母。`/keys`(或 F1、Ctrl+X h)列出当前全部绑定。
| 按键 | 说明 |
|---|---|
| Enter | 发送;busy 时入队 |
| Ctrl+N / Alt+Enter | 换行(粘贴的多行文本保持原样,不会被当成发送) |
| Shift+Enter / Ctrl+Enter | 换行;需要支持 kitty 键盘协议的终端(kitty、WezTerm、foot、Ghostty、WHC Shell 等),启动时自动探测 |
| ← → Home End Ctrl+A/E Ctrl+B/F Alt+B/F Ctrl/Alt+← → | 移动光标(按字 / 按词,支持中文宽字符) |
| Ctrl+W / Alt+Backspace / Ctrl+Backspace / Ctrl+Delete | 向前删词 / 向后删词(Ctrl+Backspace 需要 kitty 键盘协议) |
| Ctrl+U / Ctrl+K / Delete | 删到行首 / 删到行尾 / 删后一个字符 |
| ↑ / ↓ | 提示历史(多行输入时先在行间移动) |
| Tab | 补全 `/命令` 和 `@文件`;没有可补全时切换 Build / Plan(Plan 只开放只读工具) |
| Shift+Tab | 循环切换审批模式 ask → auto → yolo |
| Ctrl+P | 命令面板(输入过滤,↑↓ / 鼠标选择) |
| Ctrl+O / Ctrl+R | 展开/折叠最近的 tool 输出 / 最近的 Thought |
| Ctrl+T / Ctrl+L | 开关任务面板 / 重绘 |
| PgUp / PgDn、Alt+↑ / Alt+↓、Ctrl+Home / Ctrl+End | 半页滚动、单行滚动、跳到开头 / 最新 |
| Ctrl+X n / l | 新会话 / 会话列表 |
| Ctrl+X m / p / e | 模型 / provider / 推理强度 |
| Ctrl+X u / r / c | 撤销上一轮改动 / 重问 / 压缩上下文 |
| Ctrl+X y / x | 复制上一条回复 / 导出记录 |
| Ctrl+X t / d / s | 全部 Thought 默认展开或折叠 / 全部 tool 输出 / 侧栏 |
| Ctrl+X a / k / g | 子 agent 面板 / skills / 插件 |
| Ctrl+X h、F1 / Ctrl+X q | 快捷键列表 / 退出 |
| 鼠标点击 | 展开/折叠 tool 卡片和 Thought、在输入框里定位光标、点模式行切换 Build/Plan、在选择框里选中 |
| 鼠标拖动 / 双击 / 三击 | 在对话区选中文字(按字符 / 词 / 行),松开即通过 OSC 52 复制到剪贴板;拖到上下边缘会自动滚动。Shift+拖动或 `/mouse` 关闭后用终端原生选择 |
| Esc | 取消当前 turn(不会退出) |
| Ctrl+C 两次 / 空输入 Ctrl+D | 退出 |
Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中显示实时计时;`/thinking on` 改为默认展开。
**自定义按键**:`~/.config/cst-agent/keybinds.json`,键是动作名(`/keys` 里能看到),值是一个或多个按键,
`"none"` 解除绑定:
```json
{
"leader": "ctrl+x",
"new_session": ["<leader> n", "f2"],
"toggle_thought": "ctrl+g",
"todo": "none"
}
```
按键写法:`ctrl+x`、`alt+b`、`shift+tab`、`f1`…`f12`、`pgup`、`home`、`<leader> n`。
写错的条目会被跳过并在 `/keys` 里提示,其余照常生效。
### 斜杠命令
输入 `/` 弹出补全;唯一前缀即可(如 `/st` → `/status`)。标 * 的命令在模型运行中也能用。
| 命令 | 说明 |
|---|---|
| `/help` * | 命令与按键 |
| `/new`、`/session [id]`、`/rename <标题>` *、`/delete [id]` | 新建 / 选择切换 / 命名 / 删除会话 |
| `/provider [name]`、`/model [name]`、`/effort [level]` | 切换 provider(缺 key 时当场输入并用 `/models` 校验)、模型(列表优先显示 API 实际返回的模型,标 `(api)`)、推理强度;都会记住 |
| `/key` | 输入或更换当前 provider 的 API key(遮罩输入并保存) |
| `/protocol [name]` | 切换工具调用协议 |
| `/mode [coding\|general]` | 工作模式(通用助手 / 代码助手口径) |
| `/tools [on\|off NAME\|minimal\|default]` | 查看或开关工具 |
| `/approval [ask\|auto\|yolo]` *、`/yolo` * | 审批模式;本次开关 yolo |
| `/permissions [reset]` *、`/max-steps [N]` * | 权限规则、每轮步数 |
| `/sidebar` *、`/mouse` * | 开关右侧边栏(≥120 列时显示)、开关鼠标 |
| `/status` *、`/todo` * | 会话 / 模型 / 上下文占比 / 设置;任务清单 |
| `/undo`、`/retry` | 撤销上一轮文件改动;丢弃上一条回复重问 |
| `/diff [path]` * | `git status` + `git diff` |
| `/compact` | 手动压缩上下文 |
| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) |
| `/attach [path\|paste\|list\|clear]` | 给下一条消息带附件:图片、PDF(自动取文本层)、文本文件;`paste` 优先取剪贴板图片,没有就把剪贴板文本放进输入框 |
| `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) |
| `/init` | 让 agent 写 `AGENTS.md` |
| `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 |
| `/skills [reload\|list]`、`/skill <name> [任务]` | 浏览 skills;带着某个 skill 开始一轮 |
| `/plugins [trust\|untrust\|reload]`、`/mcp [reload]` | 插件与工作区信任;MCP 服务器状态 |
| `/agents` | 子 agent 角色与本会话的子 agent 记录(可查看转录) |
| `/quit` * | 退出 |
### 协议
请求统一走 OpenAI 兼容 `POST …/chat/completions`(SSE)。工具调用的承载方式由 `--protocol` 决定:
| 协议 | 承载 | 何时执行 |
|---|---|---|
| `native-eos`(默认) | 原生 `tools[]` / SSE `tool_calls` | 整个流结束后(基线) |
| `native-inc` | 同上 | 某个 index 的参数完整(下一个 index 开始或 finish)即执行 |
| `ctl` | 正文中的 CTL 帧,长度前缀参数 | `end` 行一被确认即执行,流仍在继续 |
| `ctl-heredoc` | 正文中的 CTL 帧,`<<TAG` 定界参数 | 同上 |
| `xml` | MiniMax `<minimax:tool_call>` 文本 | 流结束后 |
文本协议(ctl / ctl-heredoc / xml)不发送 `tools[]`,工具结果以
`result <id> exit=<n> <nbytes>` … `end` 帧放在下一条 user 消息里。
同一会话可以随时切换协议:历史中的调用会按当前协议重新渲染。
## CTL tool language
```text
call <id> <name>
arg <key> <nbytes>
<raw bytes exactly nbytes>
arg <key> <<TAG
<lines, verbatim>
TAG
arg <key> short inline value
end
```
Execution happens only on confirmed `end` (`CTL_EV_CALL_READY`); no prefix of
a call is ever executable. See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
## Starvation experiment
```bash ```bash
make check make check
./scripts/replay_starvation.sh scripts/prompt_smoke.sh
./tests/test_replay_real # 真实 DeepSeek 录制流上的逐字节截断(实验 C) scripts/e2e_webui.sh
``` ```
CTL vs JSON-buffer 截断数字写入 [docs/eval_tool_cst.md](docs/eval_tool_cst.md) 实验 A / C;与 mini_calc e2e 一起作为 Tool-loop CST 附录素材(**不**替代 SD 主论文 Markdown→HTML 实验)。 `icons/agent-motion/` 为 SVG 资产;`make hermit-font` 生成 `res/hermit-icons.ttf`(sbix 彩色位图,96 帧)与 `res/hermit-frames/`。安装脚本会把字体装进系统字体目录。
+33 -5
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
@@ -173,7 +200,7 @@ if(CST_APP_GUI)
# the URI makes Qt register the types with relative URLs ("qmlRegisterType # the URI makes Qt register the types with relative URLs ("qmlRegisterType
# requires absolute URLs" on every start). # requires absolute URLs" on every start).
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog TaskPanel
ThemeIcon Theme ImageStrip) ThemeIcon Theme ImageStrip)
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml) set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
endforeach() endforeach()
@@ -191,6 +218,7 @@ if(CST_APP_GUI)
qml/NoteLine.qml qml/NoteLine.qml
qml/TodoPanel.qml qml/TodoPanel.qml
qml/Composer.qml qml/Composer.qml
qml/TaskPanel.qml
qml/ConsentDialog.qml qml/ConsentDialog.qml
qml/AskDialog.qml qml/AskDialog.qml
qml/SettingsDialog.qml qml/SettingsDialog.qml
+10
View File
@@ -0,0 +1,10 @@
# Deprecated: Qt front end
This directory is the **legacy Qt 6 / QML** Hermit GUI (`cst-agent-app`).
The supported desktop shell is **`apps/desktop`** (Tauri 2 + React), with a
DeepSeek-Harness-inspired layout and an in-process C engine bridge.
Do not add features here. Once Tauri reaches feature parity, this tree will be
moved to `archive/qt-app` or removed. Android APK scripts that target this tree
are transitional.
+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

+5 -5
View File
@@ -22,7 +22,7 @@ Rectangle {
function submit() { function submit() {
const t = input.text.trim() const t = input.text.trim()
if (!t.length || running) if (!t.length)
return return
input.text = "" input.text = ""
submitted(t) submitted(t)
@@ -142,7 +142,7 @@ Rectangle {
Layout.preferredHeight: Math.min(root.compact ? 120 : 170, Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
Math.max(56, contentHeight + 12)) Math.max(56, contentHeight + 12))
placeholderText: root.running placeholderText: root.running
? "the turn is running — Esc stops it" ? "Send a correction now — Esc pauses execution"
: (root.compact : (root.compact
? "Ask for something…" ? "Ask for something…"
: "Ask for something (Enter sends, Shift+Enter for a new line)") : "Ask for something (Enter sends, Shift+Enter for a new line)")
@@ -155,7 +155,7 @@ Rectangle {
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText | inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
Qt.ImhNoAutoUppercase Qt.ImhNoAutoUppercase
background: Rectangle { color: "transparent" } background: Rectangle { color: "transparent" }
enabled: !root.running enabled: true
Keys.onPressed: function (ev) { Keys.onPressed: function (ev) {
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) { if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
@@ -206,9 +206,9 @@ Rectangle {
Layout.alignment: Qt.AlignBottom Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? 68 : 88 implicitWidth: root.compact ? 68 : 88
implicitHeight: Theme.touchTarget + 4 implicitHeight: Theme.touchTarget + 4
text: root.running ? "Stop" : "Send" text: root.running ? (input.text.trim().length ? "Redirect" : "Stop") : "Send"
enabled: root.running || input.text.trim().length > 0 enabled: root.running || input.text.trim().length > 0
onClicked: root.running ? agent.cancel() : root.submit() onClicked: root.running && !input.text.trim().length ? agent.cancel() : root.submit()
background: Rectangle { background: Rectangle {
radius: Theme.radiusSm radius: Theme.radiusSm
color: !action.enabled ? Theme.surfaceAlt color: !action.enabled ? Theme.surfaceAlt
+60 -3
View File
@@ -24,6 +24,7 @@ ApplicationWindow {
property string statusText: "" property string statusText: ""
property var sessionsView: [] // the store, for the sidebar property var sessionsView: [] // the store, for the sidebar
property bool navOpen: false // the sliding sidebar, compact only property bool navOpen: false // the sliding sidebar, compact only
TaskPanel { id: taskPanel; anchors.centerIn: parent }
property int sidebarWidth: 0 // 0 = follow the window width property int sidebarWidth: 0 // 0 = follow the window width
readonly property bool compact: width < Theme.compactBreakpoint readonly property bool compact: width < Theme.compactBreakpoint
readonly property int sidebarWide: sidebarWidth > 0 readonly property int sidebarWide: sidebarWidth > 0
@@ -201,18 +202,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
@@ -311,6 +319,12 @@ ApplicationWindow {
win.statusText = "" win.statusText = ""
} }
} }
ToolButton {
objectName: "taskPanelButton"
text: "Tasks"
implicitHeight: Theme.touchTarget
onClicked: taskPanel.open()
}
} }
} }
@@ -367,6 +381,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 +514,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 +701,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() {
+94
View File
@@ -0,0 +1,94 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
Dialog {
id: root
objectName: "taskControlDialog"
title: "Task control and jobs"
modal: true
standardButtons: Dialog.Close
width: Math.min(760, parent ? parent.width - 24 : 760)
height: Math.min(640, parent ? parent.height - 24 : 640)
property var state: ({})
function refresh() {
try { state = JSON.parse(agent.taskState()) } catch (e) { state = ({}) }
}
onOpened: { refresh(); config.text = agent.workerConfig() }
Timer { interval: 500; repeat: true; running: root.visible; onTriggered: root.refresh() }
ColumnLayout {
anchors.fill: parent
TabBar {
id: tabs
Layout.fillWidth: true
TabButton { text: "Task / Jobs" }
TabButton { text: "Models" }
TabButton { text: "Integration" }
}
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: tabs.currentIndex
ColumnLayout {
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (root.state.goal || "No active goal") }
Label { text: "Revision " + (root.state.revision || 0) + " · " + (root.state.status || "idle") + " · " + (root.state.cancel_source || "") }
RowLayout {
CheckBox {
id: readonly
objectName: "taskReadOnly"
text: "Read only"
checked: !!root.state.read_only
onClicked: agent.taskControl(checked, !direct.checked, !!root.state.paused)
}
CheckBox {
id: direct
objectName: "taskMainOnly"
text: "Main agent only"
checked: root.state.delegation === false
onClicked: agent.taskControl(readonly.checked, !checked, !!root.state.paused)
}
Button { text: root.state.paused ? "Resume" : "Pause"; onClicked: agent.taskControl(readonly.checked, !direct.checked, !root.state.paused) }
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
width: parent.width
Repeater {
model: root.state.jobs || []
Frame {
required property var modelData
Layout.fillWidth: true
ColumnLayout {
anchors.fill: parent
Label { text: modelData.job_id + " · " + modelData.kind + " · " + modelData.status }
Label { Layout.fillWidth: true; text: modelData.resource || ""; elide: Text.ElideMiddle }
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (modelData.output || "").slice(-1000) }
RowLayout {
Button { text: "Cancel"; enabled: modelData.status === "running" || modelData.status === "queued"; onClicked: agent.cancelJob(modelData.job_id, false) }
Button { text: "Force stop"; visible: modelData.status === "running_protected"; onClicked: agent.cancelJob(modelData.job_id, true) }
Button { text: "Review delta"; visible: (modelData.resource || "").indexOf("/.agent/v2/wt-") >= 0; onClicked: { worktree.text = modelData.resource; tabs.currentIndex = 2 } }
}
}
}
}
}
}
}
ColumnLayout {
Label { text: "Role model profiles and budgets (provider credentials stay separate)"; wrapMode: Text.Wrap; Layout.fillWidth: true }
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: config; wrapMode: TextEdit.Wrap; selectByMouse: true } }
Label { id: configStatus; Layout.fillWidth: true; wrapMode: Text.Wrap }
Button { text: "Save model configuration"; enabled: !agent.running; onClicked: { const e = agent.saveWorkerConfig(config.text); configStatus.text = e.length ? e : "Saved" } }
}
ColumnLayout {
TextField { id: worktree; Layout.fillWidth: true; placeholderText: "Worker worktree path" }
RowLayout {
Button { text: "Preview"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, false) }
Button { text: "Integrate reviewed delta"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, true) }
}
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: delta; readOnly: true; wrapMode: TextEdit.Wrap; selectByMouse: true } }
}
}
}
}
+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
} }
+200 -17
View File
@@ -20,6 +20,9 @@
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "cst_models.h" #include "cst_models.h"
#include "cst_runtime.h"
#include "cst_subagent.h"
#include "cst_worktree.h"
#include "cst_provider_switch.h" #include "cst_provider_switch.h"
#include "cst_web_md.h" #include "cst_web_md.h"
@@ -45,6 +48,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) {
@@ -122,7 +143,9 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) { if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) {
char key[512] = ""; char key[512] = "";
if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) { if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) {
const char *env = getenv("CST_API_KEY"); const char *env = getenv("HERMIT_API_KEY");
if (!env || !env[0])
env = getenv("CST_API_KEY");
if (env && env[0]) if (env && env[0])
snprintf(key, sizeof(key), "%s", env); snprintf(key, sizeof(key), "%s", env);
} }
@@ -141,6 +164,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 +220,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) {
@@ -201,8 +238,13 @@ QString firstLine(const QString &s) {
} // namespace } // namespace
void AgentCore::send(const QString &prompt) { void AgentCore::send(const QString &prompt) {
if (!m_agent || m_running || prompt.trimmed().isEmpty()) if (!m_agent || prompt.trimmed().isEmpty())
return; return;
if (m_running) {
cst_agent_steer(m_agent, prompt.toUtf8().constData(), -1, -1);
emit statusChanged(QStringLiteral("Applying latest instruction…"));
return;
}
/* The prompt takes the pending images with it: the view shows them on the /* The prompt takes the pending images with it: the view shows them on the
* message the composer just appended, before the turn can say anything. */ * message the composer just appended, before the turn can say anything. */
const QString sending = pendingImages(); const QString sending = pendingImages();
@@ -223,10 +265,59 @@ void AgentCore::cancel() {
if (m_agent) if (m_agent)
cst_agent_request_cancel(m_agent); cst_agent_request_cancel(m_agent);
} }
QString AgentCore::taskState() const {
if (!m_agent) return QStringLiteral("{}");
char *s = cst_runtime_json(cst_agent_config(m_agent)->runtime);
QString result = QString::fromUtf8(s ? s : "{}"); free(s); return result;
}
void AgentCore::taskControl(bool ro, bool delegation, bool paused) {
if (m_agent) cst_agent_control(m_agent, ro, delegation, paused);
}
void AgentCore::cancelJob(const QString &id, bool force) {
if (m_agent) cst_job_cancel(cst_agent_config(m_agent)->runtime, id.toUtf8().constData(), force, "user");
}
QString AgentCore::workerConfig() const {
if (!m_agent) return QStringLiteral("{}");
char *s = cst_workers_config_get(cst_agent_config(m_agent)->workspace_root);
QString result = QString::fromUtf8(s ? s : "{}"); free(s); return result;
}
QString AgentCore::saveWorkerConfig(const QString &json) {
if (!m_agent || m_running) return QStringLiteral("Wait for the active task before changing model profiles.");
char err[256];
return cst_workers_config_set(cst_agent_config(m_agent)->workspace_root, json.toUtf8().constData(), err, sizeof(err)) ? QString::fromUtf8(err) : QString();
}
QString AgentCore::worktreeDiff(const QString &path, bool integrate) {
if (!m_agent || m_running) return QStringLiteral("Wait for the active task before reviewing or integrating changes.");
const auto *cfg = cst_agent_config(m_agent);
if (cst_runtime_jobs_pending(cfg->runtime) || (integrate && !cst_runtime_check(cfg->runtime, 0, 1, 0)))
return QStringLiteral("Integration is blocked by active jobs or task controls.");
int code;
char *s = cst_worktree_diff(cst_agent_config(m_agent)->workspace_root, path.toUtf8().constData(), integrate, &code);
QString result = QString::fromUtf8(s ? s : "error"); free(s); return result;
}
/* 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 +325,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 +361,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 +408,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 +461,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 +670,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 +877,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 +1308,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,
+25
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>
@@ -156,6 +157,12 @@ class AgentCore : public QObject {
/* Ask a question; emits the streaming signals, then turnFinished(). */ /* Ask a question; emits the streaming signals, then turnFinished(). */
Q_INVOKABLE void send(const QString &prompt); Q_INVOKABLE void send(const QString &prompt);
Q_INVOKABLE void cancel(); Q_INVOKABLE void cancel();
Q_INVOKABLE QString taskState() const;
Q_INVOKABLE void taskControl(bool readOnly, bool delegation, bool paused);
Q_INVOKABLE void cancelJob(const QString &id, bool force);
Q_INVOKABLE QString workerConfig() const;
Q_INVOKABLE QString saveWorkerConfig(const QString &json);
Q_INVOKABLE QString worktreeDiff(const QString &path, bool integrate);
/* Consent: answers map to CST_PERM_*. */ /* Consent: answers map to CST_PERM_*. */
Q_INVOKABLE void answerPermission(int choice); Q_INVOKABLE void answerPermission(int choice);
@@ -204,6 +211,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 +230,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 +271,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
+554 -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 ----------- */
@@ -307,6 +461,22 @@ int runUiTest(AgentCore &core, const QString &size) {
clickMouse(win, QStringLiteral("planSwitch")); clickMouse(win, QStringLiteral("planSwitch"));
check(!core.planMode(), QStringLiteral("plan switch turns it off again")); check(!core.planMode(), QStringLiteral("plan switch turns it off again"));
/* Task controls must reach the same runtime as the model and HTTP API. */
clickMouse(win, QStringLiteral("taskPanelButton"));
QTest::qWait(150);
QObject *taskPanel = win->findChild<QObject *>(QStringLiteral("taskControlDialog"));
check(taskPanel && taskPanel->property("visible").toBool(),
QStringLiteral("Tasks opens the runtime control panel"));
clickMouse(win, QStringLiteral("taskReadOnly"));
clickMouse(win, QStringLiteral("taskMainOnly"));
QJsonObject taskState = QJsonDocument::fromJson(core.taskState().toUtf8()).object();
check(taskState.value(QStringLiteral("read_only")).toBool() &&
!taskState.value(QStringLiteral("delegation")).toBool(),
QStringLiteral("task panel enforces read-only and main-agent-only controls"));
core.taskControl(false, true, false);
if (taskPanel) QMetaObject::invokeMethod(taskPanel, "close");
QTest::qWait(150);
/* --- keyboard: typing in the composer arms Send --- */ /* --- keyboard: typing in the composer arms Send --- */
QQuickItem *send = item(win, QStringLiteral("sendButton")); QQuickItem *send = item(win, QStringLiteral("sendButton"));
if (composer && send) { if (composer && send) {
@@ -332,6 +502,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 +551,98 @@ 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 (f.toMap().value(QStringLiteral("kind")).toString() == QLatin1String("img") || 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();
hits.clear();
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 +674,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 +827,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 +896,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 +1094,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 +1107,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 +1194,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 +1241,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 +1257,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 +1267,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 +1295,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 +1317,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 +1333,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;
} }
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
src-tauri/target/
.DS_Store
*.log
+57
View File
@@ -0,0 +1,57 @@
# Hermit Desktop (Tauri 2)
Cross-platform shell for Hermit. Visual layout follows DeepSeek Harness
(session rail · chat · tool timeline · composer); the agent engine is the
in-process C core (`libcstcore`), not a Cordis/plugin runtime.
## Stack
- **Tauri 2** + Rust host
- **React 19** + Vite + TypeScript UI
- FFI to `libcstcore` (same embed path as the legacy Qt app)
## Dependencies
| Platform | Needs |
|----------|--------|
| All | Rust (stable), Node 20+, `pnpm` or `npm` |
| Linux | `webkit2gtk-4.1`, `libgtk-3`, `librsvg`, `patchelf`, pkg-config, curl |
| Windows | WebView2 runtime |
| macOS | Xcode CLT |
Nix example:
```bash
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
```
## Develop
```bash
cd apps/desktop
npm install
npm run tauri dev
```
Without a full engine link yet, the UI runs against the mock bridge
(`HERMIT_DESKTOP_MOCK=1` or automatic when FFI is unavailable).
## Build
```bash
npm run tauri build
```
Artifacts land in `src-tauri/target/release/bundle/`.
## Layout
```
apps/desktop/
src/ React UI (DSH-like)
src-tauri/ Rust + Tauri + FFI
README.md
```
The legacy Qt front end under `/app` is frozen and will be archived once
desktop feature parity lands.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermit</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3584
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "hermit-desktop",
"private": true,
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.0.3",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}
+4515
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "hermit-desktop"
version = "1.1.0"
description = "Hermit desktop shell (Tauri 2)"
authors = ["WHC"]
edition = "2021"
[lib]
name = "hermit_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
cc = "1.2"
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] }
once_cell = "1"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
# Link libcstcore from the repo (requires curl + built archive).
engine = []
+29
View File
@@ -0,0 +1,29 @@
fn main() {
// Optional: link a prebuilt libcstcore when HERMIT_CORE_LIB is set.
// Example:
// HERMIT_CORE_LIB=/path/to/libcstcore.a HERMIT_CORE_INCLUDE=/path/to/include
// cargo build --features engine
if std::env::var("CARGO_FEATURE_ENGINE").is_ok() {
if let Ok(lib) = std::env::var("HERMIT_CORE_LIB") {
let path = std::path::PathBuf::from(&lib);
if let Some(dir) = path.parent() {
println!("cargo:rustc-link-search=native={}", dir.display());
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("cstcore")
.trim_start_matches("lib");
println!("cargo:rustc-link-lib=static={}", stem);
println!("cargo:rustc-link-lib=curl");
println!("cargo:rustc-link-lib=pthread");
println!("cargo:rustc-link-lib=m");
println!("cargo:rerun-if-changed={}", lib);
}
if let Ok(inc) = std::env::var("HERMIT_CORE_INCLUDE") {
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", inc);
}
}
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default Hermit desktop capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default Hermit desktop capabilities","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+173
View File
@@ -0,0 +1,173 @@
//! Engine façade: mock by default; with `--features engine` + HERMIT_CORE_LIB
//! this module will grow real FFI around `cst_agent.h` (AgentCore parity).
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub title: String,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum ChatItem {
#[serde(rename = "user")]
User { id: String, text: String },
#[serde(rename = "assistant")]
Assistant { id: String, text: String },
#[serde(rename = "thought")]
Thought { id: String, text: String },
#[serde(rename = "tool")]
Tool {
id: String,
name: String,
status: String,
detail: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub provider: String,
pub model: String,
pub approval: String,
pub workspace: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum EngineEvent {
#[serde(rename = "assistant_delta")]
AssistantDelta { text: String },
#[serde(rename = "thought_delta")]
ThoughtDelta { text: String },
#[serde(rename = "tool_start")]
ToolStart {
id: String,
name: String,
detail: String,
},
#[serde(rename = "tool_end")]
ToolEnd {
id: String,
status: String,
detail: String,
},
#[serde(rename = "consent")]
Consent {
req: ConsentRequest,
},
#[serde(rename = "turn_end")]
TurnEnd { ok: bool },
#[serde(rename = "error")]
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsentRequest {
pub id: String,
pub tool: String,
pub detail: String,
}
pub struct EngineState {
pub settings: Settings,
pub sessions: Vec<SessionInfo>,
busy: bool,
}
impl Default for EngineState {
fn default() -> Self {
let workspace = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| ".".into());
Self {
settings: Settings {
provider: "deepseek".into(),
model: "deepseek-flash".into(),
approval: "auto".into(),
workspace,
},
sessions: vec![SessionInfo {
id: "welcome".into(),
title: "Welcome".into(),
updated_at: chrono_now(),
}],
busy: false,
}
}
}
fn chrono_now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub type SharedEngine = Mutex<EngineState>;
pub fn list_sessions(state: &SharedEngine) -> Vec<SessionInfo> {
state.lock().unwrap().sessions.clone()
}
pub fn new_session(state: &SharedEngine) -> SessionInfo {
let mut g = state.lock().unwrap();
let s = SessionInfo {
id: format!("s{}", chrono_now()),
title: "New session".into(),
updated_at: chrono_now(),
};
g.sessions.insert(0, s.clone());
s
}
pub fn open_session(_state: &SharedEngine, _id: &str) -> Vec<ChatItem> {
// Real FFI will replay transcript from cst_agent_store_*.
Vec::new()
}
pub fn settings_get(state: &SharedEngine) -> Settings {
state.lock().unwrap().settings.clone()
}
pub async fn send_turn(app: AppHandle, state: &SharedEngine, text: String) {
{
let mut g = state.lock().unwrap();
if g.busy {
return;
}
g.busy = true;
}
// Placeholder stream until libcstcore FFI is wired (feature = "engine").
let _ = app.emit(
"hermit://event",
EngineEvent::ThoughtDelta {
text: "Rust host received turn; FFI bridge pending full AgentCore port."
.into(),
},
);
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let _ = app.emit(
"hermit://event",
EngineEvent::AssistantDelta {
text: format!("Echo (host): {}", text),
},
);
let _ = app.emit("hermit://event", EngineEvent::TurnEnd { ok: true });
state.lock().unwrap().busy = false;
}
pub fn cancel_turn(state: &SharedEngine) {
state.lock().unwrap().busy = false;
}
pub fn consent_answer(_id: &str, _answer: &str) {
// Real path: wake the worker blocked in consent hook.
}
+31
View File
@@ -0,0 +1,31 @@
//! Future FFI surface against `include/cst_agent.h` (AgentCore parity).
//!
//! Enable with:
//! make -C ../.. libcstcore.a # or cmake cstcore
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
//! cargo build --features engine
//!
//! Mapping (Qt AgentCore → Tauri commands/events):
//! open / send / cancel → session_* / send_turn / cancel_turn
//! hooks.assistant_delta → hermit://event AssistantDelta
//! hooks.tool_* → ToolStart / ToolEnd
//! hooks.consent (blocking) → Consent event + consent_answer wakes waiter
//! settings / creds → settings_get (+ later settings_set)
#![allow(dead_code)]
#[cfg(feature = "engine")]
mod live {
// Bindings land here (cbindgen or manual extern "C").
}
#[cfg(not(feature = "engine"))]
pub fn engine_linked() -> bool {
false
}
#[cfg(feature = "engine")]
pub fn engine_linked() -> bool {
true
}
+14
View File
@@ -0,0 +1,14 @@
/* Placeholder for future cbindgen / manual FFI against include/cst_agent.h.
* Build with --features engine and HERMIT_CORE_LIB pointing at libcstcore.a.
*/
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
/* Mirrors the subset AgentCore used; real symbols come from libcstcore. */
typedef struct cst_agent cst_agent_t;
#ifdef __cplusplus
}
#endif
+73
View File
@@ -0,0 +1,73 @@
mod engine;
mod ffi;
use engine::{SharedEngine, EngineState};
use std::sync::Mutex;
use tauri::Manager;
#[tauri::command]
fn session_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::SessionInfo> {
engine::list_sessions(&state)
}
#[tauri::command]
fn session_new(state: tauri::State<'_, SharedEngine>) -> engine::SessionInfo {
engine::new_session(&state)
}
#[tauri::command]
fn session_open(
state: tauri::State<'_, SharedEngine>,
id: String,
) -> Vec<engine::ChatItem> {
engine::open_session(&state, &id)
}
#[tauri::command]
fn settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
engine::settings_get(&state)
}
#[tauri::command]
async fn send_turn(
app: tauri::AppHandle,
state: tauri::State<'_, SharedEngine>,
text: String,
) -> Result<(), String> {
engine::send_turn(app, &state, text).await;
Ok(())
}
#[tauri::command]
fn cancel_turn(state: tauri::State<'_, SharedEngine>) {
engine::cancel_turn(&state);
}
#[tauri::command]
fn consent_answer(id: String, answer: String) {
engine::consent_answer(&id, &answer);
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.manage(Mutex::new(EngineState::default()))
.invoke_handler(tauri::generate_handler![
session_list,
session_new,
session_open,
settings_get,
send_turn,
cancel_turn,
consent_answer
])
.setup(|app| {
if let Some(w) = app.get_webview_window("main") {
let _ = w.set_title("Hermit");
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running Hermit desktop");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hermit_desktop_lib::run();
}
+53
View File
@@ -0,0 +1,53 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermit",
"version": "1.1.0",
"identifier": "xyz.whcself.hermit",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Hermit",
"width": 1280,
"height": 840,
"minWidth": 880,
"minHeight": 560,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"linux": {
"deb": {
"depends": []
}
},
"macOS": {
"minimumSystemVersion": "12.0"
},
"windows": {
"nsis": {
"installMode": "currentUser"
}
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import { useCallback, useEffect, useState } from "react";
import { Rail } from "@/components/Rail";
import { Transcript } from "@/components/Transcript";
import { Composer } from "@/components/Composer";
import { ConsentModal } from "@/components/ConsentModal";
import {
answerConsent,
cancelTurn,
getSettings,
listSessions,
newSession,
onEngineEvent,
openSession,
sendTurn,
type ChatItem,
type ConsentRequest,
type SessionInfo,
type Settings,
} from "@/lib/bridge";
import "@/styles/app.css";
export default function App() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [items, setItems] = useState<ChatItem[]>([]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState("");
const [settings, setSettings] = useState<Settings>({
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: ".",
});
const [consent, setConsent] = useState<ConsentRequest | null>(null);
const [showSettings, setShowSettings] = useState(false);
const refreshSessions = useCallback(async () => {
setSessions(await listSessions());
}, []);
useEffect(() => {
void (async () => {
setSettings(await getSettings());
await refreshSessions();
})();
}, [refreshSessions]);
useEffect(() => {
let un: (() => void) | undefined;
void onEngineEvent((ev) => {
if (ev.type === "assistant_delta") {
setItems((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "assistant") {
return [
...prev.slice(0, -1),
{ ...last, text: last.text + ev.text },
];
}
return [
...prev,
{ kind: "assistant", id: `a${Date.now()}`, text: ev.text },
];
});
} else if (ev.type === "thought_delta") {
setItems((prev) => {
const last = prev[prev.length - 1];
if (last && last.kind === "thought") {
return [
...prev.slice(0, -1),
{ ...last, text: last.text + "\n" + ev.text },
];
}
return [
...prev,
{ kind: "thought", id: `th${Date.now()}`, text: ev.text },
];
});
} else if (ev.type === "tool_start") {
setItems((prev) => [
...prev,
{
kind: "tool",
id: ev.id,
name: ev.name,
status: "running",
detail: ev.detail,
},
]);
} else if (ev.type === "tool_end") {
setItems((prev) =>
prev.map((it) =>
it.kind === "tool" && it.id === ev.id
? { ...it, status: ev.status, detail: ev.detail || it.detail }
: it,
),
);
} else if (ev.type === "consent") {
setConsent(ev.req);
} else if (ev.type === "turn_end") {
setBusy(false);
} else if (ev.type === "error") {
setBusy(false);
setItems((prev) => [
...prev,
{
kind: "assistant",
id: `err${Date.now()}`,
text: `Error: ${ev.message}`,
},
]);
}
}).then((u) => {
un = u;
});
return () => un?.();
}, []);
const open = async (id: string) => {
setActiveId(id);
setItems(await openSession(id));
};
const onNew = async () => {
const s = await newSession();
await refreshSessions();
setActiveId(s.id);
setItems([]);
};
const onSend = async () => {
const text = draft.trim();
if (!text || busy) return;
setDraft("");
setBusy(true);
setItems((prev) => [
...prev,
{ kind: "user", id: `u${Date.now()}`, text },
]);
await sendTurn(text);
};
return (
<div className="app">
<Rail
sessions={sessions}
activeId={activeId}
workspace={settings.workspace}
query={query}
onQuery={setQuery}
onNew={() => void onNew()}
onOpen={(id) => void open(id)}
onSettings={() => setShowSettings(true)}
/>
<section className="main">
<header className="topbar">
<h1>
{sessions.find((s) => s.id === activeId)?.title ?? "New session"}
</h1>
<div className="chips">
<span className="chip ok">engine ready</span>
<span className="chip">
<strong>{settings.model}</strong>
</span>
</div>
</header>
<Transcript items={items} />
<Composer
value={draft}
busy={busy}
settings={settings}
onChange={setDraft}
onSend={() => void onSend()}
onStop={() => void cancelTurn()}
/>
</section>
{consent ? (
<ConsentModal
req={consent}
onAnswer={(a) => {
void answerConsent(consent.id, a);
setConsent(null);
}}
/>
) : null}
{showSettings ? (
<div className="consent" onClick={() => setShowSettings(false)}>
<div
className="consent-card"
onClick={(e) => e.stopPropagation()}
>
<h3>Settings</h3>
<pre>{JSON.stringify(settings, null, 2)}</pre>
<div className="consent-actions">
<button
type="button"
className="primary"
onClick={() => setShowSettings(false)}
>
Close
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useRef } from "react";
import type { Settings } from "@/lib/bridge";
type Props = {
value: string;
busy: boolean;
settings: Settings;
onChange: (v: string) => void;
onSend: () => void;
onStop: () => void;
};
export function Composer({
value,
busy,
settings,
onChange,
onSend,
onStop,
}: Props) {
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "0px";
el.style.height = Math.min(200, el.scrollHeight) + "px";
}, [value]);
return (
<div className="composer-wrap">
<div className="composer">
<textarea
ref={ref}
rows={2}
placeholder="Describe what you want to build, / commands, @ files…"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy && value.trim()) onSend();
}
}}
/>
<div className="composer-bar">
<div className="composer-left">
<span className="chip">
<strong>{settings.model}</strong>
</span>
<span className="chip">
{settings.provider} · {settings.approval}
</span>
</div>
{busy ? (
<button type="button" className="send stop" onClick={onStop}>
Stop
</button>
) : (
<button
type="button"
className="send"
disabled={!value.trim()}
onClick={onSend}
>
Send
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import type { ConsentRequest } from "@/lib/bridge";
type Props = {
req: ConsentRequest;
onAnswer: (a: "deny" | "once" | "always_tool" | "always_prefix") => void;
};
export function ConsentModal({ req, onAnswer }: Props) {
return (
<div className="consent">
<div className="consent-card">
<h3>Allow {req.tool}?</h3>
<pre>{req.detail}</pre>
<div className="consent-actions">
<button type="button" onClick={() => onAnswer("deny")}>
Deny
</button>
<button type="button" onClick={() => onAnswer("always_prefix")}>
Always prefix
</button>
<button type="button" onClick={() => onAnswer("always_tool")}>
Always tool
</button>
<button type="button" className="primary" onClick={() => onAnswer("once")}>
Allow once
</button>
</div>
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { SessionInfo } from "@/lib/bridge";
type Props = {
sessions: SessionInfo[];
activeId: string | null;
workspace: string;
query: string;
onQuery: (q: string) => void;
onNew: () => void;
onOpen: (id: string) => void;
onSettings: () => void;
};
export function Rail({
sessions,
activeId,
workspace,
query,
onQuery,
onNew,
onOpen,
onSettings,
}: Props) {
const filtered = sessions.filter(
(s) => !query || s.title.toLowerCase().includes(query.toLowerCase()),
);
return (
<aside className="rail">
<div className="brand">
<div className="brand-mark">H</div>
<div>
<div className="brand-name">Hermit</div>
<div className="brand-sub">local coding agent</div>
</div>
</div>
<button type="button" className="btn-new" onClick={onNew}>
+ New session
</button>
<div className="rail-sec">
<span>Sessions</span>
</div>
<input
className="sess-search"
type="search"
placeholder="Search sessions"
value={query}
onChange={(e) => onQuery(e.target.value)}
/>
<div className="sessions">
{filtered.map((s) => (
<button
key={s.id}
type="button"
className={`sess${s.id === activeId ? " active" : ""}`}
onClick={() => onOpen(s.id)}
>
<div className="sess-title">{s.title}</div>
<div className="sess-meta">
{new Date(s.updated_at).toLocaleString()}
</div>
</button>
))}
</div>
<div className="rail-foot">
<div className="workspace" title={workspace}>
{workspace}
</div>
<div className="rail-actions">
<button type="button" className="icon-btn" onClick={onSettings}>
Settings
</button>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,63 @@
import type { ChatItem } from "@/lib/bridge";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
type Props = { items: ChatItem[] };
export function Transcript({ items }: Props) {
if (items.length === 0) {
return (
<div className="transcript">
<div className="empty">
<h2>Meet Hermit</h2>
<p>
Local coding agent — read and edit your workspace, run commands with
approval, keep sessions on disk. Describe a task to begin.
</p>
</div>
</div>
);
}
return (
<div className="transcript">
{items.map((it) => {
if (it.kind === "user") {
return (
<div className="msg user" key={it.id}>
<div className="msg-role">You</div>
<div className="bubble">{it.text}</div>
</div>
);
}
if (it.kind === "assistant") {
return (
<div className="msg assistant" key={it.id}>
<div className="msg-role">Hermit</div>
<div className="bubble">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{it.text}</ReactMarkdown>
</div>
</div>
);
}
if (it.kind === "thought") {
return (
<details className="thought" key={it.id}>
<summary>Thought for a while</summary>
<div className="thought-body">{it.text}</div>
</details>
);
}
return (
<div className="tool-card" key={it.id}>
<div className="tool-head">
<span className="dot" />
<span className="name">{it.name}</span>
<span className="status">{it.status}</span>
</div>
{it.detail ? <div className="tool-body">{it.detail}</div> : null}
</div>
);
})}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
/** IPC / mock bridge for Hermit desktop. */
export type SessionInfo = {
id: string;
title: string;
updated_at: number;
};
export type ConsentRequest = {
id: string;
tool: string;
detail: string;
};
export type ChatItem =
| { kind: "user"; id: string; text: string }
| { kind: "assistant"; id: string; text: string }
| { kind: "thought"; id: string; text: string }
| {
kind: "tool";
id: string;
name: string;
status: "running" | "ok" | "denied" | "error";
detail: string;
};
export type EngineEvent =
| { type: "assistant_delta"; text: string }
| { type: "thought_delta"; text: string }
| { type: "tool_start"; id: string; name: string; detail: string }
| { type: "tool_end"; id: string; status: "ok" | "denied" | "error"; detail: string }
| { type: "consent"; req: ConsentRequest }
| { type: "turn_end"; ok: boolean }
| { type: "error"; message: string };
export type Settings = {
provider: string;
model: string;
approval: "ask" | "auto" | "yolo";
workspace: string;
};
type Unlisten = () => void;
async function tryInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T | null> {
try {
const { invoke } = await import("@tauri-apps/api/core");
return await invoke<T>(cmd, args);
} catch {
return null;
}
}
const mockSessions: SessionInfo[] = [
{
id: "s1",
title: "Fix home TUI layout",
updated_at: Date.now() - 3600_000,
},
{
id: "s2",
title: "Ship hermit-update",
updated_at: Date.now() - 86400_000,
},
];
let mockBusy = false;
const mockListeners = new Set<(e: EngineEvent) => void>();
function emitMock(e: EngineEvent) {
mockListeners.forEach((fn) => fn(e));
}
export async function listSessions(): Promise<SessionInfo[]> {
const real = await tryInvoke<SessionInfo[]>("session_list");
return real ?? mockSessions;
}
export async function newSession(): Promise<SessionInfo> {
const real = await tryInvoke<SessionInfo>("session_new");
if (real) return real;
const s: SessionInfo = {
id: `s${Date.now()}`,
title: "New session",
updated_at: Date.now(),
};
mockSessions.unshift(s);
return s;
}
export async function openSession(id: string): Promise<ChatItem[]> {
const real = await tryInvoke<ChatItem[]>("session_open", { id });
if (real) return real;
if (id === "s1") {
return [
{ kind: "user", id: "u1", text: "右上角布局不对,帮我看看。" },
{
kind: "thought",
id: "t1",
text: "Sidebar starts at row 0 while topbar only covers the main pane…",
},
{
kind: "tool",
id: "tool1",
name: "read_file",
status: "ok",
detail: "src/tui.c · draw_side / layout",
},
{
kind: "assistant",
id: "a1",
text: "顶栏只盖主区、侧栏从第 0 行铺满,右上角会空出一块。已把 topbar 拉满并把 seat deck 对齐到顶栏下方。",
},
];
}
return [];
}
export async function getSettings(): Promise<Settings> {
const real = await tryInvoke<Settings>("settings_get");
return (
real ?? {
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: "~/hermit",
}
);
}
export async function sendTurn(text: string): Promise<void> {
const real = await tryInvoke<void>("send_turn", { text });
if (real !== null) return;
if (mockBusy) return;
mockBusy = true;
emitMock({ type: "thought_delta", text: "Planning a short reply…" });
await delay(400);
emitMock({
type: "tool_start",
id: "m1",
name: "shell",
detail: "hermit --version",
});
await delay(500);
emitMock({
type: "tool_end",
id: "m1",
status: "ok",
detail: "hermit 1.1.0",
});
const reply =
"这是桌面壳的本地演示流(未链上引擎时)。接上 libcstcore 后,这里会变成真实 turn。";
for (const ch of reply) {
emitMock({ type: "assistant_delta", text: ch });
await delay(12);
}
emitMock({ type: "turn_end", ok: true });
mockBusy = false;
}
export async function cancelTurn(): Promise<void> {
await tryInvoke("cancel_turn");
mockBusy = false;
emitMock({ type: "turn_end", ok: false });
}
export async function answerConsent(
id: string,
answer: "deny" | "once" | "always_tool" | "always_prefix",
): Promise<void> {
await tryInvoke("consent_answer", { id, answer });
}
export async function onEngineEvent(fn: (e: EngineEvent) => void): Promise<Unlisten> {
try {
const { listen } = await import("@tauri-apps/api/event");
const un = await listen<EngineEvent>("hermit://event", (ev) => fn(ev.payload));
return () => {
un();
};
} catch {
mockListeners.add(fn);
return () => {
mockListeners.delete(fn);
};
}
}
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
+9
View File
@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+425
View File
@@ -0,0 +1,425 @@
@import "./theme.css";
.app {
display: grid;
grid-template-columns: var(--rail-w) 1fr;
height: 100%;
overflow: hidden;
}
/* —— left rail (DSH-like) —— */
.rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px 12px;
background: var(--bg-elev);
border-right: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 6px;
}
.brand-mark {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(145deg, var(--brand), var(--brand-dim));
box-shadow: inset 0 0 0 1px rgba(169, 217, 215, 0.25);
display: grid;
place-items: center;
color: var(--accent-text);
font-weight: 700;
font-size: 13px;
}
.brand-name {
font-weight: 650;
letter-spacing: 0.02em;
}
.brand-sub {
color: var(--text-faint);
font-size: 11px;
}
.btn-new {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
border-radius: var(--radius-sm);
background: var(--brand);
color: #fff;
font-weight: 600;
transition: background 0.15s ease;
}
.btn-new:hover {
background: #2f94ab;
}
.rail-sec {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 0;
color: var(--text-faint);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.sess-search {
width: 100%;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg);
outline: none;
}
.sess-search:focus {
border-color: var(--brand);
}
.sessions {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 2px;
}
.sess {
text-align: left;
padding: 10px 10px;
border-radius: var(--radius-sm);
color: var(--text-muted);
border: 1px solid transparent;
}
.sess:hover {
background: var(--bg-hover);
color: var(--text);
}
.sess.active {
background: var(--bg-card);
border-color: var(--border-strong);
color: var(--text);
}
.sess-title {
font-weight: 550;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sess-meta {
font-size: 11px;
color: var(--text-faint);
margin-top: 2px;
}
.rail-foot {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.workspace {
padding: 8px 10px;
border-radius: var(--radius-sm);
background: var(--bg);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
font-family: var(--mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rail-actions {
display: flex;
gap: 6px;
}
.icon-btn {
flex: 1;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
color: var(--text-muted);
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
/* —— main column —— */
.main {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--bg);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px var(--pad);
border-bottom: 1px solid var(--border);
}
.topbar h1 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-muted);
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.chip strong {
color: var(--text);
font-weight: 550;
}
.chip.ok {
border-color: rgba(61, 154, 106, 0.35);
color: var(--ok);
}
.transcript {
flex: 1;
overflow: auto;
padding: 20px var(--pad) 8px;
display: flex;
flex-direction: column;
gap: 14px;
}
.empty {
margin: auto;
max-width: 420px;
text-align: center;
color: var(--text-muted);
}
.empty h2 {
margin: 0 0 8px;
color: var(--text);
font-weight: 600;
font-size: 20px;
}
.empty p {
margin: 0;
color: var(--text-faint);
}
.msg {
max-width: 820px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.msg.user {
align-items: flex-end;
}
.msg-role {
font-size: 11px;
color: var(--text-faint);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.bubble {
padding: 12px 14px;
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: var(--shadow);
white-space: pre-wrap;
word-break: break-word;
}
.msg.user .bubble {
background: linear-gradient(160deg, var(--brand-dim), #1a4a56);
border-color: transparent;
}
.msg.assistant .bubble {
background: var(--bg-elev);
}
.thought,
.tool-card {
max-width: 820px;
width: 100%;
margin: 0 auto;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-elev);
overflow: hidden;
}
.thought summary,
.tool-head {
list-style: none;
cursor: pointer;
padding: 10px 12px;
color: var(--text-muted);
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.thought summary::-webkit-details-marker {
display: none;
}
.thought[open] summary {
border-bottom: 1px solid var(--border);
}
.thought-body,
.tool-body {
padding: 10px 12px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
white-space: pre-wrap;
}
.tool-head .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--crab);
}
.tool-head .name {
color: var(--text);
font-weight: 550;
}
.tool-head .status {
margin-left: auto;
color: var(--text-faint);
}
.composer-wrap {
padding: 10px var(--pad) 16px;
}
.composer {
max-width: 820px;
margin: 0 auto;
border: 1px solid var(--border-strong);
background: var(--bg-card);
border-radius: 16px;
padding: 10px 12px 8px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 8px;
}
.composer textarea {
width: 100%;
min-height: 56px;
max-height: 200px;
resize: none;
border: none;
outline: none;
background: transparent;
padding: 4px 2px;
}
.composer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.composer-left {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.send {
height: 34px;
min-width: 72px;
padding: 0 14px;
border-radius: 999px;
background: var(--crab);
color: #1a100c;
font-weight: 650;
}
.send:disabled {
opacity: 0.45;
cursor: default;
}
.send.stop {
background: var(--danger);
color: #fff;
}
.consent {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
z-index: 40;
}
.consent-card {
width: min(480px, calc(100% - 32px));
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
}
.consent-card h3 {
margin: 0 0 8px;
}
.consent-card pre {
margin: 0 0 14px;
padding: 10px;
background: var(--bg);
border-radius: var(--radius-sm);
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
max-height: 180px;
overflow: auto;
}
.consent-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.consent-actions button {
height: 34px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.consent-actions .primary {
background: var(--brand);
border-color: transparent;
color: #fff;
}
@media (max-width: 880px) {
.app {
grid-template-columns: 1fr;
}
.rail {
display: none;
}
}
+72
View File
@@ -0,0 +1,72 @@
:root {
/* Hermit brand + DSH-like rhythm */
--brand: #277f94;
--brand-dim: #1d5f6e;
--crab: #f68042;
--eye: #9957ad;
--ok: #3d9a6a;
--warn: #e9ba47;
--danger: #c45c4a;
--bg: #0f1114;
--bg-elev: #161a1f;
--bg-card: #1b2128;
--bg-hover: #222933;
--border: rgba(255, 255, 255, 0.06);
--border-strong: rgba(255, 255, 255, 0.12);
--text: #e8ecf0;
--text-muted: #8b949e;
--text-faint: #5c6570;
--accent-text: #a9d9d7;
--radius: 12px;
--radius-sm: 8px;
--pad: 16px;
--rail-w: 260px;
--font: "IBM Plex Sans", "Segoe UI", "Noto Sans SC", system-ui, sans-serif;
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, monospace;
--shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
button,
input,
textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
a {
color: var(--accent-text);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] }
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/Composer.tsx","./src/components/ConsentModal.tsx","./src/components/Rail.tsx","./src/components/Transcript.tsx","./src/lib/bridge.ts"],"version":"5.9.3"}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
envPrefix: ["VITE_", "TAURI_"],
build: {
target: process.env.TAURI_ENV_PLATFORM === "windows" ? "chrome105" : "safari13",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
});
+94
View File
@@ -0,0 +1,94 @@
# NixOS and other nix users: legacy Qt Quick front end (frozen; Tauri app is
# apps/desktop). CLI remains scripts/bootstrap_linux.sh.
#
# nix-build # ./result/bin/hermit-app (Qt, transitional)
# nix-env -f . -i hermit
{ 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 = "hermit";
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 =
"Hermit: local coding agent (legacy Qt Quick front end; prefer apps/desktop)";
homepage = "https://git.whcself.xyz/WHC/Hermit";
platforms = pkgs.lib.platforms.linux;
mainProgram = "cst-agent-app";
};
}
+17
View File
@@ -0,0 +1,17 @@
# Hermit 文档索引
规范仓库:[WHC/Hermit](https://git.whcself.xyz/WHC/Hermit)。从旧名迁移见根目录 [MIGRATION.md](../MIGRATION.md)。
二进制:`hermit`(兼容软链 `cst-agent`)。配置目录:`~/.config/hermit`(若无则回退 `~/.config/cst-agent`)。
| 文档 | 内容 |
|---|---|
| [env_openai.md](env_openai.md) | Provider、模型、API Key、环境变量(`HERMIT_*` / `CST_*`) |
| [parliament.md](parliament.md) | 多设备议会 |
| [workers.md](workers.md) | 后台任务、worker、job 完成报告 |
| [webui.md](webui.md) | 内置 HTTP + SSE WebUI |
| [plugins.md](plugins.md) | Skills、插件、MCP |
| [compat.md](compat.md) | 平台兼容 |
| [README-windows.md](README-windows.md) | Windows |
| [android.md](android.md) | Android / APK |
| [archive/](archive/) | 历史设计笔记(非产品文档) |
+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.
+10
View File
@@ -0,0 +1,10 @@
# 归档
历史设计笔记与评测草稿,**不是**面向用户的产品文档,也不再作为论文素材维护。
| 文件 | 说明 |
|---|---|
| `theory_tool_cst.md` | 早期工具调用确认流设计笔记 |
| `eval_tool_cst.md` | 早期协议/夹具评测草稿 |
日常使用请看仓库根目录 [README.md](../../README.md) 与同级其它 `docs/*.md`。
@@ -1,9 +1,8 @@
# Tool-loop CST 评测笔记(反哺论文素材) # Tool-loop CST 评测笔记(反哺论文素材)
> 本文是 **CST-Agent** 侧的评测/案例记录,供写入 SD 主论文附录或独立 TR 时粘贴。 > **归档。** 早期评测草稿,已停止维护;请勿当作当前产品说明。
> **不**替代 [`SD/paper/sections/eval_zh.tex`](../../SD/paper/sections/eval_zh.tex) 中 Markdown→HTML 主实验。
理论锚点见 [theory_tool_cst.md](theory_tool_cst.md)。 理论锚点见同目录 [theory_tool_cst.md](theory_tool_cst.md)。
## 命题分工 ## 命题分工
@@ -1,5 +1,7 @@
# Tool-loop CST:确认前沿推广到工具调用 # Tool-loop CST:确认前沿推广到工具调用
> **归档。** 早期设计笔记,已停止维护;请勿当作当前产品说明。见 [README.md](README.md)。
## 命题 ## 命题
在 agent 工具环中,对模型输出做**确认流式转导**:只在 \(O_{\mathrm{conf}}\) 内不可逆地发射「可执行工具调用 / 可应用补丁 / 可提交会话状态」。相对「拼到合法 JSON 再执行」的缓冲基线,这能降低注意力饥渴导致的残缺工具帧(未闭合括号、半截 arguments)被过早执行或整轮失败的问题。 在 agent 工具环中,对模型输出做**确认流式转导**:只在 \(O_{\mathrm{conf}}\) 内不可逆地发射「可执行工具调用 / 可应用补丁 / 可提交会话状态」。相对「拼到合法 JSON 再执行」的缓冲基线,这能降低注意力饥渴导致的残缺工具帧(未闭合括号、半截 arguments)被过早执行或整轮失败的问题。
+2 -2
View File
@@ -22,8 +22,8 @@ adds Homebrew's keg-only `ncurses`/`curl` to `PKG_CONFIG_PATH`; without
Homebrew it falls back to the system `-lncurses` (wide-character capable). Homebrew it falls back to the system `-lncurses` (wide-character capable).
Verified so far: NixOS x86_64 with gcc, clang and TinyCC (`-Werror` except Verified so far: NixOS x86_64 with gcc, clang and TinyCC (`-Werror` except
tcc, `make check`, `make asan`, ctest). CI (`.gitea/workflows/ci.yml`) checks tcc, `make check`, `make asan`, ctest). CI (`.gitea/workflows/ci.yml`)
with TinyCC on Debian and builds the static musl releases for x86_64, x86 and checks with TinyCC and builds the static musl releases for x86_64, x86 and
aarch64 with gcc `-Werror` (`make check` on both x86 targets, a qemu smoke aarch64 with gcc `-Werror` (`make check` on both x86 targets, a qemu smoke
test on aarch64). macOS and WSL are checked by hand with the list below. test on aarch64). macOS and WSL are checked by hand with the list below.
+20
View File
@@ -0,0 +1,20 @@
# Hermit Desktop (Tauri)
Canonical GUI: [`apps/desktop`](../apps/desktop/). Qt under [`app/`](../app/) is frozen ([DEPRECATED](../app/DEPRECATED.md)).
## Shape
- **Shell**: Tauri 2 (Linux / Windows / macOS)
- **UI**: React 19 — session rail, chat, tool cards, composer (DeepSeek Harness–like IA)
- **Engine**: in-process `libcstcore` via Rust FFI (feature `engine`); without it the host uses a mock / echo path so the UI is developable
## Dev
```bash
nix-shell -p rustc cargo nodejs_22 pkg-config webkitgtk_4_1 gtk3 librsvg openssl curl
cd apps/desktop && npm install && npm run tauri dev
```
## Product env
`HERMIT_*` preferred, `CST_*` accepted (see `hermit_getenv`). Config dir `~/.config/hermit`.
+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
+122
View File
@@ -0,0 +1,122 @@
# Parliament(议会)快速上手
密码串派生 Realm → 登录夺主席 / 加入席位 → 常驻服务密封同步共享上下文。
主席是用户入口;成员席位在本机干活并用 `parliament_note` 通报进度。
## 一次性初始化
```bash
# 交互输入密码,或事先 export CST_REALM_PASSWORD=…
cst-agent --realm-password-init
```
磁盘只存 salt + 公钥,**不存私钥**。
## 日常:登录夺主席,或加入席位
```bash
export CST_INSTANCE_ID=laptop-a # 可选;默认用本机 hostname
cst-agent --login --realm-label "thinkpad"
# 其他机器(同 salt/公钥 + 密码):
cst-agent --join --instance phone-b --realm-label "pixel"
# TUI: /realm login(夺主席)或 /realm join(不抢主席)
```
跨网段(如 Tailscale)多播发现不可用时,在 Realm 目录放 `peers.json`:
```json
{"peers":[{"host":"100.64.0.13","port":9100,"iid":"labpc","name":"labpc"}]}
```
效果:
1. 用密码解锁 Realm;`--login` / `/realm login` 使本机成为**唯一主席**
2. `--join` / `/realm join` 只登记席位票:主席空缺时才夺椅,否则以 **member** 身份加入
3. 写入本地登录票(24h;成员票在主席易主后仍有效,除非本席被剥夺主席)
4. 拉起后台议会服务(发现 + 密封通道按 revision 同步 `parliament.context`;
Unix 下 accept 独立线程,dial 失败会指数退避,避免饿死入站)
查看状态:
```bash
cst-agent # TUI: /status 或 /realm
# /realm login|join|stop
cst-agent --service-stop
```
### 常驻服务(推荐)
登录后会 fork 后台席位进程;也可用 systemd --user 托管前台循环(崩溃自动拉起):
```bash
# 安装脚本附带的辅助命令
cst-agent-service install-unit
# 在 ~/.config/cst-agent/parliament.env 写入:
# CST_REALM_PASSWORD=…
# CST_INSTANCE_ID=my-laptop
systemctl --user enable --now cst-agent-parliament.service
cst-agent-service status
```
或手动前台:`CST_REALM_PASSWORD=… cst-agent --service-fg`。
## 单机默认(solo)
未设置 `collaboration` 时,**即使存在密码 Realm,默认也是单机 solo**:`task` /
`worker_*` / runtime jobs 可用,不强制登录闸。密码 Realm 仅保管密钥,不再自动开议会。
要进入议会模式,任选其一:
- `CST_COLLABORATION=parliament` 或 `settings.json` 里 `"collaboration": "parliament"`
- 已有 open 状态的 parliament meta(例如刚 `--login` 过)
显式议会模式下打开 agent 才要求登录(TTY 下可当场输入密码)。强制恢复本机 worker:
`CST_COLLABORATION=legacy_workers`。
## 议员协作协议
席位通过共享 `parliament.context` 协作(不是进程内 `task`/`worker_*`):
1. 开工前阅读 Environment 里的 Shared parliament context
2. 用本机工具完成分配切片
3. 跨席相关的进度/议程/结果用工具 **`parliament_note`** 发布(`kind=status|agenda|result|handover`)
4. 服务在写入后尽快 dial peers;回合内若 revision 变化,会注入 `[cst] Parliament shared context updated`
显式 `"collaboration": "parliament"`(或 env)时:
- **硬禁用** `task` / `worker_*` / runtime 委托工具
- 提供 `parliament_note`;提示词说明 chair / member 角色合同
## 换项目(清场)
```bash
cst-agent --new-project my-app
```
清空共享上下文、撤销主席与登录票,并停止本机服务。Realm 密钥保留。
## 传输安全
Peer 在 Realm 证书互鉴后做 **临时 X25519 密钥交换**,后续议会上下文同步走
**XChaCha20-Poly1305**(Monocypher,无需 OpenSSL)。握手前的发现/证书交换仍是明文元数据。
## Docker 冒烟
```bash
bash scripts/docker_parliament_test.sh
```
Alpine 内构建,chair↔member 握手 + 密封通道同步(`sealed=1` / `peer_sync`)。
## 相关标志
| 标志 | 作用 |
|------|------|
| `--realm-password-init` | 创建密码 Realm |
| `--login` | 登录 + 夺主席 + 启服务 |
| `--join` | 加入席位(不抢主席)+ 启服务 |
| `--service` / `--service-fg` | 仅启服务 |
| `--service-stop` | 停服务 |
| `--new-project ID` | 清场换项目 |
| `--peer-port N` | 登录/服务用的 TCP 端口(默认 9100) |
| `--instance ID` | 实例 id(也可用 `CST_INSTANCE_ID`;默认 hostname) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+125
View File
@@ -0,0 +1,125 @@
# 多模型 worker 与编排模式
主 agent 使用强模型进行设计与验收,子 agent 按角色绑定较便宜的模型。
用户配置目录的 `workers.json` 可设置所有项目的默认配置;存在工作区
`.agent/workers.json` 时,以工作区的完整配置为准,不合并两份配置。
在工作区创建 `.agent/workers.json`,或者通过 TUI `/agents config <JSON>`、
WebUI 扩展面板的「模型与 worker 配置」保存同样的配置:
```json
{
"orchestrate": true,
"max_steps": 20,
"max_tasks": 8,
"max_requests": 100,
"profiles": {
"worker": {
"provider": "deepseek",
"model": "deepseek-flash",
"effort": "low"
}
},
"roles": {
"general": "worker",
"explore": "worker",
"plan": "worker"
}
}
```
provider 必须已注册,使用其独立保存的凭据或 provider 的环境变量。
档案支持 `temperature`、`top_p`、`max_tokens`。不要在此文件中保存 API Key。
自定义角色 Markdown frontmatter 也可指定 `profile: worker`;JSON 中的角色绑定优先。
未绑定的角色继承主 agent;旧 `model` frontmatter 仍在原 provider 下生效。
CLI `--orchestrate` / `--no-orchestrate` 可以覆盖工作区设置。开启后主 agent
优先委派适合拆分的任务,但保留获授权的写文件和 shell 工具;简单操作可以直接执行。
规划模式和只读限制继续约束子 agent。旧 `task` 的只读调用可并行,可写调用串行。
## V2:任务控制与异步执行
默认工具配置增加 `worker_start`:`kind=read` 路由到 `explore` 角色;
`kind=edit` 路由到 `general`,必须提供 `files` 路径数组,在独立 Git worktree 中编辑;
`kind=external` 使用受保护的串行资源。模型仍由上述 profiles/roles 配置决定,
例如 explore 绑定 DS Flash、general 绑定 DS Pro,主 agent 使用 Kimi Code。
旧 `task`、worker 续接和预算配置继续兼容。
异步调用返回 `job_id`。`job_read` 查看状态,`job_wait` 最多等待 30 秒;
主循环在未完成的作业上等待,不通过反复请求模型轮询。
主 agent 的默认 `run_shell` 在 250ms 内未结束时也返回作业状态。
`effect=workspace` 表示允许安全中断的工作区命令;未声明的外部命令默认受保护,
涉及 fastboot/adb/flash 的命令始终按受保护作业处理。这是调度约定,不是安全认证。
新消息在执行中发送时会作为纠正指令:任务版本递增,旧版本后续工具调用被拒绝,
可中断作业收到取消请求;已完成的副作用不会回滚。受保护外部操作不因普通取消被杀死,
只有用户明确强制停止或程序关闭时才强制中断。不要把强制停止用于正在刷写的设备。
状态明确记录取消来源,不能把 instruction_changed/user 等同于 timeout。
纠正后只等待当前任务版本的作业;旧版本受保护作业仍在后台时,任务报告 partial,
不把新指令卡在旧操作上,也不谎报旧操作已完成。
TUI:`/task state`、`/task readonly`、`/task write`、`/task direct`(禁止委派)、
`/task auto`、`/task pause`、`/task resume`、`/task cancel JOB_ID`、`/task force JOB_ID`。
暂停/恢复是权限控制,不会重放操作;恢复后发送下一条指令继续。
WebUI `/task` 打开任务面板;Qt 桌面/Android 共用 Tasks 面板。
面板可查看作业输出、切换约束、取消作业、预览和集成编辑结果;Qt 还提供 worker JSON 配置。
HTTP 对应 `GET/POST /api/task`、`POST /api/jobs/cancel`、`POST /api/worktree`。
编辑 worker 的 `worker_diff` 预览增量,`worker_integrate` 执行三方合并。
基线包含用户已有 tracked 修改及显式作用域内的 untracked 输入,使用私有 index,
不会暂存、提交或重置用户分支。集成冲突时原文件和用户 index 不变,worktree/补丁保留。
非 Git 根目录使用直接执行或兼容 task。worktree 不是恶意 shell 的完整安全边界;
可用时编辑 shell 使用系统 sandbox,集成时再检查 tracked 修改范围。
任务结果使用 completed/partial/blocked/cancelled,存在运行作业、最近动作失败或缺少
必要工具证据时不接受 completed,并最多要求一次模型修正;这不是语义正确性的证明。
长输出写入证据文件,`evidence_read` 按 ID、字节偏移和长度检索,避免重跑命令。
默认活跃上下文预算上限为主 agent 48k、worker 24k,保留稳定系统/工具前缀。
会话旁的 `.task-v2.json` 保存状态和有限事件历史,重启只展示历史,不重启外部操作。
当前每个运行时最多保留 64 个热作业槽;已结束的槽可复用,旧状态和输出保留为文件,
可继续按 job ID 查询。达到 64 个未结束作业时明确拒绝新作业。
本地验证:`tests/test_runtime` 覆盖版本失效、只读/委派控制、完成证据、异步等待、
保护取消、恢复配置、私有 index 与三方集成冲突;`tests/test_subagent` 验证模型路由和预算。
真实 API 的费用、延迟、缓存命中提升需要运行下面的付费评测,不从模拟测试推导。
## 单机默认
默认协作模式是 **solo / legacy_workers**(未设 `CST_COLLABORATION` 且无 open
parliament meta)。本机可直接使用本节全部工具;密码 Realm 不会自动关掉它们。
仅当显式 `collaboration=parliament`(或已有 open parliament)时,这些工具被隐藏,
改走 `parliament_note`。详见 `docs/parliament.md`「单机默认」。
作业终态会入队完成事件:TUI 推送一行系统消息,下一模型回合注入 `[cst] job …`
笔记,减少盲轮询 `job_wait`。
## 任务和续接
首次通过 `task` 指定 `agent`、`description` 和完整 `prompt`,结果包含 `worker_id`。
再次调用 `task` 时传 `worker_id` 和新任务即可续接;历史、模型绑定与角色指令保存于
主会话目录中,重启恢复主会话后仍可使用。模型配置变化适用于新 worker。
运行中断不会自动重放工具;先检查当前文件状态再续接。
`max_steps` 可降低本次请求上限,不得提高角色或工作区上限。
默认每次委派最多 20 个实际模型请求,主回合最多 8 次委派、100 个子 agent 请求。
续接也算委派,重试、压缩、总结和缓存命中的请求都计数。达到上限后不再发请求,
任务返回最后可用结果与失败原因,不额外调用模型总结。下一主回合重置共享预算。
TUI `/agents` 查看角色和运行记录,`/agents config` 查看配置,
`/agents budget` 查看当前回合总量,`/agents cancel sub-1` 请求取消指定 worker。
WebUI 显示 worker 模型、请求数、缓存 token 和续接/取消入口。
每次委派的使用记录追加到 `sub-N.runs.jsonl`;最新状态在 `sub-N.json`。
## 缓存与验证
复用 worker 保留其历史前缀。角色指令位于环境信息前,任务差异作为新消息追加。
支持缓存路由的平台使用包含工作区、模型、角色、系统提示和工具 schema 的稳定标识,
相同配置的新 worker 也可利用公共前缀。其它平台仍使用自身隐式缓存或已有缓存断点。
缓存数据未返回时显示未知,不把未知视为零命中;实际命中率取决于平台。
使用 `tests/test_subagent` 验证跨平台路由、持久化续接、预算、并行与取消。
真实模型验收应固定模型版本、参数和任务,对比完成率、越界操作、无效调用、请求数及缓存。
可运行 `bash scripts/eval_workers.sh --provider kimi-code --model k3`;设置
`WORKER_EVAL_CONFIG` 为配置文件路径可对比编排方案。脚本在临时目录测试限定文件修改与
长文件定位,保留输出、会话和 worker 使用记录,不删除评测材料。
建议任务包含:限定单文件修复、读取截断输出、工具失败恢复、验证结果报告和主 agent 分派验收。
不要在真实模型评测中使用生产密钥以外的敏感数据;评测会产生 API 费用。
+23
View File
@@ -0,0 +1,23 @@
# Hermit / 寄居蟹 · 扁平 SVG 动图包
设计原则:遵循原始草图。纯色块、橙色方形身体、紫色眼睛、短脚;无渐变、无阴影、无高光。蓝色终端方块就是寄居蟹的壳。
6 个状态 × 2 个版本,共 12 个 SVG:
- idle:等待输入
- question:请求问题
- thinking:思考,黄色小钳子托腮
- coding:写码,>_ 眼睛
- success:完成
- error:遇到问题
icons/{状态}-shell.svg 是大的带壳图标;icons/{状态}.svg 是小的无壳图标。透明背景、CSS 循环动画,支持系统减少动态效果。
直接打开 index.html 查看,支持深浅背景、暂停、速度调整、自动轮播以及两个版本的 SVG 下载。无联网依赖。
接入示例:<img src="icons/idle.svg" width="24" alt="等待输入">。大图标建议 64px 及以上,小图标建议 24–32px;16px 时状态细节较弱。使用内联 SVG 时可通过 --speed 设置速度。
普通字符终端不直接渲染 SVG;图标适用于支持图形的终端界面、Webview 或 CLI 伴随应用。
**TUI 双轨**:默认极简字符/色块帧见 `src/hermit_glyph.c`(idle 眨眼、coding `>_`、ask/thinking/success/error,与本目录 HTML 动画状态一致);`make hermit-font`(Pillow + fonttools,无需 rsvg)烘焙 `res/hermit-frames/` 与 `res/hermit-icons.ttf`(sbix,PUA `U+E700+`)。`make install` / bootstrap 会安装该字体;`HERMIT_ICONS=font` 时输出码点序列(终端需选用 Hermit Icons)。
更新:橘杏色身体、莓紫眼睛、低饱和蓝绿终端壳。预览主角色以 620ms 缓入缓出插值连续改变眼睛路径与身体姿态,支持中途切换;不重建主角色。状态符号淡入淡出。独立 SVG 保留各状态循环动效;跨状态变形逻辑位于 index.html。
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="coding" role="img" aria-label="Hermit 写码 带壳"><title>Hermit · 写码</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 21 4 4-4 4m10-1h5"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="coding" role="img" aria-label="Hermit 写码 无壳"><title>Hermit · 写码</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 21 4 4-4 4m10-1h5"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="error" role="img" aria-label="Hermit 遇到问题 带壳"><title>Hermit · 遇到问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 22 5 2m6 0 5-2"/></g><path class="hermit-mark" d="M52 12v6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="error" role="img" aria-label="Hermit 遇到问题 无壳"><title>Hermit · 遇到问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 22 5 2m6 0 5-2"/></g><path class="hermit-mark" d="M52 12v6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round"/></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="idle" role="img" aria-label="Hermit 等待输入 带壳"><title>Hermit · 等待输入</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M29 23v5m10-5v5"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="idle" role="img" aria-label="Hermit 等待输入 无壳"><title>Hermit · 等待输入</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M29 23v5m10-5v5"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="question" role="img" aria-label="Hermit 请求问题 带壳"><title>Hermit · 请求问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M30 22v4m10-4v4"/></g><path class="hermit-mark" d="M47 13c0-6 8-6 8-1 0 3-4 3-4 6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round" fill="none"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="question" role="img" aria-label="Hermit 请求问题 无壳"><title>Hermit · 请求问题</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M30 22v4m10-4v4"/></g><path class="hermit-mark" d="M47 13c0-6 8-6 8-1 0 3-4 3-4 6m0 5v.1" stroke="#9957ad" stroke-width="3" stroke-linecap="round" fill="none"/></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="success" role="img" aria-label="Hermit 完成 带壳"><title>Hermit · 完成</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 25 3-3 3 3m5 0 3-3 3 3"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7 5 52 42" class="success" role="img" aria-label="Hermit 完成 无壳"><title>Hermit · 完成</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m27 25 3-3 3 3m5 0 3-3 3 3"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 80" class="thinking" role="img" aria-label="Hermit 思考 带壳"><title>Hermit · 思考</title><style>svg{--speed:1}.hermit-body{transform-origin:32px 35px;animation:hermit-idle calc(3.8s / var(--speed)) cubic-bezier(.45,0,.55,1) infinite}.hermit-eyes{transform-box:fill-box;transform-origin:center;animation:hermit-blink calc(5.4s / var(--speed)) ease-in-out infinite}.question .hermit-body{animation-name:hermit-ask}.coding .hermit-eyes{animation:none}.coding .hermit-body{animation-name:hermit-code;animation-duration:calc(1.4s / var(--speed))}.success .hermit-body{animation-name:hermit-joy}.error .hermit-body{animation-name:hermit-care}.hermit-mark{animation:hermit-mark calc(2.8s / var(--speed)) ease-in-out infinite}.thinking .hermit-eyes{animation:hermit-look calc(3.8s / var(--speed)) ease-in-out infinite}@keyframes hermit-idle{0%,100%{transform:translateY(0) rotate(-1deg) scale(1,1)}50%{transform:translateY(-1.2px) rotate(1deg) scale(1.015,.985)}}@keyframes hermit-blink{0%,42%,48%,100%{transform:scaleY(1)}45%{transform:scaleY(.12)}}@keyframes hermit-ask{0%,100%{transform:rotate(-2deg) translateY(0)}45%,65%{transform:rotate(6deg) translateY(-1.3px)}}@keyframes hermit-code{0%,100%{transform:translateY(0) rotate(-1.4deg)}50%{transform:translateY(-1px) rotate(1.4deg)}}@keyframes hermit-joy{0%,100%{transform:translateY(0) scale(1.02,.98)}45%{transform:translateY(-2.8px) rotate(-3deg) scale(.98,1.02)}70%{transform:translateY(-1px) rotate(2deg)}}@keyframes hermit-care{0%,70%,100%{transform:rotate(0)}78%{transform:rotate(-3deg)}88%{transform:rotate(3deg)}}@keyframes hermit-mark{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-1.7px) rotate(3deg)}}@keyframes hermit-look{0%,25%,100%{transform:translate(0,0)}45%,70%{transform:translate(1.5px,-.6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x="8" y="12" width="68" height="47" rx="3" fill="#277f94"/><path d="m20 25 5 6-5 6m14 1h10" stroke="#a9d9d7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/><g transform="translate(34 23)"><g class="hermit-body"><path d="M23 36v5m18-5v5" stroke="#a4574f" stroke-width="6" stroke-linecap="round"/><rect x="12" y="15" width="36" height="25" rx="4" fill="#f68042"/><g class="hermit-eyes" fill="none" stroke="#9957ad" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M31 22v3m10-3v3"/></g><path class="hermit-mark" d="M43 32v7h-8" stroke="#e9ba47" stroke-width="6" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Some files were not shown because too many files have changed in this diff Show More