Compare commits

..
98 Commits
Author SHA1 Message Date
WHCandCursor 5538ed3412 refactor: archive GUI clients; keep minimal TUI with hooks/API
ci / tcc-check (push) Successful in 1m8s
ci / linux (aarch64, 0) (push) Failing after 1h0m3s
ci / linux (x86, 1) (push) Failing after 1h0m3s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 50s
Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus
libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-30 22:49:23 +08:00
WHCandCursor ea38247161 feat: Hermit 1.1.0 — HERMIT_* env, Tauri desktop shell
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
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 38s
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 42s
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 40s
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
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)
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
WHC 580d533656 v0.9.6: documents, downloads, PDFs and the phone's own files
Four tools and a document writer, plus the storage half of the phone: until
now the picker's answer (a content:// URI) reached the engine as a path, and
it said "cannot read" -- the app had no way to hand a phone's file over.

- md_to_html (new, src/tools/web.c + cst_web_md_document()): markdown in, one
  self-contained HTML document out -- doctype, utf-8, viewport, escaped
  title, a small stylesheet; no script, no external request. It is written
  into the workspace and delivered as a file card (for_user, so it never
  enters a request), the way deliver_file does. The default name comes from
  the source file's stem or, for pasted markdown, from the title with what a
  path cannot carry turned into '-'. It has a preview, so an approval prompt
  says "render x -> y.html" before anything is written.
- download (src/tools/web.c): an http(s) URL into a file in the workspace,
  streamed with a 128 MB ceiling; a redirect chain, the same CA handling and
  the same refusals as web_fetch, but the bytes stay on disk (a half file is
  removed on any failure, including HTTP >= 400). The name comes from the URL
  when the caller names only a directory.
- read_pdf (src/tools/fs_tools.c): the text layer and page count of a PDF,
  which read_file refuses because the bytes are binary -- and a PDF is
  exactly what arrives from a phone. Same extractor an attachment uses, its
  own 4 MiB input ceiling, 256 KiB of text by default with a note when cut.
- session_search (new, src/tools/session_tools.c): every stored session --
  this one and all the ones before it -- searched for a substring, reporting
  the session's id, message count and title, then the matching lines with
  their number and role. Case-insensitive by default, capped at 20 matches.
- device_info (same file, perm_name "device"): version, system/arch/CPU
  count, workspace, sandbox, model and permissions, config/temp/program
  directories, pid -- so the model stops guessing where it runs.
- Tools 3 and 4 come with tests/test_tools_ext.c (also in both build files):
  the document is a whole page, a script tag and a javascript: link do not
  survive, a hostile title stays text and cannot write outside the workspace,
  the preview names the file, read_pdf/download refuse what they must (no
  network in a test), session_search finds a fixture session and says so when
  nothing matches, device_info names the workspace. README's tool table grew
  five rows.

- Android storage: the picker (SAF) answers with a content:// URI, and the
  engine opens files by path, so the app now copies what it points at into
  its own directory first -- app/android/src/xyz/whcself/cstagent/
  AttachFile.java, called through a new app::materializeAttachment()
  (ToolProvider.h: one API for every platform; a desktop strips file:// and
  refuses content:// with a reason). The copy needs no permission at all: the
  grant travels with the URI.
- "All files access" (MANAGE_EXTERNAL_STORAGE) is the other half: it is what
  lets the engine read a path in shared storage that a task names rather than
  picks. Android has no runtime dialog for it, so main.cpp opens the one
  Settings screen once (AttachFile.java's requestAllFilesAccess, with the
  all-apps screen as a fallback) and never insists: without it everything
  works, one picked file at a time. On the NX789J the screen came up, the
  app-op reads back "allow", and a relaunch no longer asks.
- Composer.qml hands a content:// URI through untouched (it is not a path,
  and decoding it would corrupt it); AgentCore::attachFile() turns whatever
  the picker gave into a path first.

Desktop: build clean, make check 36/36 (the new test included, also under
ASan), --uitest all green, --selftest and cst-app-selftest OK.
The APK builds and installs (86 MB, release-signed) with the new Java class
in its dex.

Version 0.9.6.
2026-10-11 18:33:41 +08:00
WHC 08008a5a4c v0.9.5: a session owns a directory, and the store hands out uuids
A session's transcript lived beside its attachments, both named after an id
that was a timestamp. Two things fell out of that: on a workspace two
machines share, a timestamp is not an id (the same second on both is enough
to hand out the same name), and "delete this conversation" had to chase the
jsonl, the title and a directory in a shared attachments/ tree, guessing what
belonged to whom. A phone's app data has nobody to tidy it by hand.

Now one session is one directory:

  <workspace>/.agent/sessions/
    <uuid>/session.jsonl     the transcript
    <uuid>/title             the window title, one line
    <uuid>/attachments/      images and delivered files (img-…, art-…)
    <uuid>/sub-1.jsonl       an agent role's own transcript
    CURRENT                  the id the next run opens

- src/agent_store.c: new_id() makes a uuid v4 (16 bytes from /dev/urandom,
  clock+pid only as a fallback), dir_for_id/flat_for_id/nested_for_id/
  shape_of_id decide which shape an id has, so every reader takes either.
  An id is a directory name, so it is checked as one (no "/", no "..",
  nothing starting with ".", not CURRENT or attachments). path_for_id() keeps
  the shape a session already has and makes the directory for a new id, so
  nothing had to change in the callers. store_list() lists the directories
  that hold a transcript plus the flat files, newest first. delete() is one
  rm_tree of the session's directory, plus the old shared attachments/<id>
  and CURRENT when it pointed there.
- cst_agent_store_migrate() (--migrate-store, or plain cst-agent for a
  session): moves a flat store in — <id>.jsonl to <id>/session.jsonl,
  <id>.title in, the shared attachments/<id>/ to <id>/attachments/. rename
  first, copy when the filesystem says no, and counts what it moved, so a
  second run is a no-op that says "nothing to move". On the real workspace:
  6 sessions moved, the shared directory gone, --list-sessions reads them
  back unchanged, a second run moved 0.
- cst_agent_store_id_of_path(): the id of a session path. This is the bug the
  whole change had been hiding: cst_agent_session_id() took the id from the
  file name, and the new file name is session.jsonl -- every session would
  have called itself "session" (the sidebar, CURRENT, undo, an agent role's
  directory, and the metrics file, which now also goes through the helper).
- src/attach.c: a session that owns its directory keeps its payloads inside
  it (<id>/attachments/); only the flat layout needs the shared directory and
  the id stem. Attachments behave exactly as before otherwise.
- tests/test_store.c: uuid shape and uniqueness, a small buffer refused, a
  new session's path and directory, write/list/info/title read back, the
  payload landing in <id>/attachments/, delete taking the directory with it;
  then an old-layout store: readable before the move, moved byte for byte,
  its payload readable through the new path, listed once, a second migration
  a no-op, deletion covering the migrated payload. Also in the Makefile and
  CMakeLists test lists.
- app/src/ui/UiTest.cpp: the image directory check wanted "/attachments/",
  which the new layout has no trailing slash for.

Desktop: build clean, make check 35/35, --uitest all green, --selftest and
cst-app-selftest OK, and --selftest opens a session of the migrated workspace.

Version 0.9.5.
2026-10-11 17:39:00 +08:00
WHC 7299f39245 v0.9.4: a turn keeps running when the app is not on screen
A phone freezes an app it cannot see, and the front end had nothing holding
it: a turn that took minutes -- a long shell command, a provider that thinks,
a build -- stopped quietly the moment the user pressed home, and the answer
was only there if the window happened to come back. The engine's own /attach
flow made this visible: an APK turn survived, a pocket did not.

- app/android/src/xyz/whcself/cstagent/CstService.java: a foreground service
  started with an explicit component (nothing outside the app can reach it,
  no intent filter) for as long as a turn runs. Being a foreground service
  makes the process one Android neither freezes nor kills; a partial wake
  lock (30 min ceiling) keeps the CPU awake with the screen off. Its ongoing
  notification says what is running -- "CST-agent is working" plus the first
  line of the prompt -- on the same cst-agent channel the notification_send
  tool uses, and taps back into the app. A phone that refuses the foreground
  start (Android 12+ does, from the background) loses the protection, not the
  turn: the failure is caught and the service stops itself.
- Manifest: FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC (API 34+ wants
  the typed one) and WAKE_LOCK, plus the <service> with
  foregroundServiceType="dataSync". The first build of this failed with
  "Expected '>', but got ' '" -- a manifest comment cannot contain "--".
- Front end: app::holdForeground(text) / app::releaseForeground() in
  ToolProvider.h, one call for every platform -- Android starts and stops the
  service through JNI, a desktop has nothing to hold (its window is the app).
  AgentCore::send() calls it when the turn starts (firstLine() of the prompt
  is what the notification says) and finishTurn() releases it, so the
  notification exists exactly as long as the work does. CST_APP_NO_SERVICE=1
  keeps a test run from touching the phone.
- On the NX789J, with the app sent home and the screen off: the service read
  back isForeground=true with its notification, the wake lock cst-agent:turn
  was taken at the start of the turn and dropped at its end, run_shell
  "sleep 60" finished with the screen dark, and the service was gone after.

Desktop: build clean, --uitest all green, --selftest and cst-app-selftest OK
(the hold is a no-op there, so nothing about the Qt side changed).

Version 0.9.4.
2026-10-11 16:51:39 +08:00
WHC d4f65b17cf v0.9.3: notification_send posts on the phone, and Java helpers have a home
The tool was the last Android one that never worked on the NX789J: it would
fail with "Notification.Builder.build() failed: no exception detail" and the
shade stayed empty. The detail was the whole story -- Qt clears a Java
exception before C++ gets to look at it, so a build() that throws and a
build() that returns null look exactly the same from here, and the only way
to catch the difference is to let Java catch it.

- app/android/src/xyz/whcself/cstagent/Notifier.java: the builder, the
  PendingIntent, build() and notify() in one try/catch that returns "" or
  the throwable's class and message. androiddeployqt copies app/android/ with
  the rest of the package source and Qt's Gradle template puts its src/ on
  java.srcDirs, so a class dropped there needs no build-script change (the
  first one is this; the foreground service of the next step goes there too).
- AndroidTools.cpp keeps what it can see from C++ (the context, the channel
  on API 26+, the small icon looked up by name, the launch intent) and calls
  Notifier.post(); the result line now carries the Java failure when there is
  one. The javaErrorText() helper of the previous attempt is gone: reading a
  pending exception from C++ is exactly what does not work here.
- On the device: the tool answers "notification sent", the system holds the
  record (channel cst-agent, importance 3, the framework small icon, the tap
  intent back into the app). Earlier attempts in the same session are in the
  transcript next to it: four failures from the old path, then this one.

Docs: docs/android.md gets the Java-helper mechanism (and why the tool has to
be wrapped in Java at all) and no longer lists notification_send as missing;
app/README.md's platform table and the Android note say where a Java class
goes.

Version 0.9.3.
2026-10-11 16:40:51 +08:00
WHC 60cbce3c5c v0.9.2: files the agent delivers come back as cards, and the saved key is used
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.

deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
  the model; cst_attach_import_artifact() stores any bytes up to
  CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
  extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
  directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
  skips for_user entries without even a "not sent" note, and a turn that only
  delivered a file no longer invents an empty user message. tests/test_artifacts.c
  pins that down end to end, including a session-file round trip.

The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
  name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
  entry is a file card (extension badge, name, type and size, Open, Share);
  a picture stays a thumbnail and opens on tap. Both shapes are read, so
  sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
  the session's own messages carries it, and hand the display name to the
  platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
  resolveActivity() checked, read permission granted) and shares through the
  share sheet; a desktop uses QDesktopServices / the clipboard.
  CST_APP_NO_OPEN=1 makes --uitest run without opening anything.

Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
  `for_user`: the file survived the file, but came back as an attachment --
  no card, and put back in front of the model. It now uses the one writer
  (cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
  card that is still running -- and the engine reports the result *before*
  the files, so the card was already complete and the files were dropped.
  A live turn therefore showed no card while a replay of the same session
  did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
  never drawn. The card now carries the reason ("no app on the phone opens
  report.md").

Also
- The saved provider key reaches the engine on every start: AgentCore::open()
  was handed an empty key and the engine never resolves credentials itself
  (only the CLI did), so every start asked for it again. It resolves the same
  order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
  the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
  and keeps failing on the NX789J -- the build() returns null and Qt clears
  the exception before C++ can read it, so the call has to be wrapped in Java
  next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
  refused), the live order of result-then-files, and the startup row count.

Version 0.9.2.
2026-10-11 16:34:47 +08:00
WHC 84f9585588 v0.9.1: the window opens on the session the engine already has open
A workspace is never empty on the second run: cst_agent_open() resumes the
last session of the store, so the engine holds a transcript before the window
exists. The window only filled its view on a session *switch*, and a start is
not a switch -- the pane came up empty next to a sidebar pointing at the very
session it belonged to (on the phone: "hello, 6 messages" highlighted, no
transcript under it).

- Main.qml: Component.onCompleted reloads the session list and replays the
  open session, the same call the sessionChanged handler makes.
- AgentCore::switchSession(): a click on the row of the session that is
  already open emits sessionChanged instead of returning quietly, so the row
  redraws its transcript rather than being a dead spot.
- --uitest: the window is now created over a seeded session (a fixture is
  written and switched to before the QML loads, which is what a restart looks
  like) and the check "the open session is on screen at startup" pins it --
  it failed with 0 rows before the fix. Two qWaits in the settings-dialog
  section, whose clicks were racing the dialog's layout: the extra startup
  work made that visible (5 failures that had nothing to do with sessions).

Verified: --uitest 79/79 (the new check among them), cst-app-selftest OK, and
on the phone -- the APK rebuilt and installed, a restart of the app draws the
whole stored session (messages, tool cards, thinking blocks, copy icons).

Version 0.9.1.

mode = coding
2026-10-11 15:26:32 +08:00
WHC c2320f946b v0.9.0: the Qt front end as an Android APK, images in the transcript
Two strands land together: the window draws pictures now, and the whole front
end builds, installs and runs on the phone with the engine linked in-process.

Images (the "make a picture actually show up" part)
- Engine, additively: cst_agent_hooks_t.on_tool_images(call, list, n, ud) fires
  when a call left attachments behind, and cst_agent_pending_rel() /
  cst_agent_pending_is_image() read that list without the app touching the
  store. tests/test_attach.c::test_agent_pending pins the three cases (an
  image, a stored name that passes cst_attach_rel_ok, out-of-range NULL).
- App: image://cstattach/<stored name> (app/src/ui/SessionImages.cpp) serves
  only the current session's attachment directory, checked with
  cst_attach_rel_ok, decodes down to the requested sourceSize and never
  upscales, and holds a mutex because requestImage() runs on the render
  thread while the directory can change underneath it. AgentCore gained
  attachmentDir(), pendingImages(), the toolImages()/promptImages() signals
  and hookToolImages(); history() gives replayed rows their images too.
- QML: ImageStrip.qml (a thumb strip, <=150 px tall, shaped by the picture,
  tooltip with name/size/origin, the file's name when it went missing),
  wired into MessageCard and ToolCard. Qt 6's ListModel cannot hold a
  list-valued role (append() reads back undefined, setProperty drops it), so
  a row carries one image per line as <stored>|<name>|<origin>|<w>x<h> and
  ImageStrip decodes that.

Android (the engine is already in the APK; this is the front end)
- app/src/main.cpp: on Android the workspace, config, tmp and HOME go under
  the app's own data directory before the engine opens anything, because
  QDir::currentPath() is "/" there and /tmp is not writable.
- app/android/: the manifest (QtApplication/QtActivity, INTERNET and
  POST_NOTIFICATIONS, adjustResize, no storage permission -- the picker will
  be the system one), strings, a vector icon; minSdk 24 and targetSdk 35 come
  from app/CMakeLists.txt through QT_ANDROID_MIN/TARGET_SDK_VERSION.
- app/src/platform/android/AndroidTools.cpp: the Android file behind
  PlatformInit(), owner "app:android" (notification, clipboard, share sheet,
  file picker are stubs for now, so the tool list does not lie about them).
- app/tools/selftest.c decides its scratch workspace with os_temp_dir(), so
  the C smoke test runs on the device too.
- scripts/build_apk_android.sh: sources to installed APK in one command
  (environment checks, qt-cmake configure, --target apk, adb install), and
  the host-side plumbing the NixOS + qt.io combination needs -- the NDK
  sysroot include dirs for Qt's FindGLESv2/FindEGL (they only look under
  <sysroot>/include), -L$PREFIX/lib on the shared/module link lines as well
  as the executables', the Android debug keystore created on first use with
  QT_ANDROID_SIGN_APK + QT_ANDROID_KEYSTORE_* (androiddeployqt copies the
  unsigned gradle output and signs it itself), and the #!/bin/bash shebang of
  the build-tools wrappers, which NixOS does not have.
- scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/): the
  release lint task is switched off -- lintVitalAnalyzeRelease resolves its
  own tool jars straight from dl.google.com, bypassing every repository the
  build configures, and fails the build after all the real work is done.
- Icons: app/icons/*.svg (Breeze's 24@3x files, the 19 names the window asks
  for) are compiled in as :/cst-icons and ThemeIcons.cpp falls back to them
  when the platform has no icon theme. On the phone the Qt theme lookup found
  nothing and the QML glyph fallback was an empty box in the phone's fonts.
- docs/android.md: "The APK (Qt front end)" -- the SDK assembled without
  sdkmanager, the host Qt's missing libraries on NixOS, the Gradle mirrors,
  lint, signing -- and a Status that says what runs where. app/README.md
  gains the images and APK lines, and stops claiming Android platform tools
  that are still stubs.

Verified: engine `make check` green; desktop `cst-app-selftest: OK` and
--uitest all checks pass; phone (NX789J, Android 16, arm64-v8a) -- the APK
installs, starts on the workspace under /data/user/0/xyz.whcself.cstagent/
files/, a turn reaches a configured provider over HTTPS from inside the ART
process and renders the reply, rotation keeps the session and docks the
sidebar, and the toolbar/card icons draw.

Version 0.9.0.

mode = coding
2026-10-11 15:22:10 +08:00
WHC 82dee65942 v0.8.15: custom providers and model parameters under Advanced options
The settings dialog could pick a preset, a model and a key and nothing else.
Two things were missing: a provider the presets do not know, and what the
model is asked to do while it answers. Both live under "Advanced options"
now, next to the entry they belong to.

Custom providers (engine)
- cst_models_user_set_provider() / cst_models_user_drop_provider() write the
  engine's own user layer, <config>/cst-agent/models.json:
  {"providers": {"myllm": {"api_base": ..., "key_env": ...,
                           "default_model": ..., "models": [...]}}}
  and reload the registry, so the provider is at once in the provider list,
  in the wire models[] and in the CLI's /provider -- the file a hand-written
  override would go into is the same one, so the GUI and an editor agree.
  The name is checked (1-31 of letters, digits, '.', '_', '-'), a preset's
  name overrides that preset (a self-hosted endpoint, a proxy), everything
  else in the file (model rules) is kept, and a file that is not a JSON
  object is never overwritten. Dropping only removes the user's own entry.
- AgentCore::providers() gained "custom" (source == "user"), and
  addProvider() / removeProvider() drive the two calls above.

Model parameters (engine + wire)
- cst_agent_config_t and cst_llm_request_t carry temperature, top_p and
  max_tokens. 0 means the field is not sent at all -- an absent field is the
  provider's own default, which "temperature": 0 is not (deterministic
  decoding is a real choice), so cst_llm_build_body() only adds what was set.
  tests/test_llm_body.c:test_gen_params pins both halves.
- settings.json keeps them per provider ("params": {...}) via
  cst_settings_remember_params(); cst_settings_remember() carries them along
  with the provider choice, and cst_agent_create() reads them, so a CLI or
  TUI run sends what the window was set to. An all-zero config (the CLI)
  leaves what the GUI saved alone.
- AgentCore::genParams() / setGenParams(), and useProvider() applies both the
  remembered parameters of the provider it lands on and a base URL that was
  edited in the dialog.

The dialog
- Rewritten around one scrolling body with a fixed footer: a long model list
  used to push the key field and the buttons out of the dialog. The model
  chips are filtered (a filter field appears past twelve) and capped at 40
  with "... +N more", which is what a router with hundreds of models needs.
- "Advanced options" folds out the provider entry (name, base URL, key
  variable, default model, models; Save provider / Remove from models.json)
  and the model parameters (temperature, top-p, max tokens; empty = not
  sent). The fields follow the picked provider and are what gets saved, so a
  hand-typed edit is never lost to a stale property.

Verified: --uitest 67/67 (15 new checks: parameters round-trip through the
engine, a provider added through the form and removed again, a bad name
refused, a preset not removable), --selftest, ctest cst-app-selftest, and
make check with the new body test.

Version 0.8.15.

mode = coding
2026-10-11 13:26:02 +08:00
WHC 3fd039cb51 v0.8.14: icons from the desktop's theme (Breeze), readable text, composer frame
The window drew its icons with unicode glyphs -- a clip emoji for attach, a
gear, a hamburger, a chevron, a multiplication sign for delete. Those render
in whichever font happens to cover them (colour emoji in a grey toolbar) and
are a box on a machine without that glyph. They now come from the icon theme,
the way every other desktop app gets them.

Icons
- app/src/ui/ThemeIcons.{h,cpp}: a QQuickImageProvider for
  image://csttheme/<name>?color=%23rrggbb -- QIcon::fromTheme() in the
  desktop's theme (whatever the platform theme set, else breeze-dark, breeze,
  hicolor), tinted with QPainter's SourceIn and rasterised at the requested
  size and the screen's device pixel ratio. Qt only builds its theme search
  paths from XDG once a theme name is set, so the provider fills them itself
  (GenericDataLocation/icons, ~/.icons and, on NixOS,
  /run/current-system/sw/share/icons, for a shell started outside the desktop
  session). An unknown name yields a null image, which QML reports as
  Image.Error.
- app/qml/ThemeIcon.qml: the one component that asks for an icon. Every use
  still names the old character as `glyph:`, so a host with no icon theme
  degrades to exactly what the window looked like before instead of going
  blank. installThemeIcons() is called by main() and by --uitest, so the test
  loads the same icons the app does.
- Replaced: nav (application-menu), settings (settings-configure), attach
  (mail-attachment), copy (edit-copy), session delete (edit-delete), new
  session (list-add), expand/collapse (arrow-right/arrow-down), jump to the end
  (go-down), the plan checklist (task-complete/box). A tool card also shows
  what kind of tool ran -- Theme.toolIcon(): pencil for patch/write, text page
  for read, folder for list/glob, magnifier for grep, braces for a shell,
  globe for web, list for a plan. Toolbar icons are 22 px (Breeze's toolbar
  size), card and row icons 14-16 px.

Colours (the "some text is unreadable" part)
- textFaint #6d757b -> #8d959c: ~3.4:1 -> ~5:1 on the card fill. It carries
  hints, provider URLs, session metadata and placeholders, so it is text, not
  decoration.
- A selected provider row and the open session row write on the accent fill;
  they now use accentText / accentTextDim / okOnAccent instead of faint grey
  and green on blue (named "accentText", not "onAccent": QML reads any `onFoo`
  property as a signal handler and refuses the assignment).
- The filled buttons (Send, Use this) take their ink from Theme.accentInk.

Composer
- The frame is one Theme.pad around its column on every side (anchors.fill,
  implicitHeight + 2*pad). With the top margin alone the last row -- the clip
  and Send -- hung over the lower border, which is what put the clip on the
  frame instead of inside it.

app/README.md gains an "Icons, colors" section (the provider, why the theme
search paths are filled by hand, the qtsvg requirement for rasterising Breeze,
the tool map, and the contrast rules).

Verified: --uitest 52/52, --selftest OK, and the 0.8.14 window relaunched in
the Plasma session (no QML warnings).

Version 0.8.14.

mode = coding
2026-10-11 12:53:49 +08:00
WHC 8694afca94 v0.8.13: tool cards paired by call id, markdown rendered while it streams
Two things the window got wrong once a real turn ran through it, plus the
switch that was invisible on a dark theme.

Tool cards (app/src/core/AgentCore.*, app/qml/Main.qml, ToolCard.qml):
- A turn that called the same tool twice left the first card on "running"
  forever and gave its result to the second card: both were paired by name.
  cst_agent_hooks_t hands the call (ctl_tool_call_t) to every callback, so the
  id travels with the signals now (toolEvent(phase, id, name, detail),
  toolResult(id, name, exitCode, output)) and findTool() matches on it, in the
  same way AgentCore::history() fills it from tool_call_id / tool_calls_json
  for a replayed session. Only a call without an id -- an old text-protocol
  session -- still falls back to the name.
- Nothing may keep spinning after a turn: closeAllRows() runs when the turn
  ends and settles every open row; a call that never came back shows "no
  result" (state "unfinished") and no longer claims an exit code.

Streaming text (app/qml/Main.qml, ChatView.qml):
- The answer was plain text while it streamed and turned into markdown only
  when the turn ended. A 120 ms timer now re-renders the open answer with the
  engine's own renderer (AgentCore::renderMarkdown -> cst_web_md_html), so a
  list, a table or a code fence fills in formatted as it arrives. A row keeps
  its own "html" field; a replayed session renders once, where its rows are
  built.

QML details:
- the Plan mode Switch got a drawn indicator: the Basic style's pill is a dark
  pill with a dark knob on this theme, which made the control look disabled.
- Every engine signal handler is a plain window function now
  (engineToolEvent, engineToolResult, engineTurnFinished, engineAssistant,
  ...), which is also what lets --uitest drive the exact path; rowJson(i)
  returns a transcript row as JSON for it (an object would not survive
  QMetaObject::invokeMethod).

Tests: 6 more checks -- a call opens a card, two calls of one tool are two
cards, the result lands on the card of its own call, the other card is
untouched, the turn end settles an open call, a delta opens a streaming answer
and markdown is rendered while it still streams. 52/52 pass, --selftest,
ctest cst-app-selftest.

Version 0.8.13.
2026-10-11 12:44:20 +08:00
WHC cbd71b83c7 v0.8.12: attachments in the composer, picker + drop + paste
A prompt can carry files now, and they come in the three ways a desktop user
expects: the 📎 button in the composer (QtQuick.Dialogs' FileDialog), files
dropped anywhere on the window (a DropArea over it, with an accent border and
"drop to attach" while something is dragged), and an image on the clipboard
(Ctrl+V). Text keeps the editor's own paste: the composer asks
AgentCore::clipboardHasImage() first and only takes the keystroke when there is
an image.

AgentCore (app/src/core/AgentCore.*):
- pending is a property now (QStringList) fed by cst_agent_pending_describe();
  attachFile() and attachClipboard() wrap cst_agent_attach_file() /
  cst_agent_attach_bytes(), clearPending() wraps cst_agent_pending_clear(), and
  seesImages() reports whether the model is shown images at all.
- the engine's description says what the model gets ("png 12.0 KB"), not where
  it came from, so the window remembers the names it attached and puts them in
  front: "note.txt — text 21 B".
- pendingChanged() fires on attach, on clear and when a turn ends (the prompt
  consumed the list), which is what makes the chips move.

Engine (src/attach.c):
- cst_attach_describe() printed every sub-megabyte size as "%.0f KB": a 21-byte
  text file came out as "text 0 KB". It is "B", "KB" with one decimal or "MB"
  now, and test_attach still passes.

QML: the composer shows one chip per pending file with "clear all"; the hint
line mentions 📎/Ctrl+V and says when the model is text-only (images then
travel as notes); Main.qml owns the DropArea and the drag feedback.

Verified: 44/44 UI checks (8 new: attach ok, one pending, the name is shown, a
missing file is refused, one chip in the composer, clear all empties it, the
clipboard image is seen and attached), --selftest, make check rc=0 (test_attach
included), and screenshots of the chip row.

Version 0.8.12.
2026-10-11 12:35:22 +08:00
WHC be4025e276 v0.8.11: theme the settings dialog's fields and buttons
The Basic style paints its own controls: the two text fields came out as white
boxes on the dark panel and the buttons looked nothing like the rest of the
window. Both fields now carry the surface, border and focus colors the chat
cards use (same shape as AskDialog's field), and the buttons follow the
composer: Close is a quiet bordered button, Use this is the accent one.

No behavior change; 36/36 UI checks still pass and the dialog was looked at
again through CST_APP_SHOT.

Version 0.8.11.
2026-10-11 12:27:44 +08:00
WHC 69842edf16 v0.8.10: settings dialog (provider, model, key), window starts on a provider
The window could be started, but it could not be pointed anywhere: a provider,
a model and a key only came in on the command line. The gear in the header now
opens a dialog that changes all three on the engine's live config.

AgentCore (app/src/core/AgentCore.*):
- providers(): the engine's preset table (cst_provider_count/at), with base
  URL, the remembered or preset model, whether a key exists and where it comes
  from (cst_creds_resolve: "saved", or the environment variable), the preset's
  key variable and the source ("built-in" / "user"). A key is read into a local
  buffer only to answer "is there one"; it never leaves the C++ side.
- modelChoices(provider): the provider's own list (cst_provider_models), the
  fetched cache (cst_ps_cached -- which writes through its out-parameters, so
  it takes a real int, not NULL: passing one cost a segfault) and the
  remembered pick. No network. modelInfo() is cst_model_info + its "200K ·
  text only" label; usage() is cst_agent_usage for the session and the turn.
- useProvider(provider, model, key) -> "" or a reason: a different provider
  goes through cst_ps_switch_provider() (the key is verified against /models
  before it is saved; offline it is kept unverified and the header says so),
  a key for the current one through cst_ps_set_key(), and the model is
  remembered in settings.json. syncConfig() takes the engine's config back
  over the app's copy, so provider/model/approval read correctly afterwards.
  The engine's session is never touched by a switch.
- open() no longer starts with an empty provider: it takes the one the engine
  used last (settings.json), else the built-in default, plus that provider's
  base URL and model. Without this the first turn could not be sent at all and
  no entry in the dialog was "in use".

QML (app/qml/SettingsDialog.qml, Main.qml):
- a modal dialog with the themed surface the other dialogs use (the Basic
  style's light panel washes out the dark theme), the provider list with its
  key state, a model field with the info label and clickable candidates, a
  password field for a new key, the session's token counters, and a note that
  reports what the last "Use this" answered.

Tests: 8 more checks -- the presets are readable, an unknown provider is
refused, the counters are readable, the gear opens the panel, the rows exist, a
model typed into the field reaches the engine, the note says so, Close puts the
dialog away. 36 checks pass. --uitest also points XDG_CONFIG_HOME at its
scratch directory now, so a run cannot rewrite the user's settings.json or
credentials, and CST_APP_SHOT writes shot.png at the end plus
shot-settings.png while the dialog is open (that is how the dialog above was
reviewed on a machine without a screen).

Verified: 36/36 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of the dialog.

Version 0.8.10.
2026-10-11 12:25:33 +08:00
WHC f5d340bd2a v0.8.9: sessions in the Qt front end, transcript replay, UI test helpers
A conversation is now something the window can pick. The sidebar lists the
workspace's stored sessions and switching replays one into the view.

Sessions (app/src/core/AgentCore.*, app/qml/SessionRow.qml, Sidebar.qml):
- AgentCore::open() resolves the workspace's current session through
  cst_agent_store_resolve_default() before the engine starts. Without it the
  engine opened session.jsonl in the process's working directory, next to a
  session list that stayed empty.
- sessionId property; sessions() (id, title, firstUser, messages, mtime in ms,
  current); newSession(), switchSession(), deleteSession() (moves the engine
  off a session it has open first), renameSession(); sessionChanged and
  sessionListChanged signals. No mutation runs during a turn.
- history() turns the open session back into the rows the live signals build
  (kind/role/text/name/state/detail/output/exitCode/complete): system prompt
  skipped, notes and attachments shown, assistant reasoning as a think row,
  tool_calls_json as one card per call, and each result paired with its call
  the way findTool() pairs the live stream. A call with no result keeps state
  "unfinished" ("no result"), so a replay never shows a spinner that cannot
  stop. Main.qml replays on sessionChanged.
- SessionRow.qml is a file of its own: as an inline component it would not see
  the Repeater's modelData. Session controls are disabled while a turn runs.

Tests (app/src/ui/UiTest.cpp, app/src/main.cpp):
- the fixture session is written through the store's own API (a tool call with
  its result), then the checks click its row, assert the five replayed rows and
  the paired result, start two new sessions (the view empties), delete one by
  its button (the open one stays), and switch back. 28 checks in total.
- item() falls back to the visual tree: delegates a model owns are not always
  in the window's QObject tree, which is why the rows were invisible to
  findChild even though they were on screen.
- --uitest drives a scratch workspace unless --workspace is given; it creates
  and deletes sessions. CST_APP_SHOT=file saves the window as a PNG when the
  run ends and CST_APP_DUMP=1 prints the session rows seen; both are how the
  layout gets looked at on a machine without a screen.

QML: ThinkBlock's click target is a TapHandler on its row (a MouseArea anchored
inside a layout is undefined behavior and Qt said so).

Verified: 28/28 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of a replayed session (cards, tool card with its result, the
think block, the sidebar list).

Version 0.8.9.
2026-10-11 12:14:59 +08:00
WHC da812cc7e8 v0.8.8: Qt front end redesign, responsive shell, UI input tests
The window is no longer one file pretending to be a chat box. The transcript,
the run panel, the composer and the two consent dialogs are their own QML
components over one Theme singleton, and the shell around them adapts to the
screen: docked and scrollable on a desktop, a slide-out drawer on a phone.

QML (app/qml/, new except Main.qml):
- Theme.qml: single QML singleton, dark Breeze-flavoured tokens (surface,
  border, accent, text/textDim/textFaint, radius, pad, gap, sizes, monospace);
  diffLineColor() for unified-diff rows, compactBreakpoint 880, touchTarget
  34, segmentHeight 30, headerHeight 42.
- ChatView.qml: a row-type dispatcher over the transcript model (message,
  thought, tool, note), follow-the-tail scrolling with a "jump to latest"
  button once the user scrolls up, and an empty state that says where the
  agent runs and what it can do.
- MessageCard.qml, ThinkBlock.qml, NoteLine.qml, ToolCard.qml (status pill,
  arguments, collapsible output, 500-line cut, diff colouring, spinner while
  the call runs), TodoPanel.qml.
- Sidebar.qml: workspace/provider/status, mode and approval segmented rows,
  plan switch, the engine's todo list, version footer; scrolls, so a short
  window still reaches every control.
- Composer.qml: auto-growing field, Enter sends / Shift+Enter newlines,
  red Stop while a turn runs, autocorrect hints off, finger-sized targets.
- ConsentDialog.qml (Deny / Once / Always -> CST_PERM_*) and AskDialog.qml
  (the model's options become buttons, free text stays possible).
- Main.qml: shell with header, draggable sidebar splitter (hover feedback,
  200-560 px, double-click resets), transcript, composer, the two dialogs,
  Esc to cancel and Ctrl+L to clear. Under 880 px the sidebar becomes a
  drawer with a scrim instead of squeezing the chat away.

AgentCore / main.cpp:
- provider, model, approval and planMode are properties now; approval and
  plan mode write back into the engine's config, and renderMarkdown() calls
  cst_web_md_html(), so a table or a code fence looks like the WebUI's.
- the todo hook writes m_todo on the GUI thread (it was written from the
  worker thread before: a data race on a QString member).
- --size WxH for the window (the layout tests drive it), and the Basic style
  is chosen by default: the Breeze Quick style registers its types with
  relative URLs, which floods the log and breaks the composer.

UI tests (app/src/ui/):
- --uitest drives the real window with synthesized input and exits with the
  number of failures: 15 checks over docking/drawer transitions, mouse clicks
  and touch taps reaching the engine's mode, the plan switch, the composer
  arming the send button, and a 460x900 -> 1200x820 resize round trip.
- every control the tests touch carries an objectName; the sidebar's
  implicitHeight binding loop is gone.

Tests: cst-app-selftest and --uitest pass in the standalone app build,
ctest cst-app-selftest passes in the repo build (38/38 on the host).

Version 0.8.8.
2026-10-11 11:58:23 +08:00
WHC 5ba215b36c v0.8.7: tool capability metadata, in-process engine, Qt app, Android engine
The engine can be embedded now instead of only driven from a terminal: the
same objects link into a Qt front end (app/) that owns a cst_agent_t inside
its own process -- no port, no token, no second process -- and that front end
now runs on the desktop and, engine-side, on the phone.

Capability metadata (include/cst_tools.h, src/tools/registry.c):
- cst_tool_def_t gains perm_name, platforms and available (a probe that
  returns a reason), so a permission is addressable by name and a tool can
  say why it cannot run here.
- cst_platform_name(), cst_tool_platform_ok(), cst_tool_available(),
  cst_tool_meta_reload(), cst_tool_perm_name() and CST_PLAT_DESKTOP[].
- cst_tool_register() may take over a built-in name that is not runnable on
  this platform, so a front end replaces it with its own implementation.
- the registry entries carry their class (delete_file -> "delete"), the
  desktop clipboard tools carry a clipboard probe; find/active/run gate on it.
- /api/settings.tools[] exposes perm_name, platforms, available, why and the
  declared parameters.

Engine as a library (Makefile, CMakeLists.txt):
- libcstcore.a from the CORE_OBJ set, and a CMake cstcore target shared by
  the CLI and by app/ (-DCST_BUILD_APP=ON): the engine is compiled once.
- Android branch for the vendored sashidown: a per-ABI archive built with the
  NDK's aarch64-linux-android24-clang wrapper, because CMake's compiler is
  plain clang and keeps --target/--sysroot in its own variables.

Qt front end (app/, new):
- AgentCore wraps one cst_agent_t: turns run on a worker thread, every
  cst_agent_hooks_t callback becomes a Qt signal, and the two hooks that need
  an answer (on_permission, on_ask_user) block the worker until the GUI
  replies; mode, approval and plan mode go through the engine's config.
- ToolProvider hands the front end's tools to the engine (owner "app:*"); the
  Linux one is notification_send with a probe for notify-send/D-Bus.
- Main.qml: transcript, tool blocks, consent dialog (Deny/Once/Always ->
  CST_PERM_*), ask_user, mode switch, Esc to cancel; --selftest stays
  headless and runs the embedding checks without QML.

Android (scripts/, docs/android.md, new):
- scripts/build_third_party_android.sh builds static mbedTLS + libcurl for an
  ABI (CA path /system/etc/security/cacerts); build_android.sh builds
  cst-agent, libcstcore.a and the test binaries for the phone, can push them
  and run the whole suite there.
- sandbox_exec.c keeps the plain exec path on Android: the seccomp supervisor
  compiled in and the probe reported "seccomp", but writes outside the
  workspace were not denied on the device -- claiming a sandbox that does not
  hold is worse than reporting none.
- tests/test_diff.c compares layouts only against real GNU diffutils: toybox's
  diff prints the same hunk header with its own hunk/context rules.

Tests: test_tool_meta (metadata, platform gate, probe reasons, takeover of a
built-in name) and test_embed (in-process embedding contract) are new; the
existing registry/llm_body assertions now follow availability. 38 test
binaries pass on the host, 36/36 on arm64-v8a Android 16, cst-app-selftest
passes in both the standalone app/ build and the repo build.

Version 0.8.7.
2026-10-11 11:42:04 +08:00
WHC e0b0eddf3a v0.8.6: general mode, clipboard text, PDF text extraction
Working mode: --mode coding|general (CST_MODE, settings.json, /mode,
WebUI run settings). general swaps the default framing for a general
assistant one -- workflow, safety notes, tool wording (run_shell, the
subagents) -- and /status shows it; minimal (the paper profile) stays
byte-frozen. The prompt state carries the mode so a switch leaves a
system note in history.

Clipboard: clipboard_read / clipboard_write tools (16 MiB cap read,
default 64 KiB window; writing is a WRITE permission with a preview) on
pbcopy / wl-copy / xclip / pbpaste / wl-paste / xclip and the Win32
clipboard. In the TUI, /attach paste falls back to clipboard text when
there is no image on it.

PDF: a dependency-free text extractor (src/pdf_text.c): inflate,
Flate/ASCIIHex/ASCII85, PNG/TIFF predictors, layout-independent object
scan, object streams, page tree walk with inherited resources, content
lexer (Tj/TJ/'/" and the Td/TD/T*/Tm spacing heuristics), per-font
ToUnicode CMaps and inline images skipped. Encrypted files and pages
with no text layer are refused with the reason.

Attachments: /attach (a path or files dropped on the terminal) now take
images, PDFs and text. A PDF is stored as its text layer with the mime
still saying application/pdf and its page count; anything else has to be
UTF-8 text. Documents travel inline as text parts, so CST_NO_IMAGES and
text-only models no longer drop them, while images keep their old path.

Tests: test_pdf_text (plain, bare and zlib-framed Flate, inline image,
CID + ToUnicode, object stream, error paths), documents in test_attach,
inlined documents in test_llm_body, mode in test_config.
2026-10-11 11:09:39 +08:00
WHC 50abcf1a91 build: finish cross-platform release tooling 2026-10-10 16:21:00 +08:00
WHC 9bf7707912 v0.8.5: WebUI process timeline, Gemini-style images, aligned layout
Process timeline: a run of consecutive thinking and tool calls is one
block that folds to a single line ("思考 4 次 · 工具 6 次"; while running
it shows the current action) and opens into a compact timeline, one row
per thought or call. Visible text, a note or the next prompt closes the
block; history and the live stream follow the same rule. show_thinking
now also applies while streaming instead of forcing thoughts open.

Layout: the avatar gutter and the flex-wrap that dropped it under long
answers are gone, so answer, process blocks, notes and composer share one
left edge. The text column and composer are both 720px (--content); the
rail and run panel grow with clamp() on wide screens. A rebuild after
each turn no longer replays every entrance animation, and the turn time
survives it. Message actions are icon buttons and only appear under text.

Images: a prompt's images sit above the bubble, right-aligned (one keeps
its shape, several are 152px rounded tiles) and open in a lightbox (Esc,
arrow keys). Images uploaded from the page show as thumbnails over the
composer and appear on the prompt as soon as it is sent.

Also: ?theme= is no longer overridden by app.js, the user bubble is
visible in the light theme, and the narrow top bar no longer wraps.
2026-10-10 14:54:36 +08:00
WHC 7e1dbd9afa v0.8.4: WebUI sessions, diff, settings, slash commands and extensions
Sessions: rename, delete and search; /api/retry regenerates the last
answer, editing a prompt resends from there; /api/export downloads the
transcript as Markdown (cst_session_export_md). Session messages carry
note/tool_result markers, so a reload rebuilds the tool steps with
their arguments, output and exit status; subagent progress is shown.

Diff and settings: /api/diff shows the real diff; /api/settings covers
protocol, max_steps, tool switches and confirm; /api/permissions edits
the rules; sandbox status is reported. Session rename no longer reads
past a short title.

Commands: slash commands with completion in the composer (/model,
/provider, /new, /retry, /undo, /compact, /export, /diff, /init,
/skill and more), a Ctrl+P palette, Shift+Tab cycles the approval
mode, F1 or ? shows the keys. "Thinking expanded" is saved in the
settings (show_thinking), "expand all tool output" in the browser.

Extensions: GET /api/extensions lists MCP servers, plugins, skills,
agent roles and subagent runs; POST /api/extensions/reload and
/api/extensions/trust; a workspace switch reloads them. /api/init and
/api/skill run on the server (cst_init_prompt shared with the TUI).

Errors, statuses and [cst] notes are shown in Chinese with a readable
reason instead of rc=; a failed turn's error survives the reload.

Tests: scripts/e2e_webui_sessions.py, e2e_webui_workspace.py and
e2e_webui_ext.py, run from e2e_webui.sh.
2026-10-10 13:57:17 +08:00
WHC 2d1052fd5f v0.8.3: WebUI models, providers and keys; garbled thinking fixed
Streaming: Chinese reasoning showed up garbled in the WebUI. The agent
loop now feeds visible/thinking text in runs instead of byte by byte,
and the WebUI holds an incomplete UTF-8 tail until the next chunk
(cst_utf8_hold_feed), so every think_delta/assistant_delta is whole.

src/provider_switch.c: model list fetch/cache, key verify-and-save and
provider switching moved out of tui.c and shared by TUI and WebUI.

WebUI API:
- GET /api/providers, GET /api/models[?refresh=1] (401 on a rejected
  key, still listing built-ins), POST /api/provider, POST /api/key
  (key checked against /v1/models, saved to credentials 0600, never
  echoed in replies, events or logs)
- /api/config validates everything before applying, persists model,
  effort and approval (not yolo), warns when effort does not apply;
  plan_mode no longer forces read-only
- state: turn_usage, api_base, key_set/key_from, model_info,
  effort_applies, sees_images, max_steps, tool_profile, perm_timeout;
  permission events carry their timeout

Frontend: searchable model picker with refresh and manual entry,
provider picker and key dialog, effort/vision hints, toasts instead of
silent failures, Chinese error text, real protocol in the settings and
usage panels, approval countdown from the server's timeout.

Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake
/v1/models, wrong key, switching, persistence, Chinese thinking) run
from e2e_webui.sh, scripts/tui_keys/provider.py.
2026-10-09 15:54:53 +08:00
WHC e238f5f37d v0.8.2: build fixes, WebUI security and correctness
Build: <fcntl.h> in tui.c, format-truncation in http_srv.c and /web.

WebUI:
- refuse cross-site requests (Origin must be the page itself) in the
  default no-token mode: any page could POST approval=yolo and a prompt
- session/workspace/undo/compact/attach/model changes are 409 while a
  turn runs (use-after-free, messages landing in the wrong session)
- ask_user returned "dismissed" at once instead of waiting
- cancel and shutdown wake a turn parked on a permission or question;
  SIGTERM mid-turn no longer hangs or crashes
- a page opened while a question is pending still gets it;
  Last-Event-ID resumes the stream; event seq matches the SSE id
- token mode: page assets load without the token, images via cookie
- --web-open works, --web-port/--web-token are validated
- frontend: gate keys ignored while typing, clear-images button,
  reasoning no longer wiped while streaming

HTTP server: auth and size cap before reading the body, strict
Content-Length, NUL in url_decode, 413 on keep-alive, idle timeout,
absolute-form only without a leading '/', case-insensitive Connection,
header deadline, 400 for long request lines, unlink before close,
poll() instead of select(), SO_EXCLUSIVEADDRUSE on Windows.

Markdown sanitizer escapes quotes in attribute values.

/web: passes this session's provider/model/base/effort/approval (key
via the environment), keeps output off the TUI, shows the real URL and
keeps CURRENT on the TUI's session.

Tests: scripts/e2e_webui_ask.py, scripts/e2e_webui_http.py (run from
e2e_webui.sh), sanitizer quote test.
2026-10-09 13:51:34 +08:00
WHC adf3e9f024 v0.8.0: WebUI redesign, /web, workspace switch, 75% budget, no shell timeout
The front end is rebuilt on the design tokens the desktop app ships (surfaces,
color-mix borders, alpha hovers, accent): a 30-icon inline SVG sprite, dark and
light themes, layout switches as deep links (?side=0, ?panel=0, ?theme=light)
resolved before the first paint. The empty page is one line and leaves with the
first turn; the right rail keeps three cards - todo, file changes (+N -M) and
context (tokens, prompt cache hit rate). Both rails animate in and out, and a
collapsed rail widens its grid track to 0 instead of being removed, so the
transcript never slides into a zero-width column.

- src/webui.c: POST /api/workspace - re-root the agent at another directory
  (permission rules, metrics, AGENTS.md follow) and start a session there, as
  sessions live per directory; state also reports cached/cache_miss tokens
- src/agent_loop.c: cst_agent_set_workspace(), rebuilt prompt and re-measured
  budget; the auto-compact threshold is 75% of the window (was 60%)
- src/tui.c: /web forks a WebUI next to the TUI - separate process and a new
  session, because cst_webui_run() blocks and two writers corrupt one session
- src/os_compat.c, src/os_win.c: run_shell runs without a time limit unless
  CST_SHELL_TIMEOUT asks for one, so builds and long clones finish
- src/main.c, include/cst_webui.h: one CST_WEBUI_DEFAULT_PORT for --help, the
  docs and /web; Termux opens the browser through termux-open-url
- README.md, docs/webui.md, docs/env_openai.md: /web, /api/workspace, the new
  state fields, the 75% threshold and the layout deep links

tests/e2e_webui.sh green (permission round-trip, token paths, --web-dev);
make check green.
2026-10-09 11:50:13 +08:00
WHC 6e81513e36 v0.7.8: WebUI (--web): HTTP/1.1 + SSE front end, engine API v1, tests
A second implementation of cst_agent_hooks_t next to the TUI: the turn runs on
a worker thread, the hooks publish events into a ring buffer that SSE clients
read, so the engine itself needs no changes.

- src/net.c, include/cst_net.h: POSIX/Winsock sockets, non-blocking connect
- src/http_srv.c, include/cst_http.h: HTTP/1.1, keep-alive, chunked streaming,
  body caps (413), Expect: 100-continue, connection cap, exact routing
- src/web_md.c, include/cst_web_md.h: streaming markdown -> sanitized HTML
  (tag/attribute allowlist, javascript: URLs and off-site images dropped)
- src/webui.c, include/cst_webui.h: event ring + SSE broadcast, all routes,
  token (Bearer/Cookie/query) and loopback Host checks, 3 s auto-deny when no
  client is attached
- src/webui_stub.c, CST_WEBUI=0 / CST_BUILD_WEBUI=OFF: build without the WebUI
- web/: no-build front end (transcript, approvals, todo, sessions, image
  upload, status bar)
- docs/webui.md: engine API v1 - events, requests, security model
- tests/test_webui.c, scripts/e2e_webui.sh: unit tests plus an end-to-end run
  driven by a recorded SSE stream (no API key needed)
- src/main.c: --web --web-port --web-host --web-token --web-dev --web-open
  and CST_WEB_PORT/HOST/TOKEN/ROOT

An empty POST body is now accepted without Content-Length (RFC 9112 6.3) so
curl-driven clients can reach cancel/undo/compact/quit; CMakeList's core tests
learned about sashidown and test_webui, and CST_WEBUI=0 defines CST_NO_WEBUI.
2026-10-09 10:20:09 +08:00
WHC c7b32c3fd6 v0.7.5: arrow keys, image request fix, deepseek-flash vision, musl CI
TUI keys: register both CSI and SS3 forms of the cursor/Home/End keys and
the xterm modifier sequences when terminfo lacks them, so an arrow sent in
the "other" form no longer turns into Esc + a typed letter. Up/Down move by
visual (soft-wrapped) row, and the draft survives a trip through history.
scripts/tui_keys drives the TUI in a pty to check all of this.

Images: text inside a multimodal content array is now a {"type":"text"}
part; DeepSeek rejected the bare string with HTTP 422. deepseek-flash
(and its deepseek-chat / deepseek-reasoner aliases) read images, so the
registry marks them vision; deepseek-v4-pro stays text only.

CI: models.c lost _DEFAULT_SOURCE in 0.7.0 and gmtime_r broke the musl
build; tests sleep with poll() instead of usleep for the same reason.
2026-10-08 22:35:34 +08:00
WHC 32b4afd772 v0.7.1: allow network inside the run_shell sandbox
Landlock no longer handles TCP, the Seatbelt profile drops (deny network*),
and the seccomp filter stops refusing IPv4/IPv6 sockets. Sandboxed commands
run without asking in auto mode on every backend (Low-IL no longer asks for
network commands), and connection errors are not treated as sandbox blocks.
2026-10-08 21:05:14 +08:00
WHC 895b47274c v0.7.0: image input, model registry, prefix-cache savings
Images
- vision gate: text-only models get a note instead of image parts and no
  view_image tool; unknown models retry once without images on HTTP 400
- paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip,
  pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image
  file paths attaches them
- MCP image content is stored in the session and sent with the tool result
- attach: rel path validation, base64 decode, /retry keeps images

Model registry
- built-in src/models_builtin.json embedded at build time, merged with
  <config>/models.json and cached GET /models metadata (context, vision,
  pricing); most specific rule wins per field
- /model labels, /status source, --list-models [provider]

Cache and cost
- stable request prefix (frozen tools, notes as messages), DeepSeek prefix
  completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
2026-10-08 20:34:09 +08:00
WHC 0226f417ff providers: add teamorouter; pick the first /models id when no default model 2026-10-08 14:30:00 +08:00
WHC 1772c26d43 ci: auto-publish Windows zip to Gitea Releases
After a successful windows-cross build on main or v* tags, upload the zip
and .sha256 via scripts/release_windows.sh (creates/updates v$VERSION).
Prefer secrets.RELEASE_TOKEN; fall back to GITHUB_TOKEN. Skip on PRs.
2026-10-07 21:43:41 +08:00
WHC 630e8f4295 ci: Windows zip only builds cst-agent; link util for replay tests
os_win.c needs cst_buf_* / cst_utf8_valid from util.c after os_compat was
added to the small replay CMake targets. Release packaging only needs
cst-agent.exe, so bootstrap_windows.sh builds that target alone.
2026-10-07 21:09:44 +08:00
WHC 94e8746091 ci: multi-mirror GitHub clone for mingw third_party
windows-cross died cloning busybox-w32 through a global gitclone.com
insteadOf (HTTP 502). Try ghfast/ghproxy/gitclone/github per repo instead
of forcing one proxy for every fetch.
2026-10-07 11:43:12 +08:00
WHC 546a4c7eeb ci: link os_compat for replay CMake tests; fix llvm-mingw mirrors
gcc -O0 keeps unused cst_test_util.h helpers that need os_*; clang GC'd
them so only the gcc CMake step failed. Prefer ghfast/ghproxy.net for the
llvm-mingw tarball (gitclone 500 / github unreachable on the runner).
2026-10-07 02:57:38 +08:00
WHC 9b2c7acfae ci: fix test_subagent strcasestr crash; llvm-mingw Windows zip
clang under strict C11 left strcasestr undeclared (returns int → pointer
truncation → SIGSEGV). Parse Content-Length portably; keep -Werror on
make check. Drop Nix for windows-cross (root /nix fails) and use pinned
llvm-mingw UCRT instead; Alpine musl gets linux-headers for audit.h.
2026-10-07 02:39:36 +08:00
WHC 420f9b242c ci: drop sanitizers job; harden test_subagent fake server
test_subagent segfaulted in the Docker runner: the fake SSE handler took
user + strlen(user) - 1 with an empty user string. Guard JSON access and
format the child report with an empty-safe last character. Remove the
sanitizers job entirely (if: false still queued it and blocked the runner).
2026-10-07 02:27:00 +08:00
WHC e5ea869681 ci: install unzip for windows-cross zip listing 2026-10-07 02:21:28 +08:00
WHC d6ae1a67c4 ci: drop macOS, skip ASan, checkout without GitHub, MinGW zip
- Replace actions/checkout (pulls from github.com) with scripts/ci_checkout.sh
  fetched from this Gitea over raw/commit.
- Remove the macOS job (no runner).
- Disable sanitizers until the Docker runner has SYS_PTRACE; it was wedging
  the only slot with AddressSanitizer:DEADLYSIGNAL.
- Add windows-cross: Nix UCRT mingw via TUNA, bootstrap_windows.sh, verify
  dist/cst-agent-*-windows-x86_64.zip. Third-party git clones go through
  gitclone.com.
2026-10-07 02:21:18 +08:00
WHC 41c7e04940 ci: cancel stuck runs; cap ASan wall time; silence path snprintf warning
The sanitizers job wedged the only runner by printing AddressSanitizer
DEADLYSIGNAL forever (Docker without SYS_PTRACE). Concurrency now cancels
in-progress jobs on a new push, and make asan is wrapped in timeout -s KILL
480 with shallow ASAN_OPTIONS.

Also copy the path with memcpy after the length check so -O1 asan builds
do not warn about snprintf truncation into OS_PATH_MAX.
2026-10-07 01:55:51 +08:00
WHC 77de812207 sashidown: keep -Iinclude when parent make overrides CFLAGS
CI passes CFLAGS=-Werror on the command line; that wiped the library's
CFLAGS ?= ... -Iinclude -Isrc, so fsm.h could not find sashidown.h.
Always append the include paths via override CPPFLAGS.
2026-10-07 01:52:42 +08:00
WHC 9ec21b7fc0 ci: use Tsinghua apt/apk mirrors on the runner
Install was pulling bookworm packages from deb.debian.org; the domestic
Gitea runner crawls that for minutes. Rewrite sources to TUNA before
apt-get/apk, with CST_CI_MIRROR to override.
2026-10-07 01:42:56 +08:00
WHC daa92a6cfb seccomp: require child memory peek in the availability probe
With Yama ptrace_scope=2 and no CAP_SYS_PTRACE, process_vm_readv and
/proc/<pid>/mem both fail, so sc_decide cannot read path strings and every
workspace write gets EACCES. The old probe only checked the USER_NOTIF
round trip, so CST_SANDBOX=seccomp and the forced-seccomp test pass looked
available and then failed on the first write.

sc_can_peek() forks a child, places a marker on its stack, and retries the
same sc_peek path. sc_probe() now needs that too, so those hosts fall back
to none (or keep landlock) instead of a silent deny-all sandbox.
2026-10-07 01:39:08 +08:00
WHC 1476f1774a Temp dir falls back when TMPDIR or /tmp is unusable (Termux); v0.6.1
os_temp_dir() used $TMPDIR or "/tmp" without checking either. On Termux
/tmp usually does not exist (or SELinux denies it) unless TMPDIR points at
$PREFIX/tmp, so plugin and MCP temp files failed and shell children that
need a temp file (heredocs) broke.

  * os_temp_dir() picks, once, the first of $TMPDIR, $PREFIX/tmp, /tmp,
    /var/tmp and ~/.cache/cst-agent/tmp where a file can really be
    created; if none works it keeps the old answer
  * os_temp_dir_export() at startup writes the choice back to TMPDIR, so
    shell children and the sandbox's writable temp dir agree
  * tests/test_tmpdir.c covers the fallbacks in fresh forks
2026-10-06 23:32:21 +08:00
WHC 2a43ae6622 Windows: official port -- UCRT static build, UTF-8 throughout, Low IL sandbox, ncurses TUI, installer; v0.6.0
Build
  * cross build switches to pkgsCross.ucrt64 (shell-win.nix); curl (Schannel),
    winpthreads and ncurses 6.6 are linked statically, so the release is
    cst-agent.exe + busybox.exe (busybox-w32 Unicode) with no extra DLLs
  * windres embeds a manifest with activeCodePage=UTF-8 and a version resource
  * ncurses 6.6 Windows console driver patches (mouse button state, A_DIM,
    wide-char declarations): scripts/patches/
  * scripts/bootstrap_msys2.sh builds the same release natively in MSYS2
    UCRT64; PR #1's MINGW64 notes now point there
  * vendor/tre-regex (musl's TRE) provides <regex.h> on MinGW

UTF-8
  * paths cross the system call boundary as UTF-16 (*W APIs); console code
    pages set to 65001 and restored on exit; files are read and written in
    binary mode
  * child output that is not valid UTF-8 is decoded from the console, OEM or
    ANSI system code page
  * read_file hides CR; edit_file, apply_patch and write_file keep CRLF line
    endings and a UTF-8 BOM

OS layer and processes
  * os_dir_* and cst_fnmatch replace the POSIX-only directory walks in
    subagent, plugins, skills, undo and fs_tools; realpath / path_under and
    symlink rejection work on Windows
  * every child goes through CreateProcessW with its own environment block
    inside a Job Object, so timeouts and Esc end the whole process tree

Sandbox (auto mode, "auto · lowil")
  * run_shell runs with a restricted Low integrity token; the workspace gets
    an inheritable Low label once, the sandbox TEMP is labeled Low, and the
    credentials file gets a no-read-up label
  * drive roots, the profile and system folders are never labeled
  * network commands still ask, since Low IL does not block the network;
    Access denied and MSYS2's 0xc0000142 offer a rerun outside the sandbox
  * /sandbox lists labeled folders, /sandbox reset-labels restores them

TUI
  * raster console fonts are switched to Consolas / NSimSun while running
  * the home logo falls back to text when the console font lacks U+2580
  * mouse drag copy goes to the Windows clipboard

Distribution
  * scripts/install_windows.ps1 installs a Gitea release into
    %LOCALAPPDATA%\Programs\cst-agent and adds it to the user PATH;
    cst-agent-update.cmd reruns it; scripts/release_windows.sh uploads the
    zip with the token taken from the environment
  * scripts/run_windows.bat is gone; docs/README-windows.md is rewritten

Tests
  * new test_lowil, test_win_utf8 and test_os_dir; tests made portable
    (cst_test_util.h)
  * scripts/win_test.sh and scripts/winvm/ run the suite on English and
    Chinese Windows Server VMs as Administrator, a standard user and a
    Chinese user name: 29/29 on all six
2026-10-06 23:08:14 +08:00
XYZ 9862c29f58 Windows: point libcurl at a CA bundle
An OpenSSL-based libcurl keeps the CA path it was built with. MSYS2's is
"/mingw64/etc/ssl/certs/ca-bundle.crt", which resolves to nothing once the
binary runs outside an MSYS2 shell, and that build ignores CURL_CA_BUNDLE,
SSL_CERT_FILE and SSL_CERT_DIR. Every HTTPS request then failed with
CURLE_SSL_CACERT_BADFILE (77).

llm_apply_ca_bundle() sets CURLOPT_CAINFO from $CST_CA_BUNDLE, falling back
to ca-bundle.crt beside the executable. If neither exists nothing is set,
so a libcurl linked against Schannel (the Windows certificate store)
behaves exactly as before.
2026-10-06 17:56:56 +08:00
XYZ 929f7125b8 Windows: encode and decode text as UTF-8 instead of trusting the locale
setlocale(LC_ALL, "") maps to the ANSI code page on Windows -- 936 (GBK)
on a Chinese system -- so wcrtomb() produces GBK where the protocol wants
UTF-8, and mbrtowc() reads UTF-8 as GBK. Typed Chinese therefore reached
the model as mojibake, and once the buffer held correct UTF-8 the input
line rendered as mojibake instead. Windows' C runtime has no ".UTF-8"
locale to switch to, so the conversion has to be explicit.

os_compat.[ch]
  * add os_utf8_encode() and os_utf8_decode(), locale-independent, with
    range and overlong-form checks
src/tui.c
  * input_put_wc(), the command-palette filter and the ask_user buffer use
    os_utf8_encode() instead of wcrtomb()
src/textsel.c
  * cst_utf8_decode() uses os_utf8_decode() instead of mbrtowc()
2026-10-06 17:56:55 +08:00
XYZ 3db33eddac Windows: make the CMake build work with MSYS2's native toolchain 2026-10-06 16:13:45 +08:00
487 changed files with 90749 additions and 2800 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
+2
View File
@@ -0,0 +1,2 @@
*.cmd text eol=crlf
*.bat text eol=crlf
+159 -51
View File
@@ -2,74 +2,182 @@ name: ci
on: on:
push: push:
branches: [main]
tags: ['v*']
pull_request: pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
linux: # Verification: TinyCC builds and tests everything in seconds. tcc warns on
# `#pragma GCC`, so no -Werror here; the gcc release builds below carry it.
tcc-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
env:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Install dependencies
run: |
SUDO=$(command -v sudo || true)
$SUDO bash scripts/ci_pkg_mirror.sh
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
build-essential pkg-config python3 curl ca-certificates \
libcurl4-openssl-dev libncurses-dev
- name: Build TinyCC
run: bash scripts/ci_tcc.sh
- name: Build
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" -j"$(nproc)" hermit
- name: Unit tests
run: make CC=tcc AR=ar CFLAGS="-std=c11 -Wall -O2 -g" check
- name: Smoke test
run: |
./hermit --help
./hermit --workspace "$PWD" --list-sessions
./hermit --demo-ctl --workspace .
- name: WebUI end-to-end (recorded stream, no API key)
run: sh scripts/e2e_webui.sh
# Release: static musl binaries, gcc with -Werror.
# dist/cst-agent-VERSION-linux-ARCH.tar.gz
linux:
needs: tcc-check
runs-on: ubuntu-latest
timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
cc: [gcc, clang] include:
- arch: x86_64
check: 1
- arch: x86
check: 1
- arch: aarch64
check: 0
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
- name: Install dependencies env:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Install host tools
run: | run: |
SUDO=$(command -v sudo || true) SUDO=$(command -v sudo || true)
$SUDO bash scripts/ci_pkg_mirror.sh
$SUDO apt-get update $SUDO apt-get update
# ncurses-bin/-term: tic, infocmp and the fallback terminal entries
$SUDO apt-get install -y --no-install-recommends \ $SUDO apt-get install -y --no-install-recommends \
build-essential clang cmake pkg-config python3 \ build-essential cmake ninja-build pkg-config python3 perl \
libcurl4-openssl-dev libncurses-dev curl ca-certificates xz-utils bzip2 file ncurses-bin ncurses-term
- name: Build (warnings are errors) if [ "${{ matrix.arch }}" = aarch64 ]; then
run: make CC=${{ matrix.cc }} CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" -j"$(nproc)" $SUDO apt-get install -y --no-install-recommends qemu-user-static
- name: Unit tests fi
run: make CC=${{ matrix.cc }} check - name: Cache musl toolchain + third_party prefix
- name: CMake build + ctest uses: actions/cache@v4
run: | with:
cmake -B build -DCMAKE_C_COMPILER=${{ matrix.cc }} -DCST_BUILD_TUI=ON path: |
cmake --build build -j"$(nproc)" ${{ env.HOME }}/.local/toolchains
ctest --test-dir build --output-on-failure /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)
env:
CST_LINUX_CHECK: ${{ matrix.check }}
run: bash scripts/package_linux.sh ${{ matrix.arch }}
- name: Smoke test - name: Smoke test
run: | run: |
./cst-agent --help d=$(ls -d dist/hermit-*-linux-${{ matrix.arch }})
./cst-agent --workspace "$PWD" --list-sessions file "$d/hermit"
./cst-agent --demo-ctl --workspace . run=""
[ "${{ matrix.arch }}" = aarch64 ] && run=qemu-aarch64-static
$run "$d/hermit" --version
$run "$d/hermit" --help >/dev/null
$run "$d/hermit" --workspace "$PWD" --list-sessions
cat dist/hermit-*-linux-${{ matrix.arch }}.tar.gz.sha256
# Release vVERSION (from include/cst_version.h) on Gitea. Prefer repo
# secret RELEASE_TOKEN (write:repository); fall back to the job token.
- name: Publish Gitea Release
if: github.event_name == 'push'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CST_GITEA: ${{ github.server_url }}
CST_REPO: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }}
run: bash scripts/ci_publish.sh dist/hermit-*-linux-${{ matrix.arch }}.tar.gz dist/cst-agent-*-linux-${{ matrix.arch }}.tar.gz
sanitizers: # Release: mingw-w64 UCRT gcc (Debian trixie) with -Werror.
# dist/cst-agent-VERSION-windows-x86_64.zip and -setup.exe (NSIS)
windows:
needs: tcc-check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 120
container: debian:trixie
steps: steps:
- uses: actions/checkout@v4 - name: Install tools
- name: Install dependencies
run: | run: |
SUDO=$(command -v sudo || true) # No CA certificates in the image yet, so the mirror is plain http.
$SUDO apt-get update sed -i 's#https\?://deb.debian.org#http://mirrors.tuna.tsinghua.edu.cn#g' \
$SUDO apt-get install -y --no-install-recommends \ /etc/apt/sources.list.d/debian.sources
build-essential pkg-config libcurl4-openssl-dev libncurses-dev apt-get update
- name: ASan + UBSan apt-get install -y --no-install-recommends \
run: make asan ca-certificates curl git build-essential cmake ninja-build make \
pkg-config python3 perl zip unzip xz-utils patch file \
musl: gcc-mingw-w64-ucrt64 binutils-mingw-w64-ucrt64 nsis
runs-on: ubuntu-latest - name: Checkout
container: alpine:3.20 env:
steps: GITHUB_SERVER_URL: ${{ github.server_url }}
- name: Install dependencies GITHUB_REPOSITORY: ${{ github.repository }}
run: apk add --no-cache build-base pkgconf curl-dev ncurses-dev git nodejs bash python3 GITHUB_SHA: ${{ github.sha }}
- uses: actions/checkout@v4
- name: Build + unit tests
run: | run: |
make CFLAGS="-std=c11 -Wall -Wextra -O2 -Werror" -j"$(nproc)" curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
make check -o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
# Gitea instances rarely have macOS runners; set the repository variable - name: Cache mingw third_party
# HAVE_MACOS_RUNNER=true once one is registered. uses: actions/cache@v4
macos: with:
if: ${{ vars.HAVE_MACOS_RUNNER == 'true' }} path: |
runs-on: macos-latest third_party/prefix-mingw-x86_64
steps: third_party/src
- uses: actions/checkout@v4 third_party/busybox
- name: Install dependencies key: win-tp-v1-${{ hashFiles('scripts/build_third_party_mingw.sh', 'scripts/bootstrap_windows.sh') }}
run: brew install ncurses curl pkg-config restore-keys: |
- name: Build + unit tests win-tp-v1-
- name: Cross-build zip and installer
env:
CST_WIN_CFLAGS: -Wall -Wextra -Werror
run: bash scripts/ci_windows_cross.sh
- name: Verify
run: | run: |
make -j"$(sysctl -n hw.ncpu)" ls -lah dist/windows-x86_64/
make check file dist/windows-x86_64/cst-agent.exe dist/cst-agent-*-windows-x86_64-setup.exe
cat dist/cst-agent-*-windows-x86_64.zip.sha256 dist/cst-agent-*-windows-x86_64-setup.exe.sha256
unzip -l dist/cst-agent-*-windows-x86_64.zip
- name: Publish Gitea Release
if: github.event_name == 'push'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CST_GITEA: ${{ github.server_url }}
CST_REPO: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }}
run: |
bash scripts/ci_publish.sh dist/cst-agent-*-windows-x86_64.zip \
dist/cst-agent-*-windows-x86_64-setup.exe
+38
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
@@ -21,10 +23,46 @@ third_party/busybox/busybox
third_party/busybox/busybox.exe third_party/busybox/busybox.exe
third_party/curl/ third_party/curl/
third_party/PDCurses/ third_party/PDCurses/
third_party/ncurses-*/
third_party/ncurses-*.tar.gz
third_party/busybox-w32/ third_party/busybox-w32/
third_party/busybox-src/ third_party/busybox-src/
third_party/prefix-mingw/ third_party/prefix-mingw/
third_party/prefix-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/build-android-*/
third_party/src/
vendor/sashidown/*.o vendor/sashidown/*.o
vendor/sashidown/src/*.o vendor/sashidown/src/*.o
vendor/sashidown/libsashidown.a vendor/sashidown/libsashidown.a
vendor/sashidown/libsashidown-*.a
vendor/sashidown/libsashidown-mingw.a vendor/sashidown/libsashidown-mingw.a
.gen/
.build-tmp/
# Android APK builds (scripts/build_apk_android.sh) and what gradle leaves
build-apk-android/
*.apk
*.aab
app/android/.gradle/
app/android/build/
app/android/local.properties
# the engine as a static library (app/ links it; built by `make`)
libcstcore.a
# nix-build
result
hermit
.peer_probe.out
reports/
# Tauri desktop
apps/desktop/node_modules/
apps/desktop/dist/
apps/desktop/src-tauri/target/
+268 -36
View File
@@ -5,9 +5,37 @@ set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD_REQUIRED ON)
option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON) option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
option(CST_BUILD_WEBUI "Build the HTTP/SSE engine API (--web, cst-webui/1)" ON)
option(CST_BUILD_APP "Build archived Qt front end in archive/qt-app (experimental)" OFF)
option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF) option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON) option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
if(WIN32)
enable_language(RC)
# Cross builds link libgcc/winpthreads/curl statically; MSYS2's curl is a DLL.
if(CMAKE_CROSSCOMPILING)
set(_CST_STATIC_DEFAULT ON)
else()
set(_CST_STATIC_DEFAULT OFF)
endif()
option(CST_WIN_STATIC "Link Windows executables with -static" ${_CST_STATIC_DEFAULT})
endif()
# Windows: UTF-8 manifest (activeCodePage, longPathAware) and version resource
# on every executable, so tests run with the same process code page as the app.
function(cst_win_exe t)
if(NOT WIN32)
return()
endif()
target_sources(${t} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/res/windows/cst-agent.rc")
target_include_directories(${t} PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/res/windows")
target_compile_definitions(${t} PRIVATE _WIN32_WINNT=0x0A00)
if(CST_WIN_STATIC)
target_link_options(${t} PRIVATE -static)
endif()
endfunction()
set(SD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sashidown") set(SD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sashidown")
if(CST_NATIVE_ARCH) if(CST_NATIVE_ARCH)
set(_SD_MAKE_EXTRA CST_NATIVE_ARCH=1) set(_SD_MAKE_EXTRA CST_NATIVE_ARCH=1)
@@ -15,25 +43,56 @@ else()
set(_SD_MAKE_EXTRA "") set(_SD_MAKE_EXTRA "")
endif() endif()
# sashidown has a plain Makefile; CMAKE_MAKE_PROGRAM may be ninja.
find_program(CST_MAKE NAMES make gmake mingw32-make REQUIRED)
# Separate archive for MinGW so Linux host builds keep libsashidown.a # Separate archive for MinGW so Linux host builds keep libsashidown.a
if(CMAKE_SYSTEM_NAME STREQUAL "Windows") if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a") set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a")
set(_MINGW_AR x86_64-w64-mingw32-ar) # Use whatever archiver CMake found for the active toolchain. Hardcoding
# "x86_64-w64-mingw32-ar" only works on cross-build hosts, where binutils
# installs prefixed names; MSYS2's mingw-w64 toolchain ships plain ar.exe
# and ranlib.exe, so the hardcoded names are not found.
set(_MINGW_AR "${CMAKE_AR}")
set(_MINGW_RANLIB "${CMAKE_RANLIB}")
add_custom_command( add_custom_command(
OUTPUT "${SASHIDOWN_A}" OUTPUT "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a
CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=x86_64-w64-mingw32-ranlib CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=${_MINGW_RANLIB}
COMMAND ${CMAKE_COMMAND} -E copy_if_different COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}" "${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMENT "Building libsashidown for MinGW" COMMENT "Building libsashidown for MinGW"
VERBATIM) VERBATIM)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Android")
# The host archive has to stay host-built (the CLI, the tests and the
# Makefile link it), so the NDK build keeps its own copy, one per ABI.
# CMake's compiler is plain clang (the --target/--sysroot flags live in its
# own variables), so the vendor make gets the per-target wrapper instead:
# aarch64-linux-android24-clang carries both.
get_filename_component(_SD_CC_BIN "${CMAKE_C_COMPILER}" DIRECTORY)
string(REPLACE "-none" "" _SD_TRIPLE "${CMAKE_C_COMPILER_TARGET}")
set(_SD_CC "${_SD_CC_BIN}/${_SD_TRIPLE}-clang")
if(NOT EXISTS "${_SD_CC}")
set(_SD_CC "${CMAKE_C_COMPILER}")
endif()
set(SASHIDOWN_A "${SD_DIR}/libsashidown-${ANDROID_ABI}.a")
add_custom_command(
OUTPUT "${SASHIDOWN_A}"
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a
CC=${_SD_CC} AR=${CMAKE_AR} RANLIB=${CMAKE_RANLIB}
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMENT "Building libsashidown for Android (${ANDROID_ABI})"
VERBATIM)
else() else()
set(SASHIDOWN_A "${SD_DIR}/libsashidown.a") set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
add_custom_command( add_custom_command(
OUTPUT "${SASHIDOWN_A}" OUTPUT "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a CC=${CMAKE_C_COMPILER} ${_SD_MAKE_EXTRA} COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a CC=${CMAKE_C_COMPILER} ${_SD_MAKE_EXTRA}
COMMENT "Building libsashidown.a" COMMENT "Building libsashidown.a"
VERBATIM) VERBATIM)
endif() endif()
@@ -46,9 +105,12 @@ 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
src/os_win.c
src/sandbox.c src/sandbox.c
src/sandbox_exec.c src/sandbox_exec.c
src/keymap.c src/keymap.c
@@ -57,6 +119,17 @@ 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/clipboard.c
src/pdf_text.c
src/session.c src/session.c
src/tools/tools.c src/tools/tools.c
src/tools/patch.c src/tools/patch.c
@@ -64,6 +137,7 @@ set(CST_CORE_SOURCES
src/tools/fs_tools.c src/tools/fs_tools.c
src/tools/agent_tools.c src/tools/agent_tools.c
src/tools/web.c src/tools/web.c
src/tools/session_tools.c
src/tools/diff.c src/tools/diff.c
src/undo.c src/undo.c
src/permissions.c src/permissions.c
@@ -73,11 +147,109 @@ set(CST_CORE_SOURCES
src/agent_store.c src/agent_store.c
src/json_baseline.c src/json_baseline.c
src/creds.c src/creds.c
src/provider_switch.c
src/mm_tool.c src/mm_tool.c
src/models.c src/models.c
src/model_registry.c
src/metrics.c src/metrics.c
src/hermit_glyph.c
src/net.c
src/http_srv.c
src/web_md.c
) )
set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c)
# Built-in model data as a C byte list (included by model_registry.c).
set(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json")
# The WebUI shell (web/*) as C byte arrays, the same trick as the model data.
file(GLOB CST_WEB_ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/web/*")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CST_WEB_ASSETS})
set(_cst_web_inc "/* Generated by CMake from web/ - do not edit. */\n")
set(_cst_web_table "")
foreach(_f ${CST_WEB_ASSETS})
if(IS_DIRECTORY "${_f}")
continue()
endif()
get_filename_component(_name "${_f}" NAME)
string(REGEX REPLACE "[^A-Za-z0-9]" "_" _var "wui_asset_${_name}")
file(READ "${_f}" _hex HEX)
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _hex "${_hex}")
set(_ctype "application/octet-stream")
if(_name MATCHES "\\.html$")
set(_ctype "text/html; charset=utf-8")
elseif(_name MATCHES "\\.js$")
set(_ctype "text/javascript; charset=utf-8")
elseif(_name MATCHES "\\.css$")
set(_ctype "text/css; charset=utf-8")
elseif(_name MATCHES "\\.svg$")
set(_ctype "image/svg+xml")
endif()
string(APPEND _cst_web_inc "static const unsigned char ${_var}[] = {${_hex}0x00};\n")
string(APPEND _cst_web_table " {\"${_name}\", \"${_ctype}\", ${_var}, sizeof(${_var}) - 1},\n")
endforeach()
string(APPEND _cst_web_inc "static const wui_asset_t wui_assets[] = {\n${_cst_web_table}};\n")
string(APPEND _cst_web_inc "static const int wui_asset_count = (int)(sizeof(wui_assets) / sizeof(wui_assets[0]));\n")
file(WRITE "${CST_GEN_DIR}/web_assets.inc" "${_cst_web_inc}")
if(NOT CST_BUILD_WEBUI)
list(APPEND CST_CORE_SOURCES src/webui_stub.c)
else()
list(APPEND CST_CORE_SOURCES src/webui.c)
endif()
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json" _cst_models_hex HEX)
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _cst_models_hex "${_cst_models_hex}")
file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n")
include_directories("${CST_GEN_DIR}")
if(NOT CST_BUILD_WEBUI)
add_compile_definitions(CST_NO_WEBUI=1)
endif()
if(WIN32)
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
include_directories(vendor/tre-regex)
set(_CST_TRE_SOURCES
vendor/tre-regex/regcomp.c vendor/tre-regex/regexec.c
vendor/tre-regex/regerror.c vendor/tre-regex/tre-mem.c)
list(APPEND CST_CORE_SOURCES ${_CST_TRE_SOURCES})
# Upstream code, kept as is: CST_WIN_CFLAGS=-Werror must not apply to it.
set_source_files_properties(${_CST_TRE_SOURCES} PROPERTIES COMPILE_OPTIONS "-w")
endif()
# --- the engine as a library ---------------------------------------------
# cstcore is the core without main()/TUI: the CLI links it; archived clients
# (archive/qt-app, archive/desktop-tauri) can embed the same objects in-process.
add_library(cstcore STATIC ${CST_CORE_SOURCES})
add_dependencies(cstcore sashidown)
target_include_directories(cstcore PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include"
"${SD_DIR}/include"
"${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)
if(WIN32)
target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00)
target_link_libraries(cstcore PUBLIC
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads
ws2_32 crypt32 secur32 bcrypt advapi32 user32)
else()
target_compile_definitions(cstcore PRIVATE _XOPEN_SOURCE_EXTENDED)
target_link_libraries(cstcore PUBLIC
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads m)
endif()
if(CST_BUILD_APP)
# Frozen reference GUI; not part of the default Hermit delivery.
add_subdirectory(archive/qt-app)
endif()
set(CST_SOURCES src/main.c)
if(CST_BUILD_TUI) if(CST_BUILD_TUI)
list(APPEND CST_SOURCES src/textsel.c src/tui.c) list(APPEND CST_SOURCES src/textsel.c src/tui.c)
@@ -86,7 +258,9 @@ 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)
target_include_directories(cst-agent PRIVATE target_include_directories(cst-agent PRIVATE
include include
@@ -99,21 +273,34 @@ if(NOT WIN32)
target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED) target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED)
endif() endif()
target_link_libraries(cst-agent PRIVATE # cstcore carries sashidown, curl, threads and libm as usage requirements.
"${SASHIDOWN_A}" target_link_libraries(cst-agent PRIVATE cstcore)
CURL::libcurl
Threads::Threads
)
if(NOT WIN32)
target_link_libraries(cst-agent PRIVATE m)
endif()
if(CST_BUILD_TUI) set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses")
set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses)
if(CST_BUILD_TUI AND WIN32 AND CST_WIN_CURSES STREQUAL "ncurses")
# ncursesw built by scripts/build_third_party_mingw.sh ncurses (or MSYS2's
# mingw-w64-ucrt-x86_64-ncurses); headers live in include/ncursesw.
find_path(NCURSESW_INCLUDE_DIR ncurses.h PATH_SUFFIXES ncursesw include/ncursesw)
find_library(NCURSESW_LIBRARY NAMES ncursesw)
if(NOT NCURSESW_INCLUDE_DIR OR NOT NCURSESW_LIBRARY)
message(FATAL_ERROR "CST_WIN_CURSES=ncurses needs ncursesw "
"(scripts/build_third_party_mingw.sh ncurses), or -DCST_WIN_CURSES=pdcurses")
endif()
# ncurses.h includes <ncursesw/ncurses_dll.h>, so the parent is needed too
get_filename_component(NCURSESW_PARENT_DIR ${NCURSESW_INCLUDE_DIR} DIRECTORY)
target_include_directories(cst-agent PRIVATE ${NCURSESW_INCLUDE_DIR} ${NCURSESW_PARENT_DIR})
target_link_libraries(cst-agent PRIVATE ${NCURSESW_LIBRARY})
target_compile_definitions(cst-agent PRIVATE CST_USE_NCURSES=1 NCURSES_STATIC=1 NCURSES_WIDECHAR=1)
message(STATUS "TUI: ncursesw (${NCURSESW_LIBRARY})")
elseif(CST_BUILD_TUI)
if(WIN32) if(WIN32)
# Prefer PDCurses (pdcurses / curses). Users may set PDCurses_ROOT. # PDCurses (pdcurses / curses). Users may set PDCurses_ROOT.
# PATH_SUFFIXES also covers the layout MSYS2's mingw-w64-pdcurses uses,
# which puts the headers under include/pdcurses/ rather than include/.
find_path(PDCURSES_INCLUDE_DIR curses.h find_path(PDCURSES_INCLUDE_DIR curses.h
HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}" HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
PATH_SUFFIXES include) PATH_SUFFIXES include pdcurses include/pdcurses)
find_library(PDCURSES_LIBRARY find_library(PDCURSES_LIBRARY
NAMES pdcurses pdcurses.dll curses NAMES pdcurses pdcurses.dll curses
HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}" HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
@@ -121,7 +308,11 @@ if(CST_BUILD_TUI)
if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY) if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY)
target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR}) target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR})
target_link_libraries(cst-agent PRIVATE ${PDCURSES_LIBRARY}) target_link_libraries(cst-agent PRIVATE ${PDCURSES_LIBRARY})
target_compile_definitions(cst-agent PRIVATE CST_USE_PDCURSES=1) # PDCurses suffixes its exported symbols with an ABI tag (endwin_u64_4400
# etc.). MSYS2 builds the UTF-8 wide variant, so PDC_FORCE_UTF8 has to be
# defined or curses.h maps to the w64 names and the link fails.
target_compile_definitions(cst-agent PRIVATE
CST_USE_PDCURSES=1 PDC_FORCE_UTF8=1)
message(STATUS "TUI: PDCurses (${PDCURSES_LIBRARY})") message(STATUS "TUI: PDCurses (${PDCURSES_LIBRARY})")
else() else()
message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)") message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)")
@@ -143,19 +334,29 @@ else()
endif() endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32) if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32)
# curl Schannel + networking # curl Schannel + networking; winmm is needed by PDCurses (PlaySoundW)
target_link_libraries(cst-agent PRIVATE target_link_libraries(cst-agent PRIVATE
ws2_32 crypt32 secur32 bcrypt advapi32) ws2_32 crypt32 secur32 bcrypt advapi32 user32 gdi32 winmm)
endif() endif()
# --- tests --- # --- tests ---
enable_testing() enable_testing()
set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline
test_replay_starvation test_replay_real test_mm_tool test_textsel test_replay_starvation test_replay_real test_mm_tool test_textsel
test_markdown) test_pdf_text test_markdown)
foreach(t ${_CST_SMALL_TESTS}) foreach(t ${_CST_SMALL_TESTS})
if(t STREQUAL test_textsel) if(t STREQUAL test_textsel)
add_executable(${t} tests/${t}.c src/textsel.c) add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c
src/util.c)
if(WIN32)
target_link_libraries(${t} PRIVATE advapi32 user32)
endif()
elseif(t STREQUAL test_pdf_text)
add_executable(${t} tests/${t}.c src/pdf_text.c src/os_compat.c src/os_win.c
src/util.c)
if(WIN32)
target_link_libraries(${t} PRIVATE advapi32 user32)
endif()
elseif(t STREQUAL test_markdown) elseif(t STREQUAL test_markdown)
add_executable(${t} tests/${t}.c) add_executable(${t} tests/${t}.c)
target_include_directories(${t} PRIVATE "${SD_DIR}/include") target_include_directories(${t} PRIVATE "${SD_DIR}/include")
@@ -166,33 +367,51 @@ foreach(t ${_CST_SMALL_TESTS})
elseif(t STREQUAL test_json_baseline) elseif(t STREQUAL test_json_baseline)
add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c) add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c)
elseif(t STREQUAL test_replay_starvation OR t STREQUAL test_replay_real) elseif(t STREQUAL test_replay_starvation OR t STREQUAL test_replay_real)
add_executable(${t} tests/${t}.c src/ctl_parse.c src/json_baseline.c vendor/cjson/cJSON.c) # cst_test_util.h static helpers reference os_*; gcc -O0 keeps unused ones.
# On Windows os_win.c also needs util.c (cst_buf_*, cst_utf8_valid).
add_executable(${t} tests/${t}.c src/ctl_parse.c src/json_baseline.c
vendor/cjson/cJSON.c src/os_compat.c src/os_win.c src/util.c)
if(WIN32)
target_link_libraries(${t} PRIVATE advapi32 user32)
endif()
else() else()
add_executable(${t} tests/test_mm_tool.c src/mm_tool.c) add_executable(${t} tests/test_mm_tool.c src/mm_tool.c)
endif() endif()
target_include_directories(${t} PRIVATE include "${CJSON_DIR}") target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
target_compile_definitions(${t} PRIVATE _GNU_SOURCE) target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
if(NOT WIN32) if(NOT WIN32)
target_link_libraries(${t} PRIVATE m) target_link_libraries(${t} PRIVATE m Threads::Threads)
endif() endif()
cst_win_exe(${t})
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
endforeach() endforeach()
# Tests that link the whole core (no TUI). # Tests that link the whole core (no TUI).
foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
test_shell test_diff test_tools_fs test_registry test_apply_patch test_session_roundtrip test_store test_attach
test_undo test_config test_models test_sandbox test_artifacts
test_shell test_diff test_tools_fs test_tools_ext test_registry
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_plugins test_mcp test_subagent test_runtime test_realm test_discover
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES}) test_peer_auth test_parliament test_lowil
target_include_directories(${t} PRIVATE include "${CJSON_DIR}") test_win_utf8 test_os_dir test_tmpdir
test_tool_meta test_embed)
if(CST_BUILD_WEBUI)
# test_webui drives the real server; the stub has nothing to test.
list(APPEND _CST_CORE_TESTS test_webui)
endif()
foreach(t ${_CST_CORE_TESTS})
# Link the shared cstcore archive instead of recompiling every core .c.
add_executable(${t} tests/${t}.c)
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 CURL::libcurl Threads::Threads) target_link_libraries(${t} PRIVATE cstcore)
if(NOT WIN32) add_dependencies(${t} sashidown)
target_link_libraries(${t} PRIVATE m) if(WIN32)
else() target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32 user32)
target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32)
endif() endif()
cst_win_exe(${t})
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
endforeach() endforeach()
@@ -209,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` 仓库说明,避免双主开发
+267 -53
View File
@@ -2,11 +2,23 @@ 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 ?=
UNAME_S := $(shell uname -s 2>/dev/null) 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 := ,
# 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
@@ -26,6 +38,15 @@ CPPFLAGS += -D_DARWIN_C_SOURCE
endif endif
PKGC = $(PKG_ENV) $(PKG_CONFIG) PKGC = $(PKG_ENV) $(PKG_CONFIG)
# STATIC=1: fully static link (the musl release builds).
STATIC ?= 0
LINK_STATIC :=
ifeq ($(STATIC),1)
PKGC += --static
LINK_STATIC := -static
endif
LDFLAGS += $(LINK_STATIC)
# Wide-character curses: ncursesw, else ncurses (macOS/BSD build it wide). # Wide-character curses: ncursesw, else ncurses (macOS/BSD build it wide).
CURSES_PC := $(shell for p in ncursesw ncurses; do $(PKGC) --exists $$p 2>/dev/null && { echo $$p; exit 0; }; done) CURSES_PC := $(shell for p in ncursesw ncurses; do $(PKGC) --exists $$p 2>/dev/null && { echo $$p; exit 0; }; done)
ifneq ($(CURSES_PC),) ifneq ($(CURSES_PC),)
@@ -43,30 +64,66 @@ ifeq ($(strip $(CURL_LIBS)),)
CURL_LIBS := -lcurl CURL_LIBS := -lcurl
endif endif
# WebUI (HTTP + SSE front end): src/net.c src/http_srv.c src/web_md.c
# src/webui.c. CST_WEBUI=0 swaps in the stub (--web then prints a hint).
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)
CPPFLAGS += -Iinclude -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS) # 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
# (Nix store, /opt prefixes) would not load at run time.
RPATH_LIBS :=
ifneq ($(findstring tcc,$(notdir $(CC))),)
ifneq ($(STATIC),1)
RPATH_LIBS := $(patsubst -L%,-Wl$(comma)-rpath$(comma)%,$(filter -L%,$(PKG_LIBS)))
endif
endif
# Keep POSIX declarations visible for shared test helpers (notably symlink()).
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) LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) $(RPATH_LIBS)
WEB_DIR := web
WEB_ASSETS := $(wildcard $(WEB_DIR)/*.html) $(wildcard $(WEB_DIR)/*.js) \
$(wildcard $(WEB_DIR)/*.css) $(wildcard $(WEB_DIR)/*.svg)
ifeq ($(CST_WEBUI),0)
WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui_stub.c
# The stub body is guarded by this, so the switch has to define it too.
CPPFLAGS += -DCST_NO_WEBUI=1
else
WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui.c
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 \
src/os_win.c \
src/sandbox.c \ src/sandbox.c \
src/sandbox_exec.c \ src/sandbox_exec.c \
src/keymap.c \ src/keymap.c \
src/skills.c \ src/skills.c \
src/extensions.c src/plugins.c src/mcp.c src/subagent.c \ src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
src/session.c \ src/pdf_text.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 \
src/tools/fs_tools.c \ src/tools/fs_tools.c \
src/tools/agent_tools.c \ src/tools/agent_tools.c \
src/tools/web.c \ src/tools/web.c \
src/tools/session_tools.c \
src/tools/diff.c \ src/tools/diff.c \
src/undo.c \ src/undo.c \
src/permissions.c \ src/permissions.c \
@@ -75,14 +132,22 @@ CORE_SRC = \
src/agent_loop.c \ src/agent_loop.c \
src/agent_store.c \ src/agent_store.c \
src/json_baseline.c \ src/json_baseline.c \
src/creds.c \ src/creds.c src/provider_switch.c \
src/mm_tool.c \ src/mm_tool.c \
src/models.c \ src/models.c src/model_registry.c \
src/metrics.c src/metrics.c \
src/hermit_glyph.c \
$(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
OBJ = $(SRC:.c=.o) OBJ = $(SRC:.c=.o)
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h # The core as a static library: what the Qt app embeds in-process (app/), so
# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main.
CORE_OBJ = $(CORE_SRC:.c=.o)
CST_CORE_LIB = libcstcore.a
# Generated embeds; per-file .d files (from -MMD) track the rest.
GEN_INCS = .gen/models_builtin.inc .gen/web_assets.inc
DEPFILES = $(OBJ:.o=.d)
TESTS = \ TESTS = \
tests/test_ctl_frontier \ tests/test_ctl_frontier \
@@ -92,16 +157,25 @@ TESTS = \
tests/test_replay_real \ tests/test_replay_real \
tests/test_mm_tool \ tests/test_mm_tool \
tests/test_llm_body \ tests/test_llm_body \
tests/test_prefix_stable \
tests/test_sse_decode \ tests/test_sse_decode \
tests/test_apply_patch \ tests/test_apply_patch \
tests/test_session_roundtrip \ tests/test_session_roundtrip \
tests/test_store \
tests/test_attach \
tests/test_artifacts \
tests/test_pdf_text \
tests/test_shell \ tests/test_shell \
tests/test_diff \ tests/test_diff \
tests/test_tools_fs \ tests/test_tools_fs \
tests/test_tools_ext \
tests/test_registry \ tests/test_registry \
tests/test_tool_meta \
tests/test_embed \
tests/test_undo \ tests/test_undo \
tests/test_config \ tests/test_config \
tests/test_models \ tests/test_models \
tests/test_model_registry \
tests/test_sandbox \ tests/test_sandbox \
tests/test_keymap \ tests/test_keymap \
tests/test_textsel \ tests/test_textsel \
@@ -110,86 +184,226 @@ TESTS = \
tests/test_skills \ tests/test_skills \
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_win_utf8 \
tests/test_os_dir \
tests/test_tmpdir
# test_webui drives the real server; there is nothing to test with the stub.
ifneq ($(CST_WEBUI),0)
TESTS += tests/test_webui
endif
TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS) TEST_SYS_LIBS = -lm $(PTHREAD_LIBS) $(LINK_STATIC)
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
# 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
# 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 all: sashidown-lib hermit $(CST_CORE_LIB)
# 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 $@
$(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
%.o: %.c $(HDRS) hermit-font:
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< @python3 scripts/build_hermit_font.py || \
echo "hermit-font: skipped (need Pillow+fonttools; try: nix-shell -p python3Packages.pillow python3Packages.fonttools)"
src/tui.o: src/tui.c $(HDRS) $(SD_DIR)/include/sashidown.h # Built-in model data as a C byte list (included by model_registry.c).
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< .gen/models_builtin.inc: src/models_builtin.json
@mkdir -p .gen
od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h # The WebUI shell is embedded the same way, one array per file.
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c .gen/web_assets.inc: $(WEB_ASSETS) scripts/embed_web.sh
@mkdir -p .gen
@sh scripts/embed_web.sh $@ $(WEB_DIR)
tests/test_ctl_heredoc: tests/test_ctl_heredoc.c src/ctl_parse.c include/ctl.h # Incremental rebuilds: -MMD tracks real includes instead of every header.
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c COMPILE_C = $(CC) $(CFLAGS) $(CPPFLAGS) -MMD -MP -c -o $@ $<
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(HDRS) %.o: %.c
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm $(COMPILE_C)
tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c src/model_registry.o: .gen/models_builtin.inc
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm src/webui.o: .gen/web_assets.inc
src/tui.o: $(SD_DIR)/include/sashidown.h
tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c 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_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c -lm $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h 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_mm_tool.c src/mm_tool.c $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_heredoc.c src/ctl_parse.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_textsel: tests/test_textsel.c src/textsel.c include/cst_textsel.h 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_textsel.c src/textsel.c $(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)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_real.c src/ctl_parse.c src/json_baseline.c $(CJSON_DIR)/cJSON.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_textsel: tests/test_textsel.c src/textsel.c $(TEST_OS_SRC) include/cst_textsel.h
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
tests/test_pdf_text: tests/test_pdf_text.c src/pdf_text.c include/cst_pdf.h $(TEST_OS_SRC)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_pdf_text.c src/pdf_text.c $(TEST_OS_SRC) $(TEST_SYS_LIBS)
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)
check: $(TESTS) $(SD_DIR)/libsashidown.a: sashidown-lib
# 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).
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 $(TESTS) rm -f $(OBJ) $(DEPFILES) hermit cst-agent $(CST_CORE_LIB) $(TESTS) \
.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)
+59 -390
View File
@@ -1,428 +1,97 @@
# 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). 本地 **极简 TUI** coding agent(原 CST-Agent):在工作区里读改文件、跑命令、管会话。
评测/反哺论文素材:[docs/eval_tool_cst.md](docs/eval_tool_cst.md)。 C11 实现,OpenAI 兼容网关(DeepSeek / Kimi / MiniMax / OpenAI 等)。
## 能不能用来工作? 本仓交付:`hermit` CLI/TUI、`libcstcore`、可替换 plugins/MCP/skills,以及 `hermit --web` 提供的 HTTP/SSE 引擎 API(`cst-webui/1`)。完整浏览器 SPA / 桌面 GUI 参考实现在 [`archive/`](archive/)(不编入默认产物)。
**可以**:有监督的本地改动 / 探索仓库(TUI + 13 个内置工具 + 带 diff 的权限确认 + `/undo`)。 **适合**:有监督的本地改动与仓库探索(权限确认、diff、`/undo`、沙箱)。
**不宜**:无人值守长任务或当 CI 主力(网关偶发空答;headless 模式默认自动批准)。 **慎用**:完全无人值守的长任务;headless 默认自动批准。
### 5 分钟试用 文档索引:[docs/README.md](docs/README.md)。
兼容:安装后提供 `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
``` ```
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装; [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-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
之后再启动会沿用上次的 provider、模型和推理强度(low / medium / high / max),不需要任何参数或环境变量。 ### Windows
界面里用 `/provider` 切换(没保存过 key 的会当场询问),`/model`、`/effort` 切换模型和推理强度,`/key` 更换 key。
完整环境变量说明:[docs/env_openai.md](docs/env_openai.md)。 见 [docs/README-windows.md](docs/README-windows.md)(产物名逐步改为 `hermit-…`;旧 `cst-agent-…` 包过渡期可能仍有)。
| 平台 | 一键脚本 | ### 第一次用
|---|---|
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/` |
| Windows 运行 | 拷贝 dist 后 [`scripts/run_windows.bat`](scripts/run_windows.bat) |
### 迷你验收工程(论文案例)
故意带 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)。 外置客户端契约:hooks(`cst_agent_hooks_t`)与 [`docs/webui.md`](docs/webui.md) / [`docs/webui-schema.md`](docs/webui-schema.md)。归档 GUI:[`archive/desktop-tauri`](archive/desktop-tauri/)、[`archive/qt-app`](archive/qt-app/)、[`archive/web-spa`](archive/web-spa/)。
## 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/SSE API + health page
make check # 单元测试 + 截断实验(含真实录制流) hermit --web --web-dev archive/web-spa # archived full SPA
make asan # AddressSanitizer + UBSan 构建并跑全部测试 hermit --headless --prompt '…'
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 / 工具 / 议会 / 引擎 API 见 [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 MinGW 交叉(需安装 mingw-w64) ## 开发者
cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=OFF
cmake --build build-win
# TUI:安装 PDCurses 后 -DCST_BUILD_TUI=ON -DPDCurses_ROOT=...
```
依赖:`gcc` 或 `clang`、`libcurl`、Linux TUI 需 `ncursesw`;Windows TUI 需 **PDCurses**。
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 / aarch64 | GCC, Clang | 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 | x86_64 / aarch64 | MinGW GCC/Clang | yes | PDCurses | **捆绑 busybox-w32** |
### 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` 受限时)。
### Windows 发行包
见 [docs/README-windows.md](docs/README-windows.md)。交叉构建:
```bash
./scripts/build_third_party_mingw.sh all # curl+PDCurses+busybox-w32
# 源码:https://github.com/rmyorston/busybox-w32.git
# https://github.com/mirror/busybox.git (Unix)
cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
```
产物示例:`dist/windows-x86_64/`(含 `cst-agent.exe` + `busybox.exe` + `libwinpthread-1.dll`)。
环境变量:`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` |
| 用途 | 来源(优先级从高到低) |
|---|---|
| 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_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` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 80% 时自动 `/compact`) |
| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt |
| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) |
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,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_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` | 列出会话(时间、条数、标题)后退出 |
| `-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` |
| `-P NAME` / `-m NAME` / `-e LEVEL` | 本次使用的 provider / 模型 / 推理强度(不传则用上次的) |
| `-a` / `--ask-key` | 在命令行输入并保存当前 provider 的 key |
工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。
会话落盘:`<workspace>/.agent/sessions/<id>.jsonl` + `CURRENT`;`.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 转纯文本 |
| `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) |
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
`--tools minimal` 只保留 `read_file` / `run_shell` / `write_file` / `apply_patch`(论文实验口径)。
TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
### 权限、撤销与项目说明
- **审批模式**(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`;
读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`;IPv4/IPv6 socket 一律拒绝。
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。
- Windows、更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` 只用指定的那种。
- 沙箱里的命令如果像是被拦了(`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 用到模型上下文 80% 时,下一轮开始前自动总结历史(`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]` | 切换工具调用协议 |
| `/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,需终端支持) |
| `/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/`。安装脚本会把字体装进系统字体目录。
+22
View File
@@ -0,0 +1,22 @@
# archive/ — frozen clients (not shipped with Hermit)
This tree holds **reference front ends** that used to live in the main repo.
The Hermit product surface is now:
- **CLI + minimal TUI** (`hermit`)
- **Engine library** (`libcstcore.a` + `include/`)
- **Replaceable extensions**: plugins / MCP / skills / agents (not core tools)
- **Client contracts**: `cst_agent_hooks_t` and HTTP/SSE `cst-webui/1` (`hermit --web`)
| Path | Former path | Notes |
|------|-------------|--------|
| [`qt-app/`](qt-app/) | `app/` | Qt6/QML GUI (deprecated). Experimental CMake: `-DCST_BUILD_APP=ON`. |
| [`desktop-tauri/`](desktop-tauri/) | `apps/desktop/` | Tauri 2 + React shell (FFI into `libcstcore`). |
| [`web-spa/`](web-spa/) | `web/` | Full browser SPA. Load with `hermit --web --web-dev archive/web-spa`. |
**Do not expect** these trees to stay buildable without manual path fixes.
Default `make` / CI does **not** compile them.
For a new external client, implement against [`docs/webui.md`](../docs/webui.md) and
[`docs/webui-schema.md`](../docs/webui-schema.md), or embed via `cst_agent.h` hooks / the
archived FFI bridge under `desktop-tauri/src-tauri/src/ffi_bridge.*`.
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
src-tauri/target/
.DS_Store
*.log
.layout-check.png
.layout-dsh.png
.layout-dsh-qa/
+58
View File
@@ -0,0 +1,58 @@
# 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 archive/desktop-tauri
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
```
archive/desktop-tauri/
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.
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN" data-ds-dark-theme>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermit</title>
<style>
/* Boot: match DSH bluish-950 before modules arrive; never flash light UA chrome */
html, body, #root { width: 100%; height: 100%; margin: 0; background: rgb(21, 21, 23); color: rgb(247, 247, 248); }
#root { display: flex; flex-direction: column; min-width: 0; }
button { appearance: none; -webkit-appearance: none; }
</style>
</head>
<body data-ds-dark-theme>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
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"
}
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Hermit Desktop — official local dev entry (engine preferred).
#
# Required for real turns:
# - libcstcore.a at repo root (auto-enables --features engine), OR HERMIT_FEATURES=engine
# - DEEPSEEK_API_KEY in the environment, OR set the key in Settings after launch
#
# Host-only (no engine): HERMIT_FEATURES=host ./run-dev.sh
# Composer will refuse send until engine is available (no silent mock product path).
# UI mock turns (browser/host demos only): localStorage hermit.allowMock=1 or VITE_HERMIT_ALLOW_MOCK=1
#
# Hand-check: new session → type → streaming assistant → Stop → reopen session has history
set -euo pipefail
cd "$(dirname "$0")"
# 补 PKG_CONFIG_PATH(勿整文件 source set-environment:会撞 set -u)
if [[ -z "${PKG_CONFIG_PATH:-}" ]]; then
if [[ -f /etc/set-environment ]]; then
PKG_CONFIG_PATH="$(set +u; . /etc/set-environment >/dev/null; printf '%s' "${PKG_CONFIG_PATH:-}")"
export PKG_CONFIG_PATH
fi
fi
if [[ -z "${PKG_CONFIG_PATH:-}" ]]; then
echo "hermit-dev: PKG_CONFIG_PATH 为空。请用 hermit-tauri-dev 或 nixos-rebuild 后重开终端" >&2
exit 1
fi
if ! pkg-config --exists webkit2gtk-4.1 gdk-pixbuf-2.0 2>/dev/null; then
echo "hermit-dev: 缺少 webkit/gdk-pixbuf .pc" >&2
exit 1
fi
unset WEBKIT_DISABLE_COMPOSITING_MODE || true
if [[ -f /proc/driver/nvidia/version ]]; then
export __NV_DISABLE_EXPLICIT_SYNC="${__NV_DISABLE_EXPLICIT_SYNC:-1}"
# X11/XWayland 上 WebKit DMABUF →「Failed to create GBM buffer」→ 空白灰窗
# 必须关 DMABUF;Wayland 原生则可只靠 __NV_DISABLE_EXPLICIT_SYNC
if [[ -n "${WAYLAND_DISPLAY:-}" || "${XDG_SESSION_TYPE:-}" == wayland ]]; then
export GDK_BACKEND="${GDK_BACKEND:-x11}"
fi
if [[ "${GDK_BACKEND:-}" == x11 ]]; then
export WEBKIT_DISABLE_DMABUF_RENDERER="${WEBKIT_DISABLE_DMABUF_RENDERER:-1}"
else
unset WEBKIT_DISABLE_DMABUF_RENDERER || true
fi
fi
export TAURI_DEV_HOST="${TAURI_DEV_HOST:-127.0.0.1}"
# Optional real engine (libcstcore). Example:
# HERMIT_FEATURES=engine ./run-dev.sh
# # or:
# HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
# HERMIT_CORE_INCLUDE=$PWD/../../include \
# npm run tauri -- dev -- --features engine
TAURI_EXTRA=()
ROOT="$(cd ../.. && pwd)"
# Auto-enable engine when libcstcore.a exists, unless HERMIT_FEATURES=host
if [[ "${HERMIT_FEATURES:-}" == *engine* ]] || {
[[ "${HERMIT_FEATURES:-}" != *host* ]] && [[ -f "$ROOT/libcstcore.a" ]]
}; then
export HERMIT_CORE_LIB="${HERMIT_CORE_LIB:-$ROOT/libcstcore.a}"
export HERMIT_CORE_INCLUDE="${HERMIT_CORE_INCLUDE:-$ROOT/include}"
if [[ -f "$HERMIT_CORE_LIB" ]]; then
# NixOS: -lcurl needs an explicit libdir (pkg-config or ldd).
if [[ -z "${HERMIT_CURL_LIBDIR:-}" ]]; then
if pkg-config --exists libcurl 2>/dev/null; then
HERMIT_CURL_LIBDIR="$(pkg-config --variable=libdir libcurl 2>/dev/null || true)"
fi
if [[ -z "${HERMIT_CURL_LIBDIR:-}" ]] && command -v curl >/dev/null 2>&1; then
curl_so="$(ldd "$(command -v curl)" 2>/dev/null | awk '/libcurl\.so/ { print $3; exit }' || true)"
if [[ -n "${curl_so:-}" && -e "$curl_so" ]]; then
HERMIT_CURL_LIBDIR="$(dirname "$curl_so")"
fi
fi
if [[ -n "${HERMIT_CURL_LIBDIR:-}" ]]; then
export HERMIT_CURL_LIBDIR
export LD_LIBRARY_PATH="${HERMIT_CURL_LIBDIR}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
fi
fi
echo "hermit-dev: engine feature HERMIT_CORE_LIB=$HERMIT_CORE_LIB curl=${HERMIT_CURL_LIBDIR:-default}" >&2
TAURI_EXTRA+=(--features engine)
else
echo "hermit-dev: engine requested but missing $HERMIT_CORE_LIB (host mock)" >&2
fi
fi
for p in 1420 1421; do
if command -v fuser >/dev/null 2>&1; then
fuser -k "${p}/tcp" 2>/dev/null || true
else
mapfile -t pids < <(ss -ltnp 2>/dev/null | awk -v p=":$p" '$4 ~ p {
if (match($0, /pid=[0-9]+/)) { print substr($0, RSTART+4, RLENGTH-4) }
}' | sort -u)
for pid in "${pids[@]:-}"; do
[[ -n "${pid:-}" ]] && kill "$pid" 2>/dev/null || true
done
fi
done
sleep 0.3
if [[ -z "${DEEPSEEK_API_KEY:-}" && -z "${OPENAI_API_KEY:-}" ]]; then
echo "hermit-dev: note — no DEEPSEEK_API_KEY/OPENAI_API_KEY in env; set one or paste key in Settings" >&2
fi
if printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -qx -- '--features' && printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -qx engine; then
echo "hermit-dev: mode=engine (libcstcore)" >&2
elif printf '%s\n' "${TAURI_EXTRA[@]:-}" | grep -q engine; then
echo "hermit-dev: mode=engine (libcstcore)" >&2
else
echo "hermit-dev: mode=host (Composer send disabled until engine)" >&2
fi
echo "hermit-dev: GDK_BACKEND=${GDK_BACKEND:-none} DMABUF_OFF=${WEBKIT_DISABLE_DMABUF_RENDERER:-0} NV_SYNC=${__NV_DISABLE_EXPLICIT_SYNC:-0} host=${TAURI_DEV_HOST}" >&2
exec npm run tauri -- dev "${TAURI_EXTRA[@]}" "$@"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
[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"
tauri-plugin-dialog = "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 = []
+175
View File
@@ -0,0 +1,175 @@
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() {
let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
// src-tauri → desktop → apps → repo root
let repo_root = manifest_dir.join("../../..");
let include = std::env::var("HERMIT_CORE_INCLUDE").unwrap_or_else(|_| {
repo_root
.join("include")
.canonicalize()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| repo_root.join("include").display().to_string())
});
let mut build = cc::Build::new();
build
.file("src/ffi_bridge.c")
.include(&include)
.include("src")
.warnings(false);
// libcstcore pulls C11 + POSIX.
build.std("c11");
build.compile("hermit_ffi_bridge");
println!("cargo:rerun-if-changed=src/ffi_bridge.c");
println!("cargo:rerun-if-changed=src/ffi_bridge.h");
let core = std::env::var("HERMIT_CORE_LIB")
.ok()
.map(std::path::PathBuf::from)
.filter(|p| p.exists())
.or_else(|| {
let fallback = repo_root.join("libcstcore.a");
fallback.exists().then_some(fallback)
});
if let Some(path) = core {
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);
// libcstcore leaves sashidown symbols unresolved (same as Makefile LDLIBS).
let sashidown_dir = repo_root.join("vendor/sashidown");
let sashidown_a = sashidown_dir.join("libsashidown.a");
if sashidown_a.exists() {
println!(
"cargo:rustc-link-search=native={}",
sashidown_dir
.canonicalize()
.unwrap_or_else(|_| sashidown_dir.clone())
.display()
);
println!("cargo:rustc-link-lib=static=sashidown");
println!("cargo:rerun-if-changed={}", sashidown_a.display());
} else {
println!(
"cargo:warning=missing {} (make -C vendor/sashidown libsashidown.a)",
sashidown_a.display()
);
}
link_curl_and_sys();
println!("cargo:rerun-if-changed={}", path.display());
} else {
println!(
"cargo:warning=feature engine enabled but HERMIT_CORE_LIB unset and libcstcore.a missing"
);
}
println!("cargo:rerun-if-env-changed=HERMIT_CORE_LIB");
println!("cargo:rerun-if-env-changed=HERMIT_CORE_INCLUDE");
println!("cargo:rerun-if-env-changed=HERMIT_CURL_LIBDIR");
println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");
println!("cargo:rustc-env=HERMIT_CORE_INCLUDE={}", include);
}
tauri_build::build()
}
/// libcstcore needs libcurl (+ pthread/m/dl). On NixOS `-lcurl` alone fails
/// because curl is not on the default linker path — resolve via pkg-config,
/// HERMIT_CURL_LIBDIR, or `ldd $(command -v curl)`.
fn link_curl_and_sys() {
if link_curl_via_pkg_config() || link_curl_via_env() || link_curl_via_ldd() {
// ok
} else {
println!("cargo:warning=libcurl path not found; falling back to -lcurl");
println!("cargo:rustc-link-lib=curl");
}
println!("cargo:rustc-link-lib=pthread");
println!("cargo:rustc-link-lib=m");
println!("cargo:rustc-link-lib=dl");
}
fn apply_pkg_libs(libs: &str) -> bool {
let mut saw_curl = false;
for tok in libs.split_whitespace() {
if let Some(dir) = tok.strip_prefix("-L") {
if !dir.is_empty() {
println!("cargo:rustc-link-search=native={dir}");
}
} else if let Some(lib) = tok.strip_prefix("-l") {
if lib == "curl" {
saw_curl = true;
}
println!("cargo:rustc-link-lib={lib}");
}
}
saw_curl
}
fn link_curl_via_pkg_config() -> bool {
let Ok(out) = std::process::Command::new("pkg-config")
.args(["--libs", "libcurl"])
.output()
else {
return false;
};
if !out.status.success() {
return false;
}
let libs = String::from_utf8_lossy(&out.stdout);
apply_pkg_libs(libs.trim())
}
fn link_curl_via_env() -> bool {
let Ok(dir) = std::env::var("HERMIT_CURL_LIBDIR") else {
return false;
};
if dir.is_empty() || !std::path::Path::new(&dir).is_dir() {
return false;
}
println!("cargo:rustc-link-search=native={dir}");
println!("cargo:rustc-link-lib=curl");
true
}
fn link_curl_via_ldd() -> bool {
// Parse: libcurl.so.4 => /nix/store/.../lib/libcurl.so.4 (0x...)
let script = r#"
set -e
bin=$(command -v curl 2>/dev/null || true)
[ -n "$bin" ] || exit 1
ldd "$bin" 2>/dev/null | awk '/libcurl\.so/ { print $3; exit }'
"#;
let Ok(out) = std::process::Command::new("sh")
.arg("-c")
.arg(script)
.output()
else {
return false;
};
if !out.status.success() {
return false;
}
let so = String::from_utf8_lossy(&out.stdout);
let so = so.trim();
if so.is_empty() || so == "not" {
return false;
}
let path = std::path::Path::new(so);
let Some(dir) = path.parent() else {
return false;
};
if !dir.is_dir() {
return false;
}
println!("cargo:rustc-link-search=native={}", dir.display());
println!("cargo:rustc-link-lib=curl");
true
}
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default Hermit desktop capabilities",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open",
"dialog:default"
]
}
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","dialog:default"]}}
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

File diff suppressed because it is too large Load Diff
+780
View File
@@ -0,0 +1,780 @@
//! FFI surface against `include/cst_agent.h` via `ffi_bridge.c`.
//!
//! Enable with:
//! HERMIT_CORE_LIB=$PWD/../../libcstcore.a \
//! HERMIT_CORE_INCLUDE=$PWD/../../include \
//! cargo build --features engine
#![allow(dead_code)]
use std::sync::{Condvar, Mutex};
#[cfg(not(feature = "engine"))]
pub fn engine_linked() -> bool {
false
}
#[cfg(feature = "engine")]
pub fn engine_linked() -> bool {
true
}
/// Shared waiter: UI answers via `consent_answer` / `question_answer`.
#[derive(Default)]
pub struct ConsentGate {
inner: Mutex<ConsentGateInner>,
cv: Condvar,
}
#[derive(Default)]
struct ConsentGateInner {
pending_id: Option<String>,
pending_tool: String,
pending_detail: String,
answer: Option<String>,
answer_text: String,
cancel: bool,
}
impl ConsentGate {
pub fn request(&self, id: &str, tool: &str, detail: &str) -> i32 {
let mut g = self.inner.lock().unwrap();
g.pending_id = Some(id.to_string());
g.pending_tool = tool.to_string();
g.pending_detail = detail.to_string();
g.answer = None;
g.answer_text.clear();
g.cancel = false;
loop {
if g.cancel {
g.pending_id = None;
return 0;
}
if let Some(ans) = g.answer.take() {
g.pending_id = None;
g.answer_text = ans.clone();
return match ans.as_str() {
"once" | "always_tool" | "always_prefix" => {
if ans == "once" {
1
} else if ans == "always_tool" {
2
} else {
3
}
}
_ if !ans.is_empty() && ans != "deny" => 1,
_ => 0,
};
}
g = self.cv.wait(g).unwrap();
}
}
pub fn request_text(&self, id: &str, tool: &str, detail: &str) -> Option<String> {
let code = self.request(id, tool, detail);
if code == 0 {
return None;
}
let g = self.inner.lock().unwrap();
if g.answer_text.is_empty() || g.answer_text == "once" {
Some(String::new())
} else {
Some(g.answer_text.clone())
}
}
pub fn answer(&self, id: &str, answer: &str) -> bool {
let mut g = self.inner.lock().unwrap();
if g.pending_id.as_deref() != Some(id) {
return false;
}
g.answer = Some(answer.to_string());
g.answer_text = answer.to_string();
self.cv.notify_all();
true
}
pub fn cancel(&self) {
let mut g = self.inner.lock().unwrap();
g.cancel = true;
self.cv.notify_all();
}
}
#[cfg(feature = "engine")]
mod live {
use super::*;
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int, c_void};
use std::ptr;
use std::sync::Arc;
#[repr(C)]
struct HermitHooks {
on_assistant: Option<extern "C" fn(*const c_char, usize, *mut c_void)>,
on_thought: Option<extern "C" fn(*const c_char, usize, *mut c_void)>,
on_tool: Option<
extern "C" fn(
*const c_char,
*const c_char,
*const c_char,
*const c_char,
*mut c_void,
),
>,
on_tool_result: Option<
extern "C" fn(*const c_char, *const c_char, *const c_char, usize, c_int, *mut c_void),
>,
on_todo: Option<extern "C" fn(*const c_char, *mut c_void)>,
on_status: Option<extern "C" fn(*const c_char, *mut c_void)>,
on_perm: Option<
extern "C" fn(*const c_char, *const c_char, *const c_char, *mut c_void) -> c_int,
>,
on_ask: Option<
extern "C" fn(
*const c_char,
*const c_char,
*mut c_char,
usize,
*mut c_void,
) -> c_int,
>,
ud: *mut c_void,
}
extern "C" {
fn hermit_agent_open(
workspace: *const c_char,
provider: *const c_char,
model: *const c_char,
approval: *const c_char,
api_key: *const c_char,
effort: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> *mut c_void;
fn hermit_agent_close(agent: *mut c_void);
fn hermit_agent_turn(
agent: *mut c_void,
user_text: *const c_char,
hooks: *const HermitHooks,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_cancel(agent: *mut c_void);
fn hermit_agent_set_workspace(
agent: *mut c_void,
path: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_new_session(
agent: *mut c_void,
id_out: *mut c_char,
id_sz: usize,
) -> c_int;
fn hermit_agent_switch_session(agent: *mut c_void, id: *const c_char) -> c_int;
fn hermit_agent_session_id(
agent: *mut c_void,
out: *mut c_char,
out_sz: usize,
) -> c_int;
fn hermit_agent_set_plan_mode(agent: *mut c_void, on: c_int) -> c_int;
fn hermit_agent_set_approval(agent: *mut c_void, approval: *const c_char) -> c_int;
fn hermit_agent_set_effort(agent: *mut c_void, effort: *const c_char) -> c_int;
fn hermit_agent_attach_file(
agent: *mut c_void,
path: *const c_char,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_pending_clear(agent: *mut c_void);
fn hermit_agent_compact(
agent: *mut c_void,
err: *mut c_char,
err_sz: usize,
) -> c_int;
fn hermit_agent_usage(
agent: *mut c_void,
prompt: *mut i64,
out_tokens: *mut i64,
cost: *mut f64,
priced: *mut c_int,
);
fn hermit_agent_history_json(agent: *mut c_void) -> *mut c_char;
fn hermit_agent_todo(agent: *mut c_void) -> *mut c_char;
fn hermit_agent_export_md(agent: *mut c_void, title: *const c_char) -> *mut c_char;
fn hermit_creds_has(provider: *const c_char) -> c_int;
fn free(p: *mut c_void);
fn hermit_store_list(
workspace: *const c_char,
ids: *mut [c_char; 128],
max_ids: c_int,
) -> c_int;
fn hermit_store_info(
workspace: *const c_char,
id: *const c_char,
title: *mut c_char,
title_sz: usize,
mtime_ms: *mut i64,
) -> c_int;
fn hermit_store_title_set(
workspace: *const c_char,
id: *const c_char,
title: *const c_char,
) -> c_int;
fn hermit_store_delete(workspace: *const c_char, id: *const c_char) -> c_int;
fn hermit_creds_save(provider: *const c_char, key: *const c_char) -> c_int;
}
pub struct LiveAgent {
ptr: *mut c_void,
}
unsafe impl Send for LiveAgent {}
impl Drop for LiveAgent {
fn drop(&mut self) {
if !self.ptr.is_null() {
unsafe { hermit_agent_close(self.ptr) };
self.ptr = ptr::null_mut();
}
}
}
fn cstr_lossy(buf: &[u8]) -> String {
CStr::from_bytes_until_nul(buf)
.ok()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default()
}
pub fn open(
workspace: &str,
provider: &str,
model: &str,
approval: &str,
api_key: &str,
effort: &str,
) -> Result<LiveAgent, String> {
let ws = CString::new(workspace).map_err(|e| e.to_string())?;
let prov = CString::new(provider).map_err(|e| e.to_string())?;
let modl = CString::new(model).map_err(|e| e.to_string())?;
let appr = CString::new(approval).map_err(|e| e.to_string())?;
let key = CString::new(api_key).map_err(|e| e.to_string())?;
let eff = CString::new(effort).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let ptr = unsafe {
hermit_agent_open(
ws.as_ptr(),
prov.as_ptr(),
modl.as_ptr(),
appr.as_ptr(),
key.as_ptr(),
eff.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if ptr.is_null() {
return Err(cstr_lossy(&err));
}
Ok(LiveAgent { ptr })
}
pub fn cancel(agent: &LiveAgent) {
unsafe { hermit_agent_cancel(agent.ptr) };
}
pub fn set_workspace(agent: &LiveAgent, path: &str) -> Result<(), String> {
let p = CString::new(path).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_set_workspace(
agent.ptr,
p.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn set_plan_mode(agent: &LiveAgent, on: bool) -> Result<(), String> {
let rc = unsafe { hermit_agent_set_plan_mode(agent.ptr, if on { 1 } else { 0 }) };
if rc != 0 {
return Err("set_plan_mode failed".into());
}
Ok(())
}
pub fn set_approval(agent: &LiveAgent, approval: &str) -> Result<(), String> {
let a = CString::new(approval).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_set_approval(agent.ptr, a.as_ptr()) };
if rc != 0 {
return Err("set_approval failed".into());
}
Ok(())
}
pub fn set_effort(agent: &LiveAgent, effort: &str) -> Result<(), String> {
let e = CString::new(effort).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_set_effort(agent.ptr, e.as_ptr()) };
if rc != 0 {
return Err("set_effort failed".into());
}
Ok(())
}
pub fn attach_file(agent: &LiveAgent, path: &str) -> Result<(), String> {
let p = CString::new(path).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_attach_file(
agent.ptr,
p.as_ptr(),
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn pending_clear(agent: &LiveAgent) {
unsafe { hermit_agent_pending_clear(agent.ptr) };
}
pub fn compact(agent: &LiveAgent) -> Result<(), String> {
let mut err = vec![0u8; 512];
let rc = unsafe {
hermit_agent_compact(agent.ptr, err.as_mut_ptr() as *mut c_char, err.len())
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub struct UsageSnap {
pub prompt: i64,
pub out: i64,
pub cost: f64,
pub priced: bool,
pub pct: f64,
}
pub fn usage(agent: &LiveAgent) -> Result<UsageSnap, String> {
let mut prompt = 0i64;
let mut out = 0i64;
let mut cost = 0f64;
let mut priced = 0i32;
unsafe {
hermit_agent_usage(agent.ptr, &mut prompt, &mut out, &mut cost, &mut priced);
}
let pct = ((prompt + out) as f64 / 128_000.0 * 100.0).min(99.0);
Ok(UsageSnap {
prompt,
out,
cost,
priced: priced != 0,
pct,
})
}
pub fn new_session(agent: &LiveAgent) -> Result<String, String> {
let mut buf = vec![0u8; 64];
let rc = unsafe {
hermit_agent_new_session(agent.ptr, buf.as_mut_ptr() as *mut c_char, buf.len())
};
if rc != 0 {
return Err("new_session failed".into());
}
Ok(cstr_lossy(&buf))
}
pub fn switch_session(agent: &LiveAgent, id: &str) -> Result<(), String> {
let i = CString::new(id).map_err(|e| e.to_string())?;
let rc = unsafe { hermit_agent_switch_session(agent.ptr, i.as_ptr()) };
if rc != 0 {
return Err("switch_session failed".into());
}
Ok(())
}
pub fn session_history_json(agent: &LiveAgent) -> Result<String, String> {
let ptr = unsafe { hermit_agent_history_json(agent.ptr) };
if ptr.is_null() {
return Ok("[]".into());
}
let json = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
Ok(json)
}
pub fn todo_text(agent: &LiveAgent) -> String {
let ptr = unsafe { hermit_agent_todo(agent.ptr) };
if ptr.is_null() {
return String::new();
}
let s = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
s
}
pub fn export_md(agent: &LiveAgent, title: &str) -> Result<String, String> {
let t = CString::new(title).map_err(|e| e.to_string())?;
let ptr = unsafe { hermit_agent_export_md(agent.ptr, t.as_ptr()) };
if ptr.is_null() {
return Err("export failed".into());
}
let s = unsafe { CStr::from_ptr(ptr) }
.to_string_lossy()
.into_owned();
unsafe { free(ptr as *mut c_void) };
Ok(s)
}
pub fn creds_has(provider: &str) -> bool {
let Ok(p) = CString::new(provider) else {
return false;
};
unsafe { hermit_creds_has(p.as_ptr()) != 0 }
}
pub struct TurnCallbacks {
pub on_assistant: Box<dyn FnMut(&str) + Send>,
pub on_thought: Box<dyn FnMut(&str) + Send>,
pub on_tool: Box<dyn FnMut(&str, &str, &str, &str) + Send>,
pub on_tool_result: Box<dyn FnMut(&str, &str, &str, i32) + Send>,
pub on_todo: Box<dyn FnMut(&str) + Send>,
pub on_status: Box<dyn FnMut(&str) + Send>,
pub gate: Arc<ConsentGate>,
pub question_gate: Arc<ConsentGate>,
pub emit_consent: Box<dyn FnMut(String, String, String, Option<String>) + Send>,
pub emit_question: Box<dyn FnMut(String, String, Vec<String>) + Send>,
}
struct TurnUd {
cb: *mut TurnCallbacks,
}
unsafe fn cstr(p: *const c_char) -> String {
if p.is_null() {
return String::new();
}
CStr::from_ptr(p).to_string_lossy().into_owned()
}
extern "C" fn c_assistant(text: *const c_char, len: usize, ud: *mut c_void) {
if text.is_null() || ud.is_null() {
return;
}
let s = unsafe { std::slice::from_raw_parts(text as *const u8, len) };
let t = String::from_utf8_lossy(s);
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_assistant)(&t);
}
extern "C" fn c_thought(text: *const c_char, len: usize, ud: *mut c_void) {
if text.is_null() || ud.is_null() {
return;
}
let s = unsafe { std::slice::from_raw_parts(text as *const u8, len) };
let t = String::from_utf8_lossy(s);
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_thought)(&t);
}
extern "C" fn c_tool(
phase: *const c_char,
id: *const c_char,
name: *const c_char,
detail: *const c_char,
ud: *mut c_void,
) {
if ud.is_null() {
return;
}
let phase = unsafe { cstr(phase) };
let id = unsafe { cstr(id) };
let name = unsafe { cstr(name) };
let detail = unsafe { cstr(detail) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_tool)(&phase, &id, &name, &detail);
}
extern "C" fn c_tool_result(
id: *const c_char,
name: *const c_char,
out: *const c_char,
len: usize,
exit: c_int,
ud: *mut c_void,
) {
if ud.is_null() {
return;
}
let id = unsafe { cstr(id) };
let name = unsafe { cstr(name) };
let out = if out.is_null() {
String::new()
} else {
let s = unsafe { std::slice::from_raw_parts(out as *const u8, len) };
String::from_utf8_lossy(s).into_owned()
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_tool_result)(&id, &name, &out, exit);
}
extern "C" fn c_todo(text: *const c_char, ud: *mut c_void) {
if ud.is_null() {
return;
}
let t = unsafe { cstr(text) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_todo)(&t);
}
extern "C" fn c_status(text: *const c_char, ud: *mut c_void) {
if ud.is_null() {
return;
}
let t = unsafe { cstr(text) };
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
(cb.on_status)(&t);
}
extern "C" fn c_perm(
tool: *const c_char,
detail: *const c_char,
preview: *const c_char,
ud: *mut c_void,
) -> c_int {
if ud.is_null() {
return 0;
}
let tool = unsafe { cstr(tool) };
let detail = unsafe { cstr(detail) };
let preview = if preview.is_null() {
None
} else {
Some(unsafe { cstr(preview) })
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
let id = format!(
"c{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
(cb.emit_consent)(id.clone(), tool.clone(), detail.clone(), preview);
cb.gate.request(&id, &tool, &detail)
}
extern "C" fn c_ask(
question: *const c_char,
options: *const c_char,
ans: *mut c_char,
ans_sz: usize,
ud: *mut c_void,
) -> c_int {
if ud.is_null() || ans.is_null() || ans_sz == 0 {
return -1;
}
let question = unsafe { cstr(question) };
let options_raw = unsafe { cstr(options) };
let opts: Vec<String> = if options_raw.is_empty() {
Vec::new()
} else if options_raw.trim_start().starts_with('[') {
serde_json::from_str(&options_raw).unwrap_or_default()
} else {
options_raw
.split(|c| c == '\n' || c == '|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
};
let turn = unsafe { &mut *(ud as *mut TurnUd) };
let cb = unsafe { &mut *turn.cb };
let id = format!(
"q{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
(cb.emit_question)(id.clone(), question.clone(), opts);
match cb.question_gate.request_text(&id, "ask_user", &question) {
None => -1,
Some(text) => {
let bytes = text.as_bytes();
let n = bytes.len().min(ans_sz - 1);
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ans as *mut u8, n);
*ans.add(n) = 0;
}
0
}
}
}
pub fn run_turn(agent: &LiveAgent, text: &str, mut cb: TurnCallbacks) -> Result<(), String> {
let prompt = CString::new(text).map_err(|e| e.to_string())?;
let mut err = vec![0u8; 1024];
let mut turn_ud = TurnUd {
cb: &mut cb as *mut TurnCallbacks,
};
let hooks = HermitHooks {
on_assistant: Some(c_assistant),
on_thought: Some(c_thought),
on_tool: Some(c_tool),
on_tool_result: Some(c_tool_result),
on_todo: Some(c_todo),
on_status: Some(c_status),
on_perm: Some(c_perm),
on_ask: Some(c_ask),
ud: &mut turn_ud as *mut TurnUd as *mut c_void,
};
let rc = unsafe {
hermit_agent_turn(
agent.ptr,
prompt.as_ptr(),
&hooks,
err.as_mut_ptr() as *mut c_char,
err.len(),
)
};
if rc != 0 {
return Err(cstr_lossy(&err));
}
Ok(())
}
pub fn store_list(workspace: &str) -> Vec<(String, String, i64)> {
let Ok(ws) = CString::new(workspace) else {
return Vec::new();
};
let mut ids = [[0i8; 128]; 200];
let n = unsafe { hermit_store_list(ws.as_ptr(), ids.as_mut_ptr(), 200) };
if n <= 0 {
return Vec::new();
}
let mut out = Vec::new();
for i in 0..(n as usize) {
let id = unsafe { CStr::from_ptr(ids[i].as_ptr()) }
.to_string_lossy()
.into_owned();
if id.is_empty() {
continue;
}
let Ok(id_c) = CString::new(id.as_str()) else {
continue;
};
let mut title_buf = vec![0u8; 128];
let mut mtime: i64 = 0;
let rc = unsafe {
hermit_store_info(
ws.as_ptr(),
id_c.as_ptr(),
title_buf.as_mut_ptr() as *mut c_char,
title_buf.len(),
&mut mtime,
)
};
let title = if rc == 0 {
cstr_lossy(&title_buf)
} else {
id.clone()
};
out.push((id, title, mtime));
}
out
}
pub fn store_title_set(workspace: &str, id: &str, title: &str) -> bool {
let Ok(ws) = CString::new(workspace) else {
return false;
};
let Ok(i) = CString::new(id) else {
return false;
};
let Ok(t) = CString::new(title) else {
return false;
};
unsafe { hermit_store_title_set(ws.as_ptr(), i.as_ptr(), t.as_ptr()) == 0 }
}
pub fn store_delete(workspace: &str, id: &str) -> bool {
let Ok(ws) = CString::new(workspace) else {
return false;
};
let Ok(i) = CString::new(id) else {
return false;
};
unsafe { hermit_store_delete(ws.as_ptr(), i.as_ptr()) == 0 }
}
pub fn creds_save(provider: &str, key: &str) -> bool {
let Ok(p) = CString::new(provider) else {
return false;
};
let Ok(k) = CString::new(key) else {
return false;
};
unsafe { hermit_creds_save(p.as_ptr(), k.as_ptr()) == 0 }
}
pub struct ProviderSnap {
pub name: String,
pub model: String,
pub has_key: bool,
pub key_from: Option<String>,
}
pub fn providers_list() -> Result<Vec<ProviderSnap>, String> {
let presets = [
("deepseek", "deepseek-flash"),
("openai", "gpt-4.1"),
("anthropic", "claude-sonnet-4"),
];
Ok(presets
.into_iter()
.map(|(name, model)| {
let has = creds_has(name)
|| std::env::var(format!("{}_API_KEY", name.to_ascii_uppercase())).is_ok()
|| (name == "openai" && std::env::var("OPENAI_API_KEY").is_ok())
|| (name == "deepseek"
&& (std::env::var("DEEPSEEK_API_KEY").is_ok()
|| std::env::var("OPENAI_API_KEY").is_ok()));
ProviderSnap {
name: name.into(),
model: model.into(),
has_key: has,
key_from: if has { Some("creds/env".into()) } else { None },
}
})
.collect())
}
}
#[cfg(feature = "engine")]
pub use live::*;
@@ -0,0 +1,517 @@
#include "ffi_bridge.h"
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
const hermit_hooks_t *h;
} bridge_ud_t;
static void hook_assistant(const char *text, size_t len, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_assistant)
b->h->on_assistant(text, len, b->h->ud);
}
static void hook_think(const char *text, size_t len, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_thought)
b->h->on_thought(text, len, b->h->ud);
}
static void hook_tool(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_tool || !call)
return;
b->h->on_tool(phase ? phase : "", call->id, call->name,
detail ? detail : "", b->h->ud);
}
static void hook_tool_result(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_tool_result || !call)
return;
b->h->on_tool_result(call->id, call->name, out ? out : "", len, exit_code,
b->h->ud);
}
static void hook_todo(const char *text, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_todo)
b->h->on_todo(text ? text : "", b->h->ud);
}
static void hook_status(const char *msg, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_status)
b->h->on_status(msg ? msg : "", b->h->ud);
}
static int hook_perm(const char *tool_name, const char *detail,
const char *preview, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_perm)
return CST_PERM_DENY;
return b->h->on_perm(tool_name ? tool_name : "", detail ? detail : "",
preview, b->h->ud);
}
static int hook_ask(const char *question, const char *options, char *ans,
size_t ans_sz, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (!b || !b->h || !b->h->on_ask)
return -1;
return b->h->on_ask(question ? question : "", options ? options : "", ans,
ans_sz, b->h->ud);
}
static void hook_turn_begin(const char *user_text, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_turn_begin)
b->h->on_turn_begin(user_text ? user_text : "", b->h->ud);
}
static void hook_turn_end(int rc, const char *err, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_turn_end)
b->h->on_turn_end(rc, err, b->h->ud);
}
static void hook_error(const char *kind, const char *message, void *ud) {
bridge_ud_t *b = (bridge_ud_t *)ud;
if (b && b->h && b->h->on_error)
b->h->on_error(kind ? kind : "", message ? message : "", b->h->ud);
}
void *hermit_agent_open(const char *workspace, const char *provider,
const char *model, const char *approval,
const char *api_key, const char *effort, char *err,
size_t err_sz) {
if (err && err_sz)
err[0] = '\0';
if (!workspace || !workspace[0]) {
if (err && err_sz)
snprintf(err, err_sz, "workspace required");
return NULL;
}
if (cst_agent_store_ensure(workspace) != 0) {
if (err && err_sz)
snprintf(err, err_sz, "store ensure failed");
return NULL;
}
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", workspace);
if (provider && provider[0])
snprintf(cfg.provider, sizeof(cfg.provider), "%s", provider);
else
snprintf(cfg.provider, sizeof(cfg.provider), "%s", "deepseek");
if (model && model[0])
snprintf(cfg.model, sizeof(cfg.model), "%s", model);
else
snprintf(cfg.model, sizeof(cfg.model), "%s", "deepseek-flash");
snprintf(cfg.mode, sizeof(cfg.mode), "%s", CST_DEFAULT_MODE);
cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = CST_DEFAULT_MAX_STEPS;
cfg.tool_out_cap = CST_DEFAULT_TOOL_OUT_CAP;
cfg.approval = CST_DEFAULT_APPROVAL;
if (approval && approval[0]) {
int a = CST_DEFAULT_APPROVAL;
if (cst_approval_parse(approval, &a) == 0)
cfg.approval = a;
}
if (effort && effort[0] && cst_effort_valid(effort))
snprintf(cfg.effort, sizeof(cfg.effort), "%s", effort);
char id[64];
if (cst_agent_store_resolve_default(cfg.workspace_root, cfg.session_path,
sizeof(cfg.session_path), id,
sizeof(id)) != 0) {
if (cst_agent_store_new_id(id, sizeof(id)) != 0 ||
cst_agent_store_path_for_id(cfg.workspace_root, id, cfg.session_path,
sizeof(cfg.session_path)) != 0) {
if (err && err_sz)
snprintf(err, err_sz, "session path failed");
return NULL;
}
cst_agent_store_set_current(cfg.workspace_root, id);
}
if (api_key && api_key[0]) {
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", api_key);
} else {
char key[512];
const char *from = NULL;
if (cst_creds_resolve(cfg.provider, key, sizeof(key), &from) == 0)
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", key);
}
cst_agent_t *a = cst_agent_create(&cfg);
if (!a && err && err_sz)
snprintf(err, err_sz, "cst_agent_create failed");
return a;
}
void hermit_agent_close(void *agent) {
if (agent)
cst_agent_destroy((cst_agent_t *)agent);
}
int hermit_agent_turn(void *agent, const char *user_text,
const hermit_hooks_t *hooks, char *err, size_t err_sz) {
if (!agent || !user_text)
return -1;
bridge_ud_t bud = {.h = hooks};
cst_agent_hooks_t h;
memset(&h, 0, sizeof(h));
h.on_assistant_delta = hook_assistant;
h.on_think_delta = hook_think;
h.on_tool_event = hook_tool;
h.on_tool_result = hook_tool_result;
h.on_todo = hook_todo;
h.on_status = hook_status;
h.on_permission = hook_perm;
h.on_ask_user = hook_ask;
h.on_turn_begin = hook_turn_begin;
h.on_turn_end = hook_turn_end;
h.on_error = hook_error;
h.userdata = &bud;
return cst_agent_run_turn_ex((cst_agent_t *)agent, user_text, &h, err,
err_sz);
}
void hermit_agent_cancel(void *agent) {
if (agent)
cst_agent_request_cancel((cst_agent_t *)agent);
}
int hermit_agent_set_workspace(void *agent, const char *path, char *err,
size_t err_sz) {
if (!agent || !path)
return -1;
return cst_agent_set_workspace((cst_agent_t *)agent, path, err, err_sz);
}
int hermit_agent_new_session(void *agent, char *id_out, size_t id_sz) {
if (!agent)
return -1;
return cst_agent_new_session((cst_agent_t *)agent, id_out, id_sz);
}
int hermit_agent_switch_session(void *agent, const char *id) {
if (!agent || !id)
return -1;
return cst_agent_switch_session((cst_agent_t *)agent, id);
}
int hermit_agent_session_id(void *agent, char *out, size_t out_sz) {
if (!agent || !out || out_sz == 0)
return -1;
cst_agent_session_id((cst_agent_t *)agent, out, out_sz);
return 0;
}
int hermit_agent_set_plan_mode(void *agent, int on) {
if (!agent)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
cfg->plan_mode = on ? 1 : 0;
return 0;
}
int hermit_agent_set_approval(void *agent, const char *approval) {
if (!agent || !approval)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
int a = cfg->approval;
if (cst_approval_parse(approval, &a) == 0)
cfg->approval = a;
return 0;
}
int hermit_agent_set_effort(void *agent, const char *effort) {
if (!agent)
return -1;
cst_agent_config_t *cfg = cst_agent_config_mut((cst_agent_t *)agent);
if (!cfg)
return -1;
if (!effort || !effort[0]) {
cfg->effort[0] = '\0';
return 0;
}
if (!cst_effort_valid(effort))
return -1;
snprintf(cfg->effort, sizeof(cfg->effort), "%s", effort);
return 0;
}
int hermit_agent_attach_file(void *agent, const char *path, char *err,
size_t err_sz) {
if (!agent || !path)
return -1;
return cst_agent_attach_file((cst_agent_t *)agent, path, err, err_sz);
}
void hermit_agent_pending_clear(void *agent) {
if (agent)
cst_agent_pending_clear((cst_agent_t *)agent);
}
int hermit_agent_compact(void *agent, char *err, size_t err_sz) {
if (!agent)
return -1;
return cst_agent_compact((cst_agent_t *)agent, err, err_sz);
}
void hermit_agent_usage(void *agent, long *prompt, long *out_tokens,
double *cost, int *priced) {
if (!agent)
return;
cst_usage_t s, t;
memset(&s, 0, sizeof(s));
memset(&t, 0, sizeof(t));
cst_agent_usage((cst_agent_t *)agent, &s, &t);
if (prompt)
*prompt = s.prompt;
if (out_tokens)
*out_tokens = s.out;
if (cost)
*cost = s.cost;
if (priced)
*priced = s.priced;
}
static void json_escape_append(char **buf, size_t *len, size_t *cap,
const char *s) {
if (!s)
s = "";
for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
char tmp[8];
const char *chunk;
size_t n;
if (*p == '"' || *p == '\\') {
tmp[0] = '\\';
tmp[1] = (char)*p;
tmp[2] = 0;
chunk = tmp;
n = 2;
} else if (*p < 0x20) {
n = (size_t)snprintf(tmp, sizeof(tmp), "\\u%04x", *p);
chunk = tmp;
} else {
tmp[0] = (char)*p;
tmp[1] = 0;
chunk = tmp;
n = 1;
}
if (*len + n + 1 >= *cap) {
*cap = (*cap + n + 1) * 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return;
*buf = nb;
}
memcpy(*buf + *len, chunk, n);
*len += n;
(*buf)[*len] = 0;
}
}
static int hist_append_obj(char **buf, size_t *len, size_t *cap, int *first,
const char *head, const char *body) {
if (!*first) {
if (*len + 2 >= *cap) {
*cap *= 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
(*buf)[(*len)++] = ',';
(*buf)[*len] = 0;
}
*first = 0;
size_t hn = strlen(head);
if (*len + hn + 8 >= *cap) {
*cap = (*cap + hn) * 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
memcpy(*buf + *len, head, hn);
*len += hn;
(*buf)[*len] = 0;
json_escape_append(buf, len, cap, body ? body : "");
if (*len + 3 >= *cap) {
*cap *= 2;
char *nb = (char *)realloc(*buf, *cap);
if (!nb)
return -1;
*buf = nb;
}
(*buf)[(*len)++] = '"';
(*buf)[(*len)++] = '}';
(*buf)[*len] = 0;
return 0;
}
char *hermit_agent_history_json(void *agent) {
if (!agent)
return NULL;
cst_session_t *sess = cst_agent_session((cst_agent_t *)agent);
if (!sess)
return NULL;
cst_session_lock(sess);
int n = cst_session_count(sess);
size_t cap = 4096, len = 0;
char *buf = (char *)malloc(cap);
if (!buf) {
cst_session_unlock(sess);
return NULL;
}
buf[0] = '[';
len = 1;
int first = 1;
for (int i = 0; i < n; i++) {
const cst_message_t *m = cst_session_get(sess, i);
if (!m)
continue;
if (m->role == CST_ROLE_SYSTEM)
continue;
char idbuf[64], head[288];
snprintf(idbuf, sizeof(idbuf), "h%d", i);
if (m->role == CST_ROLE_ASSISTANT && m->reasoning && m->reasoning[0]) {
snprintf(head, sizeof(head),
"{\"kind\":\"thought\",\"id\":\"%s-r\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->reasoning) != 0)
break;
}
if (m->role == CST_ROLE_TOOL) {
const char *tname =
(m->tool_name[0] && !strpbrk(m->tool_name, "\\\"\n\r"))
? m->tool_name
: "tool";
snprintf(head, sizeof(head),
"{\"kind\":\"tool\",\"id\":\"%s\",\"name\":\"%s\",\"status\":"
"\"ok\",\"detail\":\"%s\",\"output\":\"",
idbuf, tname, tname);
if (hist_append_obj(&buf, &len, &cap, &first, head,
m->content ? m->content : "") != 0)
break;
continue;
}
if (m->role == CST_ROLE_USER) {
if (!m->content || !m->content[0])
continue;
snprintf(head, sizeof(head),
"{\"kind\":\"user\",\"id\":\"%s\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->content) != 0)
break;
continue;
}
if (m->role == CST_ROLE_ASSISTANT) {
if (!m->content || !m->content[0])
continue;
snprintf(head, sizeof(head),
"{\"kind\":\"assistant\",\"id\":\"%s\",\"text\":\"", idbuf);
if (hist_append_obj(&buf, &len, &cap, &first, head, m->content) != 0)
break;
}
}
if (len + 2 >= cap) {
char *nb = (char *)realloc(buf, len + 2);
if (nb)
buf = nb;
}
buf[len++] = ']';
buf[len] = 0;
cst_session_unlock(sess);
return buf;
}
char *hermit_agent_todo(void *agent) {
if (!agent)
return NULL;
const char *t = cst_agent_todo((cst_agent_t *)agent);
if (!t || !t[0])
return NULL;
size_t n = strlen(t);
char *out = (char *)malloc(n + 1);
if (!out)
return NULL;
memcpy(out, t, n + 1);
return out;
}
char *hermit_agent_export_md(void *agent, const char *title) {
if (!agent)
return NULL;
cst_session_t *sess = cst_agent_session((cst_agent_t *)agent);
if (!sess)
return NULL;
char sid[128];
cst_agent_session_id((cst_agent_t *)agent, sid, sizeof(sid));
const cst_agent_config_t *cfg = cst_agent_config((cst_agent_t *)agent);
const char *model = cfg && cfg->model[0] ? cfg->model : "";
return cst_session_export_md(sess, title ? title : "Session", sid, model);
}
int hermit_creds_has(const char *provider) {
char key[512];
const char *from = NULL;
if (!provider || !provider[0])
return 0;
return cst_creds_resolve(provider, key, sizeof(key), &from) == 0 ? 1 : 0;
}
int hermit_store_list(const char *workspace, char ids[][128], int max_ids) {
return cst_agent_store_list(workspace, ids, max_ids);
}
int hermit_store_info(const char *workspace, const char *id, char *title,
size_t title_sz, long long *mtime_ms) {
cst_session_info_t info;
memset(&info, 0, sizeof(info));
if (cst_agent_store_info(workspace, id, &info) != 0)
return -1;
if (title && title_sz) {
if (info.title[0])
snprintf(title, title_sz, "%s", info.title);
else if (info.first_user[0])
snprintf(title, title_sz, "%s", info.first_user);
else
snprintf(title, title_sz, "%s", id);
}
/* cst_session_info_t.mtime is seconds since epoch (os_lstat); JS Date wants ms. */
if (mtime_ms)
*mtime_ms = info.mtime > 0 ? info.mtime * 1000LL : 0;
return 0;
}
int hermit_store_title_set(const char *workspace, const char *id,
const char *title) {
return cst_agent_store_title_set(workspace, id, title);
}
int hermit_store_delete(const char *workspace, const char *id) {
return cst_agent_store_delete(workspace, id);
}
int hermit_creds_save(const char *provider, const char *key) {
return cst_creds_save(provider, key);
}
@@ -0,0 +1,88 @@
/* Thin C façade over cst_agent.h for the Tauri desktop host. */
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef void (*hermit_on_text_fn)(const char *text, size_t len, void *ud);
typedef void (*hermit_on_tool_fn)(const char *phase, const char *id,
const char *name, const char *detail,
void *ud);
typedef void (*hermit_on_tool_result_fn)(const char *id, const char *name,
const char *out, size_t out_len,
int exit_code, void *ud);
typedef void (*hermit_on_todo_fn)(const char *text, void *ud);
typedef void (*hermit_on_status_fn)(const char *msg, void *ud);
/* Return CST_PERM_* . preview may be NULL. */
typedef int (*hermit_on_perm_fn)(const char *tool, const char *detail,
const char *preview, void *ud);
/* Fill ans; return 0 ok, -1 dismiss. options is CSV or JSON-ish string. */
typedef int (*hermit_on_ask_fn)(const char *question, const char *options,
char *ans, size_t ans_sz, void *ud);
typedef void (*hermit_on_turn_begin_fn)(const char *user_text, void *ud);
typedef void (*hermit_on_turn_end_fn)(int rc, const char *err, void *ud);
typedef void (*hermit_on_error_fn)(const char *kind, const char *message,
void *ud);
typedef struct {
hermit_on_text_fn on_assistant;
hermit_on_text_fn on_thought;
hermit_on_tool_fn on_tool;
hermit_on_tool_result_fn on_tool_result;
hermit_on_todo_fn on_todo;
hermit_on_status_fn on_status;
hermit_on_perm_fn on_perm;
hermit_on_ask_fn on_ask;
hermit_on_turn_begin_fn on_turn_begin;
hermit_on_turn_end_fn on_turn_end;
hermit_on_error_fn on_error;
void *ud;
} hermit_hooks_t;
void *hermit_agent_open(const char *workspace, const char *provider,
const char *model, const char *approval,
const char *api_key, const char *effort, char *err,
size_t err_sz);
void hermit_agent_close(void *agent);
int hermit_agent_turn(void *agent, const char *user_text,
const hermit_hooks_t *hooks, char *err, size_t err_sz);
void hermit_agent_cancel(void *agent);
int hermit_agent_set_workspace(void *agent, const char *path, char *err,
size_t err_sz);
int hermit_agent_new_session(void *agent, char *id_out, size_t id_sz);
int hermit_agent_switch_session(void *agent, const char *id);
int hermit_agent_session_id(void *agent, char *out, size_t out_sz);
int hermit_agent_set_plan_mode(void *agent, int on);
int hermit_agent_set_approval(void *agent, const char *approval);
int hermit_agent_set_effort(void *agent, const char *effort);
int hermit_agent_attach_file(void *agent, const char *path, char *err,
size_t err_sz);
void hermit_agent_pending_clear(void *agent);
int hermit_agent_compact(void *agent, char *err, size_t err_sz);
void hermit_agent_usage(void *agent, long *prompt, long *out_tokens,
double *cost, int *priced);
/* Malloc'd JSON array of chat items; caller frees. NULL on failure. */
char *hermit_agent_history_json(void *agent);
/* Malloc'd todo text or NULL. */
char *hermit_agent_todo(void *agent);
/* Malloc'd markdown export; caller frees. */
char *hermit_agent_export_md(void *agent, const char *title);
/* 1 if a saved/env key exists for provider. */
int hermit_creds_has(const char *provider);
int hermit_store_list(const char *workspace, char ids[][128], int max_ids);
int hermit_store_info(const char *workspace, const char *id, char *title,
size_t title_sz, long long *mtime_ms);
int hermit_store_title_set(const char *workspace, const char *id,
const char *title);
int hermit_store_delete(const char *workspace, const char *id);
int hermit_creds_save(const char *provider, const char *key);
#ifdef __cplusplus
}
#endif
@@ -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
+291
View File
@@ -0,0 +1,291 @@
mod engine;
mod ffi;
use engine::{SharedEngine, EngineState};
use std::sync::Mutex;
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem, Submenu},
Emitter, 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 session_rename(
state: tauri::State<'_, SharedEngine>,
id: String,
title: String,
) -> bool {
engine::rename_session(&state, &id, &title)
}
#[tauri::command]
fn session_delete(state: tauri::State<'_, SharedEngine>, id: String) -> bool {
engine::delete_session(&state, &id)
}
#[tauri::command]
fn session_pin(state: tauri::State<'_, SharedEngine>, id: String) -> bool {
engine::pin_session(&state, &id)
}
#[tauri::command]
fn session_set_folder(
state: tauri::State<'_, SharedEngine>,
id: String,
folder: Option<String>,
) -> bool {
engine::session_set_folder(&state, &id, folder)
}
#[tauri::command]
fn session_compact(state: tauri::State<'_, SharedEngine>) -> String {
engine::session_compact(&state)
}
#[tauri::command]
fn session_export(state: tauri::State<'_, SharedEngine>) -> String {
engine::session_export(&state)
}
#[tauri::command]
fn settings_get(state: tauri::State<'_, SharedEngine>) -> engine::Settings {
engine::settings_get(&state)
}
#[tauri::command]
fn settings_set(
state: tauri::State<'_, SharedEngine>,
settings: engine::Settings,
) -> engine::Settings {
engine::settings_set(&state, settings)
}
#[tauri::command]
fn attach_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::AttachmentInfo> {
engine::attach_list(&state)
}
#[tauri::command]
fn attach_file(
state: tauri::State<'_, SharedEngine>,
path: String,
) -> Result<engine::AttachmentInfo, String> {
engine::attach_file(&state, path)
}
#[tauri::command]
fn attach_clear(state: tauri::State<'_, SharedEngine>) {
engine::attach_clear(&state)
}
#[tauri::command]
fn attach_remove(state: tauri::State<'_, SharedEngine>, id: String) {
engine::attach_remove(&state, &id)
}
#[tauri::command]
fn providers_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::ProviderInfo> {
engine::providers_list(&state)
}
#[tauri::command]
fn providers_use(
state: tauri::State<'_, SharedEngine>,
provider: String,
model: String,
key: String,
) -> Result<String, String> {
engine::providers_use(&state, provider, model, key)
}
#[tauri::command]
fn usage_get(state: tauri::State<'_, SharedEngine>) -> engine::UsageInfo {
engine::usage_get(&state)
}
#[tauri::command]
fn todo_get(state: tauri::State<'_, SharedEngine>) -> String {
engine::todo_get(&state)
}
#[tauri::command]
fn bash_list(state: tauri::State<'_, SharedEngine>) -> Vec<engine::BashRun> {
engine::bash_list(&state)
}
#[tauri::command]
fn workspace_files(
state: tauri::State<'_, SharedEngine>,
query: String,
) -> Vec<String> {
engine::workspace_files(&state, query)
}
#[tauri::command]
fn engine_status() -> String {
engine::engine_status()
}
#[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(state: tauri::State<'_, SharedEngine>, id: String, answer: String) {
engine::consent_answer(&state, &id, &answer);
}
#[tauri::command]
fn question_answer(state: tauri::State<'_, SharedEngine>, id: String, answer: String) {
engine::question_answer(&state, &id, &answer);
}
#[cfg(target_os = "linux")]
fn apply_linux_webview_workarounds() {
let nvidia = std::path::Path::new("/proc/driver/nvidia/version").exists();
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some()
|| std::env::var("XDG_SESSION_TYPE").ok().as_deref() == Some("wayland");
unsafe {
if nvidia && std::env::var_os("__NV_DISABLE_EXPLICIT_SYNC").is_none() {
std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1");
}
if nvidia && wayland && std::env::var_os("GDK_BACKEND").is_none() {
std::env::set_var("GDK_BACKEND", "x11");
}
let backend = std::env::var("GDK_BACKEND").unwrap_or_default();
let on_x11 = backend == "x11" || (!wayland && std::env::var_os("DISPLAY").is_some());
if nvidia && on_x11 && std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
}
fn build_menu(app: &tauri::App) -> tauri::Result<Menu<tauri::Wry>> {
let new_session = MenuItem::with_id(app, "new_session", "New Session", true, Some("CmdOrCtrl+N"))?;
let open_ws = MenuItem::with_id(app, "open_workspace", "Open Workspace…", true, Some("CmdOrCtrl+O"))?;
let settings = MenuItem::with_id(app, "settings", "Settings…", true, Some("CmdOrCtrl+Comma"))?;
let quit = PredefinedMenuItem::quit(app, Some("Quit Hermit"))?;
let file = Submenu::with_items(
app,
"File",
true,
&[&new_session, &open_ws, &PredefinedMenuItem::separator(app)?, &settings, &quit],
)?;
let edit = Submenu::with_items(
app,
"Edit",
true,
&[
&PredefinedMenuItem::undo(app, None)?,
&PredefinedMenuItem::redo(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
],
)?;
let view_plan = MenuItem::with_id(app, "view_plan", "Toggle Plan", true, None::<&str>)?;
let view_bash = MenuItem::with_id(app, "view_bash", "Toggle Background Bash", true, Some("CmdOrCtrl+`"))?;
let view = Submenu::with_items(app, "View", true, &[&view_plan, &view_bash])?;
let about = MenuItem::with_id(app, "about", "About Hermit", true, None::<&str>)?;
let help = Submenu::with_items(app, "Help", true, &[&about])?;
Menu::with_items(app, &[&file, &edit, &view, &help])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(target_os = "linux")]
apply_linux_webview_workarounds();
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.manage(Mutex::new(EngineState::default()))
.invoke_handler(tauri::generate_handler![
session_list,
session_new,
session_open,
session_rename,
session_delete,
session_pin,
session_set_folder,
session_compact,
session_export,
settings_get,
settings_set,
attach_list,
attach_file,
attach_clear,
attach_remove,
providers_list,
providers_use,
usage_get,
todo_get,
bash_list,
workspace_files,
engine_status,
send_turn,
cancel_turn,
consent_answer,
question_answer
])
.setup(|app| {
if let Ok(menu) = build_menu(app) {
let _ = app.set_menu(menu);
}
if let Some(w) = app.get_webview_window("main") {
let _ = w.set_title("Hermit");
let _ = w.set_size(tauri::Size::Logical(tauri::LogicalSize {
width: 1280.0,
height: 840.0,
}));
#[cfg(debug_assertions)]
{
let _ = w.navigate(
tauri::Url::parse("http://127.0.0.1:1420/").expect("dev url"),
);
}
}
Ok(())
})
.on_menu_event(|app, event| {
let id = event.id().as_ref();
let _ = app.emit("hermit://menu", id);
})
.run(tauri::generate_context!())
.expect("error while running Hermit desktop");
}
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hermit_desktop_lib::run();
}
@@ -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://127.0.0.1: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"
}
}
}
}
+789
View File
@@ -0,0 +1,789 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Rail } from "@/components/Rail";
import { Transcript } from "@/components/Transcript";
import { Composer, type MainView, type SlashCommand } from "@/components/Composer";
import { Hero } from "@/components/Hero";
import { WorkspaceChip } from "@/components/WorkspaceChip";
import { ApprovalCard } from "@/components/ApprovalCard";
import { QuestionCard } from "@/components/QuestionCard";
import { SessionHeader } from "@/components/SessionHeader";
import { SettingsPanel } from "@/components/SettingsPanel";
import { PlanPanel } from "@/components/PlanPanel";
import { BashPanel } from "@/components/BashPanel";
import type { HermitMood } from "@/components/Icons";
import {
answerConsent,
answerQuestion,
attachFile,
cancelTurn,
clearPending,
compactSession,
deleteSession,
engineStatus,
exportSession,
getSettings,
getTodo,
getUsage,
isShellTool,
listBashRuns,
listPending,
listProviders,
listSessions,
listWorkspaceFiles,
mockTurnsAllowed,
newSession,
onEngineEvent,
onMenuEvent,
openSession,
pickFiles,
pickWorkspace,
pinSession,
removePending,
renameSession,
sendTurn,
setSessionFolder,
setSettings,
useProvider,
type AttachmentInfo,
type BashRun,
type ChatItem,
type ConsentRequest,
type ProviderInfo,
type QuestionRequest,
type SessionInfo,
type Settings,
type UsageInfo,
} from "@/lib/bridge";
import frame from "@/dsh/layout/AppFrame.module.css";
import conv from "@/dsh/conversation/ConversationRoot.module.css";
const SIDEBAR_WIDTH = 280;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function displaySessionTitle(title?: string, id?: string): string {
const t = (title || "").trim();
if (!t || UUID_RE.test(t) || t === id) return "New session";
return t;
}
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, setSettingsState] = useState<Settings>({
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: ".",
plan_mode: false,
reasoning_effort: "high",
font_size: "m",
language: "zh",
});
const [consent, setConsent] = useState<ConsentRequest | null>(null);
const [question, setQuestion] = useState<QuestionRequest | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [status, setStatus] = useState("");
const [todo, setTodo] = useState("");
const [bashRuns, setBashRuns] = useState<BashRun[]>([]);
const [view, setView] = useState<MainView>("chat");
const [attachments, setAttachments] = useState<AttachmentInfo[]>([]);
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [usage, setUsage] = useState<UsageInfo>({
prompt: 0,
out: 0,
cost: 0,
priced: false,
pct: 0,
});
const [fileSuggestions, setFileSuggestions] = useState<string[]>([]);
const [dragging, setDragging] = useState(false);
const [engine, setEngine] = useState("host");
const convColRef = useRef<HTMLDivElement>(null);
const hero = items.length === 0 && !busy && view === "chat";
useEffect(() => {
const el = convColRef.current;
if (!el) return;
const sync = () => {
el.style.setProperty(
"--dsh-conversation-column-width",
`${el.clientWidth}px`,
);
};
sync();
const ro = new ResizeObserver(sync);
ro.observe(el);
return () => ro.disconnect();
}, []);
const agentMood: HermitMood = busy
? consent || question
? "thinking"
: "coding"
: "idle";
const engineReady = engine === "engine";
const sendBlocked = !engineReady && !mockTurnsAllowed();
const sendBlockedReason = sendBlocked
? engine === "browser"
? "当前在浏览器预览(无 Tauri)。请用 apps/desktop/run-dev.sh 启动真引擎。"
: "当前为 host(未链接 libcstcore)。请用 run-dev.sh(自动 --features engine),并在环境或设置中配置 DEEPSEEK_API_KEY。"
: engineReady
? undefined
: "演示 mock 已开启(hermit.allowMock)— 非真实引擎。";
const refreshSessions = useCallback(async () => {
const list = await listSessions();
list.sort(
(a, b) =>
Number(!!b.pinned) - Number(!!a.pinned) || b.updated_at - a.updated_at,
);
setSessions(list);
}, []);
const refreshPending = useCallback(async () => {
setAttachments(await listPending());
}, []);
const refreshBash = useCallback(async () => {
setBashRuns(await listBashRuns());
}, []);
const applySettings = useCallback(async (next: Settings) => {
const saved = await setSettings(next);
setSettingsState(saved);
document.documentElement.dataset.font = saved.font_size || "m";
document.documentElement.dataset.lang = saved.language || "zh";
return saved;
}, []);
const onPickWorkspace = useCallback(async () => {
const path = await pickWorkspace();
if (!path) return;
await applySettings({ ...settings, workspace: path });
await refreshSessions();
}, [applySettings, refreshSessions, settings]);
useEffect(() => {
void (async () => {
const s = await getSettings();
setSettingsState(s);
document.documentElement.dataset.font = s.font_size || "m";
document.documentElement.dataset.lang = s.language || "zh";
const st = await engineStatus();
setEngine(st);
setStatus(st === "engine" ? "engine ready" : st);
setProviders(await listProviders());
setUsage(await getUsage());
const list = await listSessions();
list.sort(
(a, b) =>
Number(!!b.pinned) - Number(!!a.pinned) || b.updated_at - a.updated_at,
);
setSessions(list);
await refreshPending();
// Never block first paint on ensure_live/open_session (can freeze GTK).
// Start on empty hero; open the latest session after a tick.
const forceHero =
typeof location !== "undefined" &&
new URLSearchParams(location.search).get("hero") === "1";
setActiveId(null);
setItems([]);
if (forceHero) return;
const first = list[0];
if (!first) return;
window.setTimeout(() => {
void (async () => {
try {
setActiveId(first.id);
setItems(await openSession(first.id));
setTodo(await getTodo());
setBashRuns(await listBashRuns());
} catch (e) {
console.error("openSession deferred failed", e);
setActiveId(null);
setItems([]);
}
})();
}, 0);
})();
}, [refreshPending]);
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,
ts: Date.now(),
},
];
});
} 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,
ts: Date.now(),
},
];
});
} else if (ev.type === "tool_start") {
setItems((prev) => [
...prev,
{
kind: "tool",
id: ev.id,
name: ev.name,
status: "running",
detail: ev.detail,
ts: Date.now(),
},
]);
if (isShellTool(ev.name)) {
setBashRuns((prev) => {
if (prev.some((b) => b.id === ev.id)) return prev;
return [
...prev,
{
id: ev.id,
command: ev.detail || ev.name,
status: "running",
started_at: Date.now(),
},
];
});
}
} 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,
output: ev.output ?? it.output,
exit_code: ev.exit_code ?? it.exit_code,
}
: it,
),
);
setBashRuns((prev) =>
prev.map((b) =>
b.id === ev.id
? {
...b,
status: ev.status,
output: ev.output ?? b.output,
exit_code: ev.exit_code ?? b.exit_code,
ended_at: Date.now(),
}
: b,
),
);
} else if (ev.type === "consent") {
setConsent(ev.req);
} else if (ev.type === "question") {
setQuestion(ev.req);
} else if (ev.type === "todo") {
setTodo(ev.text);
} else if (ev.type === "status") {
setStatus(ev.message);
} else if (ev.type === "turn_end") {
setBusy(false);
setConsent(null);
setQuestion(null);
void refreshSessions();
void refreshPending();
void getUsage().then(setUsage);
void getTodo().then(setTodo);
void refreshBash();
setActiveId((id) => {
if (id && ev.ok) {
void openSession(id).then(setItems);
}
return id;
});
} else if (ev.type === "error") {
setBusy(false);
setItems((prev) => [
...prev,
{
kind: "assistant",
id: `err${Date.now()}`,
text: `Error: ${ev.message}`,
ts: Date.now(),
},
]);
}
}).then((u) => {
un = u;
});
return () => un?.();
}, [refreshBash, refreshPending, refreshSessions]);
useEffect(() => {
let un: (() => void) | undefined;
void onMenuEvent((id) => {
if (id === "new_session") void onNew();
else if (id === "open_workspace") void onPickWorkspace();
else if (id === "settings") setShowSettings(true);
else if (id === "view_plan") setView((v) => (v === "plan" ? "chat" : "plan"));
else if (id === "view_bash") setView((v) => (v === "bash" ? "chat" : "bash"));
else if (id === "about") setShowSettings(true);
}).then((u) => {
un = u;
});
return () => un?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onPickWorkspace]);
const open = async (id: string) => {
setActiveId(id);
setItems(await openSession(id));
setTodo(await getTodo());
setBashRuns(await listBashRuns());
setConsent(null);
setQuestion(null);
setView("chat");
};
const onNew = async () => {
const s = await newSession();
await refreshSessions();
setActiveId(s.id);
setItems([]);
setConsent(null);
setQuestion(null);
setTodo("");
setBashRuns([]);
setView("chat");
};
const onAttach = async () => {
const paths = await pickFiles();
for (const p of paths) await attachFile(p);
await refreshPending();
};
const onSend = async () => {
const text = draft.trim();
if ((!text && attachments.length === 0) || busy || consent || question) return;
if (sendBlocked) return;
setDraft("");
setBusy(true);
setView("chat");
setItems((prev) => [
...prev,
{
kind: "user",
id: `u${Date.now()}`,
text: text || "(attachments)",
ts: Date.now(),
},
]);
if (!activeId) {
const s = await newSession();
setActiveId(s.id);
await refreshSessions();
} else {
setSessions((prev) =>
prev.map((s) =>
s.id === activeId &&
(s.title === "New session" || s.title === "Welcome")
? {
...s,
title: (text || "Attachments").slice(0, 48),
updated_at: Date.now(),
}
: s,
),
);
}
await sendTurn(text || "Please look at the attached files.");
};
const models = useMemo(() => {
const base = ["deepseek-flash", "deepseek-chat", "deepseek-reasoner"];
if (!base.includes(settings.model)) return [settings.model, ...base];
return base;
}, [settings.model]);
const slashCommands: SlashCommand[] = useMemo(
() => [
{ cmd: "/new", desc: "Start a new session", run: () => void onNew() },
{
cmd: "/model",
desc: "Cycle model",
run: () => {
const i = models.indexOf(settings.model);
const next = models[(i + 1) % models.length];
void applySettings({ ...settings, model: next });
},
},
{
cmd: "/approval",
desc: "Cycle approval mode",
run: () => {
const order: Settings["approval"][] = ["ask", "auto", "yolo"];
const i = order.indexOf(settings.approval);
void applySettings({
...settings,
approval: order[(i + 1) % order.length],
});
},
},
{
cmd: "/plan",
desc: "Toggle plan panel",
run: () => setView((v) => (v === "plan" ? "chat" : "plan")),
},
{
cmd: "/bash",
desc: "Toggle bash panel",
run: () => setView((v) => (v === "bash" ? "chat" : "bash")),
},
{
cmd: "/compact",
desc: "Compact session context",
run: async () => {
const msg = await compactSession();
const hist = activeId ? await openSession(activeId) : [];
setItems([
...hist,
{
kind: "assistant",
id: `c${Date.now()}`,
text: msg,
ts: Date.now(),
},
]);
setTodo(await getTodo());
},
},
{
cmd: "/todo",
desc: "Refresh todo checklist",
run: async () => {
const t = await getTodo();
setTodo(t);
setView("plan");
},
},
{
cmd: "/provider",
desc: "Cycle provider",
run: async () => {
const list =
providers.length > 0 ? providers : await listProviders();
if (list.length === 0) return;
const i = list.findIndex((p) => p.name === settings.provider);
const next = list[(i + 1) % list.length];
await useProvider(next.name, next.model);
setProviders(await listProviders());
setSettingsState(await getSettings());
setStatus(`provider · ${next.name}`);
},
},
{
cmd: "/clear",
desc: "Clear pending attachments",
run: async () => {
await clearPending();
await refreshPending();
},
},
{
cmd: "/rename",
desc: "Rename current session",
run: () => {
if (!activeId) return;
const cur = sessions.find((s) => s.id === activeId)?.title ?? "";
const title = window.prompt("Rename session", cur);
if (title?.trim()) {
void renameSession(activeId, title.trim()).then(refreshSessions);
}
},
},
{
cmd: "/export",
desc: "Export transcript as Markdown",
run: async () => {
const md = await exportSession();
await navigator.clipboard?.writeText(md).catch(() => undefined);
setItems((prev) => [
...prev,
{
kind: "assistant",
id: `e${Date.now()}`,
text: "Exported Markdown copied to clipboard.",
ts: Date.now(),
},
]);
},
},
{
cmd: "/settings",
desc: "Open settings",
run: () => setShowSettings(true),
},
],
[
activeId,
applySettings,
models,
providers,
refreshPending,
refreshSessions,
sessions,
settings,
],
);
const active = sessions.find((s) => s.id === activeId);
const sessionTitle = displaySessionTitle(active?.title, active?.id);
const wsLabel =
settings.workspace === "." || !settings.workspace
? "Choose workspace"
: settings.workspace.split(/[/\\]/).filter(Boolean).pop() ||
settings.workspace;
const inputBar = consent ? (
<ApprovalCard
req={consent}
onAnswer={(a) => {
void answerConsent(consent.id, a);
setConsent(null);
}}
/>
) : question ? (
<QuestionCard
req={question}
onAnswer={(a) => {
void answerQuestion(question.id, a);
setQuestion(null);
}}
onSkip={() => {
void answerQuestion(question.id, "");
setQuestion(null);
}}
/>
) : (
<Composer
value={draft}
busy={busy}
settings={settings}
models={models}
attachments={attachments}
usage={usage}
slashCommands={slashCommands}
fileSuggestions={fileSuggestions}
view={view}
hero={hero}
dragging={dragging}
sendBlocked={sendBlocked}
sendBlockedReason={sendBlockedReason}
onChange={setDraft}
onSend={() => void onSend()}
onStop={() => void cancelTurn()}
onSettingsPatch={(patch) => {
void applySettings({ ...settings, ...patch });
}}
onAttach={() => void onAttach()}
onRemoveAttach={(id) => {
void removePending(id).then(refreshPending);
}}
onQueryFiles={(q) => {
void listWorkspaceFiles(q).then(setFileSuggestions);
}}
onInsert={() => undefined}
onView={setView}
/>
);
/** DSH ConversationContent composerBar */
const composerBar = (
<div
className={`${conv.composerStack}${hero ? ` ${conv.composerHero}` : ""}`}
>
{hero ? <Hero mood={agentMood} /> : null}
{hero ? (
<div className={conv.heroWorkspaceRow}>
<WorkspaceChip
workspace={settings.workspace}
onPick={() => void onPickWorkspace()}
/>
<span className="hermit-engine-chip" data-mode={engine}>
{engine}
</span>
</div>
) : null}
{inputBar}
</div>
);
return (
<div
className={`${frame.frame} app-frame-fill`}
style={{
gridTemplateColumns: `${SIDEBAR_WIDTH}px minmax(0, 1fr) 0px`,
}}
data-rightbar-collapsed=""
data-font={settings.font_size || "m"}
onDragEnter={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const files = [...e.dataTransfer.files];
void (async () => {
for (const f of files) {
const path = (f as File & { path?: string }).path;
if (path) await attachFile(path);
}
await refreshPending();
})();
}}
>
<div className={frame.sidebarCol}>
<Rail
sessions={sessions}
activeId={activeId}
workspace={settings.workspace}
query={query}
engine={engine}
onQuery={setQuery}
onNew={() => void onNew()}
onOpen={(id) => void open(id)}
onRename={(id, title) => {
void renameSession(id, title).then(refreshSessions);
}}
onDelete={(id) => {
void (async () => {
await deleteSession(id);
await refreshSessions();
if (activeId === id) {
setActiveId(null);
setItems([]);
}
})();
}}
onPin={(id) => {
void pinSession(id).then(refreshSessions);
}}
onSetFolder={(id, folder) => {
void setSessionFolder(id, folder).then(refreshSessions);
}}
onSettings={() => setShowSettings(true)}
onPickWorkspace={() => void onPickWorkspace()}
/>
</div>
<div className={frame.centerCol}>
<div className={frame.mainPanel}>
<div
ref={convColRef}
className={`${conv.root} hermit-conversation-body`}
data-phase={hero ? "hero" : "active"}
>
{!hero ? (
<SessionHeader
workspaceLabel={wsLabel}
folder={active?.folder}
title={sessionTitle}
mood={agentMood}
status={status && status !== engine ? status : undefined}
engine={engine}
/>
) : null}
<div className={conv.body}>
<div className={conv.scrollBody} data-conversation-scroll="">
{!hero ? (
<div className={conv.viewArea}>
{view === "plan" ? (
<PlanPanel
text={todo}
planMode={!!settings.plan_mode}
onClose={() => setView("chat")}
onTogglePlanMode={() =>
void applySettings({
...settings,
plan_mode: !settings.plan_mode,
})
}
/>
) : view === "bash" ? (
<BashPanel
runs={bashRuns}
onClose={() => setView("chat")}
/>
) : (
<Transcript items={items} busy={busy} />
)}
</div>
) : null}
<div className={conv.composerSeat} data-composer-seat="">
{composerBar}
</div>
</div>
</div>
</div>
</div>
</div>
{/* rightbar fixed closed — 0 track */}
<div className={frame.rightbarCol} aria-hidden />
{showSettings ? (
<SettingsPanel
settings={settings}
providers={providers}
engine={engine}
onSave={(s) => {
void applySettings(s);
}}
onUseProvider={(provider, model, key) => {
void useProvider(provider, model, key).then(async () => {
setProviders(await listProviders());
setSettingsState(await getSettings());
});
}}
onClose={() => setShowSettings(false)}
/>
) : null}
</div>
);
}
@@ -0,0 +1,84 @@
import { useEffect, useRef } from "react";
import type { ConsentRequest } from "@/lib/bridge";
import appr from "@/dsh/approval/ApprovalPanel.module.css";
type Answer = "deny" | "once" | "always_tool" | "always_prefix";
type Props = {
req: ConsentRequest;
onAnswer: (a: Answer) => void;
};
export function ApprovalCard({ req, onAnswer }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
rootRef.current?.focus();
}, [req.id]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.isComposing || e.keyCode === 229) return;
if (e.key === "Enter") {
e.preventDefault();
onAnswer("once");
} else if (e.key === "Escape") {
e.preventDefault();
onAnswer("deny");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onAnswer, req.id]);
return (
<div className={appr.root}>
<div
className={appr.card}
ref={rootRef}
tabIndex={-1}
role="group"
aria-label={`Allow ${req.tool}?`}
>
<div className={appr.strip}>Waiting for approval</div>
<div className={appr.body}>
<div className={appr.headline}>Allow {req.tool}?</div>
<pre className={appr.command}>{req.detail}</pre>
{req.preview ? <pre className={appr.command}>{req.preview}</pre> : null}
</div>
<div className={appr.actionRow}>
<button
type="button"
className={`${appr.reject || ""} hermit-toolbar-btn`}
onClick={() => onAnswer("deny")}
>
Reject
</button>
<button
type="button"
className="hermit-toolbar-btn"
onClick={() => onAnswer("always_prefix")}
>
Always prefix
</button>
<button
type="button"
className="hermit-toolbar-btn"
onClick={() => onAnswer("always_tool")}
>
Always tool
</button>
<button
type="button"
className="hermit-send"
style={{ width: "auto", padding: "0 14px", borderRadius: 8 }}
onClick={() => onAnswer("once")}
>
Allow once
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import type { BashRun } from "@/lib/bridge";
type Filter = "recent" | "running" | "done" | "all";
type Props = {
runs: BashRun[];
onClose: () => void;
};
function fmtDur(r: BashRun): string {
const end = r.ended_at ?? Date.now();
const ms = Math.max(0, end - r.started_at);
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Math.round(ms / 1000)}秒`;
return `${Math.floor(ms / 60_000)}分${Math.round((ms % 60_000) / 1000)}秒`;
}
export function BashPanel({ runs, onClose }: Props) {
const [filter, setFilter] = useState<Filter>("recent");
const [openId, setOpenId] = useState<string | null>(null);
const list = useMemo(() => {
const sorted = [...runs].sort((a, b) => b.started_at - a.started_at);
if (filter === "all") return sorted;
if (filter === "running") return sorted.filter((r) => r.status === "running");
if (filter === "done")
return sorted.filter((r) => r.status !== "running");
return sorted.slice(0, 20);
}, [runs, filter]);
const running = runs.filter((r) => r.status === "running").length;
return (
<div className="work-panel bash-panel">
<div className="work-panel-head">
<strong>后台 Bash · {running} 运行中</strong>
<button type="button" onClick={onClose}>
关闭
</button>
</div>
<div className="bash-filters">
{(
[
["recent", "最近"],
["running", "进行中"],
["done", "已完成"],
["all", "全部"],
] as const
).map(([k, label]) => (
<button
key={k}
type="button"
className={filter === k ? "active" : ""}
onClick={() => setFilter(k)}
>
{label}
</button>
))}
</div>
{list.length === 0 ? (
<div className="work-empty">还没有 shell 工具记录。</div>
) : (
<ul className="bash-list">
{list.map((r) => (
<li key={r.id} className={`bash-row ${r.status}`}>
<button
type="button"
className="bash-row-main"
onClick={() => setOpenId(openId === r.id ? null : r.id)}
>
<span className="bash-status" aria-hidden>
{r.status === "ok"
? "✓"
: r.status === "running"
? "…"
: "✕"}
</span>
<code className="bash-cmd">$ {r.command}</code>
<span className="bash-dur">{fmtDur(r)}</span>
<span className="bash-chev">{openId === r.id ? "▾" : "▸"}</span>
</button>
{openId === r.id && r.output ? (
<pre className="bash-out">{r.output}</pre>
) : null}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,440 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AttachmentInfo, Settings, UsageInfo } from "@/lib/bridge";
import {
IconChevronDown,
IconPlus,
IconSend,
IconSparkle,
IconStop,
} from "@/components/Icons";
import bar from "@/dsh/conversation/InputBar.module.css";
const APPROVALS: {
value: Settings["approval"];
label: string;
desc: string;
}[] = [
{
value: "ask",
label: "始终询问",
desc: "只读自动;其余操作需确认",
},
{
value: "auto",
label: "必要时询问",
desc: "常规修改/命令自动;高风险再问",
},
{
value: "yolo",
label: "完全自动",
desc: "不打断;全部自行完成",
},
];
const EFFORTS: { value: string; label: string }[] = [
{ value: "", label: "Off" },
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
{ value: "max", label: "Max" },
];
export type SlashCommand = {
cmd: string;
desc: string;
run: () => void | Promise<void>;
};
export type MainView = "chat" | "plan" | "bash";
type Props = {
value: string;
busy: boolean;
settings: Settings;
models: string[];
attachments: AttachmentInfo[];
usage: UsageInfo;
slashCommands: SlashCommand[];
fileSuggestions: string[];
view: MainView;
hero?: boolean;
sendBlocked?: boolean;
sendBlockedReason?: string;
onChange: (v: string) => void;
onSend: () => void;
onStop: () => void;
onSettingsPatch: (patch: Partial<Settings>) => void;
onAttach: () => void;
onRemoveAttach: (id: string) => void;
onQueryFiles: (q: string) => void;
onInsert: (snippet: string) => void;
onView: (v: MainView) => void;
dragging?: boolean;
};
export function Composer({
value,
busy,
settings,
models,
attachments,
usage,
slashCommands,
fileSuggestions,
view: _view,
hero,
sendBlocked,
sendBlockedReason,
onChange,
onSend,
onStop,
onSettingsPatch,
onAttach,
onRemoveAttach,
onQueryFiles,
onInsert,
onView: _onView,
dragging,
}: Props) {
void _view;
void _onView;
const isHero = !!hero;
const ref = useRef<HTMLTextAreaElement>(null);
const [menu, setMenu] = useState<"model" | "approval" | null>(null);
const [palIdx, setPalIdx] = useState(0);
const slash = useMemo(() => {
const m = value.match(/(?:^|\n)\/([^\s]*)$/);
if (!m) return null;
const q = m[1].toLowerCase();
return slashCommands.filter(
(c) => c.cmd.slice(1).startsWith(q) || c.cmd.includes(q),
);
}, [value, slashCommands]);
const atQuery = useMemo(() => {
const m = value.match(/@([^\s]*)$/);
return m ? m[1] : null;
}, [value]);
useEffect(() => {
if (atQuery !== null) onQueryFiles(atQuery);
}, [atQuery, onQueryFiles]);
useEffect(() => {
setPalIdx(0);
}, [slash, atQuery, fileSuggestions]);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
const floor = isHero ? 52 : 36;
const next = Math.min(220, Math.max(floor, el.scrollHeight + 2));
el.style.height = `${next}px`;
}, [value, isHero]);
const replaceTrigger = (re: RegExp, insert: string) => {
const next = value.replace(re, insert);
onChange(next);
onInsert(insert);
};
const approvalLabel =
APPROVALS.find((a) => a.value === settings.approval)?.label ??
settings.approval;
const effort = settings.reasoning_effort ?? "";
const effortLabel =
EFFORTS.find((e) => e.value === effort)?.label ?? (effort || "Off");
const blocked = !!sendBlocked && !busy;
const canSend =
!blocked && !busy && (!!value.trim() || attachments.length > 0);
return (
<div className={`${bar.root}${isHero ? ` ${bar.hero}` : ""}`}>
{sendBlockedReason ? (
<div className={bar.notice}>{sendBlockedReason}</div>
) : null}
<div className={bar.card}>
{dragging ? (
<div className="hermit-drop-overlay">Drop files to attach</div>
) : null}
<div className={bar.overlayAnchor}>
{slash && slash.length > 0 ? (
<div className="hermit-palette" role="listbox">
{slash.map((c, i) => (
<button
key={c.cmd}
type="button"
className={i === palIdx ? "active" : ""}
onMouseDown={(e) => {
e.preventDefault();
void c.run();
onChange(
value.replace(/(?:^|\n)\/[^\s]*$/, "").trimStart(),
);
}}
>
<span className="pal-cmd">{c.cmd}</span>
<span className="pal-desc">{c.desc}</span>
</button>
))}
</div>
) : null}
{atQuery !== null && fileSuggestions.length > 0 ? (
<div className="hermit-palette" role="listbox">
{fileSuggestions.map((p, i) => (
<button
key={p}
type="button"
className={i === palIdx ? "active" : ""}
onMouseDown={(e) => {
e.preventDefault();
replaceTrigger(/@[^\s]*$/, `\`${p}\` `);
}}
>
<span className="pal-cmd">{p}</span>
<span className="pal-desc">Insert path</span>
</button>
))}
</div>
) : null}
</div>
{attachments.length > 0 ? (
<div className="hermit-attach-rail">
{attachments.map((a) => (
<div key={a.id} className="hermit-attach-chip" title={a.path}>
<span>{a.name}</span>
<button
type="button"
aria-label="Remove"
onClick={() => onRemoveAttach(a.id)}
>
×
</button>
</div>
))}
</div>
) : null}
<div className={bar.scroll}>
<textarea
ref={ref}
className="hermit-textarea"
rows={2}
placeholder={
blocked
? "Engine required to send…"
: isHero
? "Ask Hermit to build, fix, or explore…"
: "Describe a task, / commands, @ files…"
}
value={value}
disabled={blocked}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
const items =
slash && slash.length > 0
? slash
: atQuery !== null
? fileSuggestions
: null;
if (items && items.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPalIdx((i) => (i + 1) % items.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setPalIdx((i) => (i - 1 + items.length) % items.length);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
if (slash && slash.length > 0) {
const c = slash[palIdx];
void c.run();
onChange(
value.replace(/(?:^|\n)\/[^\s]*$/, "").trimStart(),
);
} else if (atQuery !== null) {
const p = fileSuggestions[palIdx];
if (p) replaceTrigger(/@[^\s]*$/, `\`${p}\` `);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
onChange(
value
.replace(/(?:^|\n)\/[^\s]*$/, "")
.replace(/@[^\s]*$/, ""),
);
return;
}
}
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
if (canSend) onSend();
}
}}
/>
</div>
<div className={bar.row}>
<div className={bar.tools}>
<button
type="button"
className={bar.add}
title="Attach"
onClick={onAttach}
disabled={blocked}
>
<IconPlus size={14} />
</button>
<div className={bar.modes}>
<div style={{ position: "relative" }}>
<button
type="button"
className="hermit-toolbar-btn"
disabled={blocked}
onClick={() =>
setMenu(menu === "approval" ? null : "approval")
}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 99,
background: "var(--dsw-alias-state-business-primary)",
}}
/>
{approvalLabel}
<IconChevronDown size={12} />
</button>
{menu === "approval" ? (
<div className="hermit-chip-menu">
<div className="menu-section">Autonomy</div>
{APPROVALS.map((a) => (
<button
key={a.value}
type="button"
className={
a.value === settings.approval ? "active" : ""
}
onClick={() => {
onSettingsPatch({ approval: a.value });
setMenu(null);
}}
>
<strong>{a.label}</strong>
<span className="menu-desc">{a.desc}</span>
</button>
))}
</div>
) : null}
</div>
<button
type="button"
className="hermit-toolbar-btn"
disabled={blocked}
onClick={() =>
onSettingsPatch({ plan_mode: !settings.plan_mode })
}
>
Plan{settings.plan_mode ? " · on" : ""}
</button>
</div>
</div>
<div className={bar.trailing}>
<div style={{ position: "relative" }}>
<button
type="button"
className="hermit-toolbar-btn"
disabled={blocked}
onClick={() => setMenu(menu === "model" ? null : "model")}
>
<IconSparkle size={12} />
<strong>{settings.model}</strong>
<span>· {effortLabel}</span>
<IconChevronDown size={12} />
</button>
{menu === "model" ? (
<div className="hermit-chip-menu">
<div className="menu-section">Models</div>
{models.map((m) => (
<button
key={m}
type="button"
className={m === settings.model ? "active" : ""}
onClick={() => onSettingsPatch({ model: m })}
>
{m}
</button>
))}
<div className="menu-section">Thinking</div>
<div className="hermit-effort-row">
{EFFORTS.map((e) => (
<button
key={e.label}
type="button"
className={
(settings.reasoning_effort ?? "") === e.value
? "active"
: ""
}
onClick={() => {
onSettingsPatch({ reasoning_effort: e.value });
setMenu(null);
}}
>
{e.label}
</button>
))}
</div>
</div>
) : null}
</div>
<span
className="hermit-toolbar-btn"
title="Context usage"
style={{ cursor: "default" }}
>
{usage.pct > 0 ? `${Math.round(usage.pct)}%` : "ctx"}
</span>
{busy ? (
<button type="button" className="hermit-send stop" onClick={onStop}>
<IconStop size={12} />
Stop
</button>
) : (
<button
type="button"
className={bar.primary}
disabled={!canSend}
onClick={onSend}
aria-label="Send"
>
<IconSend size={16} />
</button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { HermitMark, type HermitMood } from "@/components/Icons";
import hero from "@/dsh/conversation/HeroShell.module.css";
type Props = {
mood?: HermitMood;
};
/**
* DSH EmptyHero parity: headline only (mark 34 + title + preview badge).
* Workspace chip + composer live on InputBar / ConversationRoot, not here.
*/
export function Hero({ mood = "idle" }: Props) {
return (
<div className={hero.root}>
<div className={hero.stack}>
<div className={hero.headline}>
<span className={hero.fishHitbox}>
<HermitMark size={34} mood={mood} className={`${hero.fish} hermit-hero-mark`} />
</span>
<div className={hero.titleGroup}>
<span>Meet Hermit</span>
<span className={hero.previewBadge}>Preview</span>
</div>
</div>
<div className={hero.body} />
</div>
</div>
);
}
@@ -0,0 +1,228 @@
import type { ReactNode } from "react";
import idleShell from "@/assets/hermit/idle-shell.svg?raw";
import thinkingShell from "@/assets/hermit/thinking-shell.svg?raw";
import codingShell from "@/assets/hermit/coding-shell.svg?raw";
import successShell from "@/assets/hermit/success-shell.svg?raw";
import errorShell from "@/assets/hermit/error-shell.svg?raw";
type IconProps = {
size?: number;
className?: string;
title?: string;
};
export type HermitMood = "idle" | "thinking" | "coding" | "success" | "error";
const MOOD_SVG: Record<HermitMood, string> = {
idle: idleShell,
thinking: thinkingShell,
coding: codingShell,
success: successShell,
error: errorShell,
};
function Svg({
size = 16,
className,
title,
children,
}: IconProps & { children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden={title ? undefined : true}
role={title ? "img" : undefined}
>
{title ? <title>{title}</title> : null}
{children}
</svg>
);
}
/** Designed agent-motion hermit crab (shell + body + eyes). */
export function HermitMark({
size = 24,
className,
mood = "idle",
}: IconProps & { mood?: HermitMood }) {
const h = Math.round(size * (80 / 96));
return (
<span
className={`hermit-mark${className ? ` ${className}` : ""}`}
style={{ width: size, height: h, display: "inline-flex" }}
aria-hidden
dangerouslySetInnerHTML={{ __html: scaleSvg(MOOD_SVG[mood], size, h) }}
/>
);
}
function scaleSvg(raw: string, w: number, h: number): string {
// WebKitGTK + NVIDIA: infinite CSS animations inside injected SVGs freeze the
// compositor ("没有响应"). Strip <style> so marks stay static glyphs.
const stripped = raw.replace(/<style[\s\S]*?<\/style>/gi, "");
return stripped.replace(
/<svg\b([^>]*)>/,
(_m, attrs: string) => {
let next = String(attrs)
.replace(/\swidth="[^"]*"/g, "")
.replace(/\sheight="[^"]*"/g, "");
return `<svg${next} width="${w}" height="${h}">`;
},
);
}
export function IconPlus(p: IconProps) {
return (
<Svg {...p}>
<path
d="M8 3.2v9.6M3.2 8h9.6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</Svg>
);
}
export function IconSearch(p: IconProps) {
return (
<Svg {...p}>
<circle cx="7" cy="7" r="4.2" stroke="currentColor" strokeWidth="1.4" />
<path
d="M10.2 10.2L13.2 13.2"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</Svg>
);
}
export function IconFolder(p: IconProps) {
return (
<Svg {...p}>
<path
d="M2.5 5.5h4l1.2 1.5H13.5a1 1 0 011 1v5a1 1 0 01-1 1h-11a1 1 0 01-1-1v-6.5a1 1 0 011-1z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconChevronDown(p: IconProps) {
return (
<Svg {...p}>
<path
d="M4 6.5L8 10.5L12 6.5"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconSend(p: IconProps) {
return (
<Svg {...p}>
<path
d="M8 12.5V3.5M8 3.5L4.5 7M8 3.5L11.5 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconGear(p: IconProps) {
return (
<Svg {...p}>
<circle cx="8" cy="8" r="2.2" stroke="currentColor" strokeWidth="1.3" />
<path
d="M8 2.2v1.4M8 12.4v1.4M2.2 8h1.4M12.4 8h1.4M3.9 3.9l1 1M11.1 11.1l1 1M12.1 3.9l-1 1M4.9 11.1l-1 1"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</Svg>
);
}
export function IconPencil(p: IconProps) {
return (
<Svg {...p}>
<path
d="M10.5 2.8l2.7 2.7L5.8 13H3.1v-2.7L10.5 2.8z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconTrash(p: IconProps) {
return (
<Svg {...p}>
<path
d="M3.5 5h9M6 5V3.5h4V5M5 5l.5 8h5l.5-8"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconPin(p: IconProps) {
return (
<Svg {...p}>
<path
d="M9.8 2.8l3.4 3.4-2.2.6-2.8 2.8-.8 2.8-2.8-.8 2.8-2.8.6-2.2zM6.2 9.8L3.5 12.5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}
export function IconStop(p: IconProps) {
return (
<Svg {...p}>
<rect
x="4"
y="4"
width="8"
height="8"
rx="1.2"
fill="currentColor"
/>
</Svg>
);
}
export function IconSparkle(p: IconProps) {
return (
<Svg {...p}>
<path
d="M8 2.5l1.1 3.4L12.5 7 9.1 8.1 8 11.5 6.9 8.1 3.5 7l3.4-1.1L8 2.5z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</Svg>
);
}
@@ -0,0 +1,76 @@
export type PlanStep = {
text: string;
status: "pending" | "done" | "running";
};
export function parseTodo(text: string): PlanStep[] {
if (!text.trim()) return [];
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const m = line.match(/^[-*]\s*\[([ xX~])\]\s*(.*)$/);
if (m) {
const mark = m[1].toLowerCase();
return {
text: m[2] || line,
status: mark === "x" ? "done" : mark === "~" ? "running" : "pending",
} as PlanStep;
}
if (/^(done|✓|✔)/i.test(line)) {
return { text: line.replace(/^(done|✓|✔)\s*/i, ""), status: "done" as const };
}
if (/^(wip|running|\.\.\.)/i.test(line)) {
return {
text: line.replace(/^(wip|running|\.\.\.)\s*/i, ""),
status: "running" as const,
};
}
return { text: line.replace(/^[-*]\s*/, ""), status: "pending" as const };
});
}
type Props = {
text: string;
planMode: boolean;
onClose: () => void;
onTogglePlanMode: () => void;
};
export function PlanPanel({ text, planMode, onClose, onTogglePlanMode }: Props) {
const steps = parseTodo(text);
return (
<div className="work-panel plan-panel">
<div className="work-panel-head">
<strong>计划</strong>
<div className="work-panel-actions">
<button
type="button"
className={planMode ? "active" : ""}
onClick={onTogglePlanMode}
>
Plan mode {planMode ? "on" : "off"}
</button>
<button type="button" onClick={onClose}>
关闭
</button>
</div>
</div>
{steps.length === 0 ? (
<div className="work-empty">暂无计划步骤。Agent 写入 todo 后会出现在这里。</div>
) : (
<ol className="plan-steps">
{steps.map((s, i) => (
<li key={`${i}-${s.text}`} className={`plan-step ${s.status}`}>
<span className="plan-mark" aria-hidden>
{s.status === "done" ? "✓" : s.status === "running" ? "…" : "○"}
</span>
<span className="plan-text">{s.text}</span>
</li>
))}
</ol>
)}
</div>
);
}
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from "react";
import type { QuestionRequest } from "@/lib/bridge";
import appr from "@/dsh/approval/ApprovalPanel.module.css";
type Props = {
req: QuestionRequest;
onAnswer: (answer: string) => void;
onSkip: () => void;
};
export function QuestionCard({ req, onAnswer, onSkip }: Props) {
const [text, setText] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
rootRef.current?.focus();
}, [req.id]);
return (
<div className={appr.root}>
<div
className={appr.card}
ref={rootRef}
tabIndex={-1}
role="group"
aria-label="Question"
>
<div className={appr.strip}>Waiting for your answer</div>
<div className={appr.body}>
<div className={appr.headline}>{req.question}</div>
{req.options.length > 0 ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{req.options.map((opt) => (
<button
key={opt}
type="button"
className="hermit-toolbar-btn"
onClick={() => onAnswer(opt)}
>
{opt}
</button>
))}
</div>
) : null}
<input
className="question-input"
style={{
marginTop: 8,
height: 32,
width: "100%",
boxSizing: "border-box",
padding: "0 10px",
borderRadius: "var(--dsw-radius-sm)",
border: "0.5px solid var(--dsw-alias-border-l3)",
background: "var(--dsw-alias-bg-layer-1)",
color: "var(--dsw-alias-label-primary)",
font: "inherit",
}}
value={text}
placeholder={
req.options.length > 0
? "Or type a custom answer…"
: "Type your answer…"
}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && text.trim()) onAnswer(text.trim());
}}
/>
</div>
<div className={appr.actionRow}>
<button type="button" className="hermit-toolbar-btn" onClick={onSkip}>
Skip
</button>
<button
type="button"
className="hermit-send"
style={{ width: "auto", padding: "0 14px", borderRadius: 8 }}
disabled={!text.trim() && req.options.length === 0}
onClick={() => onAnswer(text.trim())}
>
Submit
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,300 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { SessionInfo } from "@/lib/bridge";
import {
HermitMark,
IconFolder,
IconGear,
IconPencil,
IconPin,
IconPlus,
IconSearch,
IconTrash,
} from "@/components/Icons";
import browser from "@/dsh/workspace/WorkspaceBrowser.module.css";
import rows from "@/dsh/workspace/Rows.module.css";
import sidebar from "@/dsh/workspace/SidebarRoot.module.css";
type Props = {
sessions: SessionInfo[];
activeId: string | null;
workspace: string;
query: string;
engine: string;
onQuery: (q: string) => void;
onNew: () => void;
onOpen: (id: string) => void;
onRename: (id: string, title: string) => void;
onDelete: (id: string) => void;
onPin?: (id: string) => void;
onSetFolder?: (id: string, folder: string | null) => void;
onSettings: () => void;
onPickWorkspace: () => void;
};
function normalizeTs(ts: number): number {
if (ts > 0 && ts < 1e12) return ts * 1000;
return ts;
}
function relTime(ts: number): string {
const t = normalizeTs(ts);
const d = Date.now() - t;
if (d < 0 || Number.isNaN(d)) return "";
if (d < 60_000) return "just now";
if (d < 3600_000) return `${Math.floor(d / 60_000)}m`;
if (d < 86400_000) return `${Math.floor(d / 3600_000)}h`;
if (d < 7 * 86400_000) return `${Math.floor(d / 86400_000)}d`;
return new Date(t).toLocaleDateString();
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function sessionLabel(s: SessionInfo): string {
const t = (s.title || "").trim();
if (!t || UUID_RE.test(t) || t === s.id) return "New session";
return t;
}
export function Rail({
sessions,
activeId,
workspace,
query,
engine,
onQuery,
onNew,
onOpen,
onRename,
onDelete,
onPin,
onSetFolder,
onSettings,
onPickWorkspace,
}: Props) {
const [searchOpen, setSearchOpen] = useState(false);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const searchRef = useRef<HTMLInputElement>(null);
const filtered = sessions.filter(
(s) =>
!query ||
sessionLabel(s).toLowerCase().includes(query.toLowerCase()) ||
s.title.toLowerCase().includes(query.toLowerCase()),
);
const groups = useMemo(() => {
const map = new Map<string, SessionInfo[]>();
for (const s of filtered) {
const key = s.folder?.trim() || "";
const list = map.get(key) ?? [];
list.push(s);
map.set(key, list);
}
return [...map.entries()].sort(([a], [b]) => {
if (!a) return 1;
if (!b) return -1;
return a.localeCompare(b);
});
}, [filtered]);
const wsLabel =
workspace === "." || !workspace
? "workspace"
: workspace.split(/[/\\]/).filter(Boolean).pop() || workspace;
useEffect(() => {
if (searchOpen) searchRef.current?.focus();
}, [searchOpen]);
return (
<aside className={sidebar.root}>
<div className={sidebar.logoRow}>
<button type="button" className={sidebar.brand} onClick={onNew}>
<span className={sidebar.brandIdentity}>
<span className={sidebar.brandMark}>
<HermitMark size={22} />
</span>
<span className={sidebar.brandName}>
<span className={sidebar.fallbackBrandName}>Hermit</span>
</span>
</span>
</button>
<button
type="button"
className={sidebar.iconButton}
aria-label="Settings"
title={`engine · ${engine}`}
onClick={onSettings}
>
<IconGear size={16} />
</button>
</div>
<button type="button" className={sidebar.newSession} onClick={onNew}>
<span className={sidebar.newSessionContent}>
<IconPlus size={16} />
<span className={sidebar.newSessionLabel}>New session</span>
</span>
</button>
<div className={browser.root}>
<div className={browser.sectionHeader}>
<button
type="button"
className={browser.iconButton}
title={workspace}
onClick={onPickWorkspace}
>
<IconFolder size={16} />
</button>
<button
type="button"
className={browser.iconButton}
aria-label="Search"
onClick={() => setSearchOpen((v) => !v)}
>
<IconSearch size={16} />
</button>
</div>
{searchOpen ? (
<input
ref={searchRef}
className={browser.searchInput}
value={query}
placeholder="Search sessions"
onChange={(e) => onQuery(e.target.value)}
/>
) : null}
<button
type="button"
className={rows.projectRow}
title={workspace}
onClick={onPickWorkspace}
>
<span className={rows.slot}>
<IconFolder size={16} />
</span>
<span className={rows.title}>{wsLabel}</span>
<span className={rows.time}>{engine}</span>
</button>
<div className={browser.listArea}>
<div className={browser.list}>
{groups.map(([folder, list]) => {
const key = folder || "__ungrouped";
const isCollapsed = !!collapsed[key];
return (
<div key={key} className={browser.groupSection}>
{folder ? (
<button
type="button"
className={rows.projectRow}
onClick={() =>
setCollapsed((c) => ({ ...c, [key]: !c[key] }))
}
>
<span className={rows.title}>{folder}</span>
<span className={rows.time}>
{isCollapsed ? "▸" : "▾"}
</span>
</button>
) : null}
{!isCollapsed
? list.map((s) => {
const selected = s.id === activeId;
return (
<div
key={s.id}
className={`${rows.sessionRow}${selected ? ` ${rows.selected}` : ""}`}
onClick={() => onOpen(s.id)}
onKeyDown={(e) => {
if (e.key === "Enter") onOpen(s.id);
}}
role="button"
tabIndex={0}
>
<span className={rows.title}>{sessionLabel(s)}</span>
<span className={rows.rowResting}>
{s.pinned ? (
<span className={rows.pinIndicator}>📌</span>
) : null}
<span className={rows.time}>
{relTime(s.updated_at)}
</span>
</span>
<div
className={`${rows.rowActions} hermit-row-actions`}
>
{onPin ? (
<button
type="button"
className={browser.iconButton}
title="Pin"
onClick={(e) => {
e.stopPropagation();
onPin(s.id);
}}
>
<IconPin size={14} />
</button>
) : null}
<button
type="button"
className={browser.iconButton}
title="Rename"
onClick={(e) => {
e.stopPropagation();
const title = window.prompt(
"Rename",
sessionLabel(s),
);
if (title?.trim())
onRename(s.id, title.trim());
}}
>
<IconPencil size={14} />
</button>
{onSetFolder ? (
<button
type="button"
className={browser.iconButton}
title="Folder"
onClick={(e) => {
e.stopPropagation();
const next = window.prompt(
"Folder (empty to clear)",
s.folder ?? "",
);
if (next === null) return;
onSetFolder(s.id, next.trim() || null);
}}
>
<IconFolder size={14} />
</button>
) : null}
<button
type="button"
className={browser.iconButton}
title="Delete"
onClick={(e) => {
e.stopPropagation();
onDelete(s.id);
}}
>
<IconTrash size={14} />
</button>
</div>
</div>
);
})
: null}
</div>
);
})}
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,67 @@
import { HermitMark, type HermitMood } from "@/components/Icons";
import conv from "@/dsh/conversation/ConversationRoot.module.css";
type Props = {
workspaceLabel: string;
folder?: string | null;
title: string;
mood: HermitMood;
status?: string;
engine: string;
};
export function SessionHeader({
workspaceLabel,
folder,
title,
mood,
status,
engine,
}: Props) {
return (
<header className={conv.header}>
<div className={conv.titleRow}>
<HermitMark size={18} mood={mood} />
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
minWidth: 0,
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontSize: 14,
fontWeight: 500,
color: "var(--dsw-alias-label-primary)",
}}
>
{title}
</span>
<span className="hermit-engine-chip" data-mode={engine}>
{engine}
</span>
</div>
<div
style={{
fontSize: 12,
color: "var(--dsw-alias-label-tertiary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{workspaceLabel}
{folder ? ` / ${folder}` : ""}
{status ? ` · ${status}` : ""}
</div>
</div>
</div>
</header>
);
}
@@ -0,0 +1,242 @@
import { useState } from "react";
import type { ProviderInfo, Settings } from "@/lib/bridge";
type Tab = "general" | "model" | "agent" | "workspace" | "about";
type Props = {
settings: Settings;
providers: ProviderInfo[];
engine: string;
onSave: (s: Settings) => void;
onUseProvider: (provider: string, model: string, key?: string) => void;
onClose: () => void;
};
export function SettingsPanel({
settings,
providers,
engine,
onSave,
onUseProvider,
onClose,
}: Props) {
const [tab, setTab] = useState<Tab>("general");
const [draft, setDraft] = useState<Settings>({
reasoning_effort: "high",
font_size: "m",
language: "zh",
native_titlebar: false,
...settings,
});
const tabs: { id: Tab; label: string }[] = [
{ id: "general", label: "通用" },
{ id: "model", label: "模型" },
{ id: "agent", label: "智能体" },
{ id: "workspace", label: "工作区" },
{ id: "about", label: "关于" },
];
return (
<div className="settings-scrim" onClick={onClose}>
<div
className="settings-card"
style={{ width: "min(720px, calc(100% - 48px))", display: "flex", gap: 16 }}
onClick={(e) => e.stopPropagation()}
>
<div className="settings-nav">
<h3>设置</h3>
{tabs.map((t) => (
<button
key={t.id}
type="button"
className={tab === t.id ? "active" : ""}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
<div className="settings-body">
{tab === "general" ? (
<>
<label className="field">
<span>字号</span>
<div className="seg">
{(["s", "m", "l", "xl"] as const).map((f) => (
<button
key={f}
type="button"
className={draft.font_size === f ? "active" : ""}
onClick={() => setDraft({ ...draft, font_size: f })}
>
{f.toUpperCase()}
</button>
))}
</div>
</label>
<label className="field">
<span>语言</span>
<div className="seg">
<button
type="button"
className={draft.language === "zh" ? "active" : ""}
onClick={() => setDraft({ ...draft, language: "zh" })}
>
简体中文
</button>
<button
type="button"
className={draft.language === "en" ? "active" : ""}
onClick={() => setDraft({ ...draft, language: "en" })}
>
English
</button>
</div>
</label>
<label className="field field-row">
<span>使用系统标题栏</span>
<input
type="checkbox"
checked={!!draft.native_titlebar}
onChange={(e) =>
setDraft({ ...draft, native_titlebar: e.target.checked })
}
/>
</label>
</>
) : null}
{tab === "model" ? (
<>
<label className="field">
<span>Provider</span>
<select
value={draft.provider}
onChange={(e) => setDraft({ ...draft, provider: e.target.value })}
>
{providers.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
{p.has_key ? "" : " (no key)"}
</option>
))}
{!providers.find((p) => p.name === draft.provider) ? (
<option value={draft.provider}>{draft.provider}</option>
) : null}
</select>
</label>
<label className="field">
<span>Model</span>
<input
value={draft.model}
onChange={(e) => setDraft({ ...draft, model: e.target.value })}
/>
</label>
<label className="field">
<span>Thinking / effort</span>
<select
value={draft.reasoning_effort ?? ""}
onChange={(e) =>
setDraft({ ...draft, reasoning_effort: e.target.value })
}
>
<option value="">Off</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="max">Max</option>
</select>
</label>
<label className="field">
<span>API key</span>
<input
type="password"
value={draft.api_key ?? ""}
placeholder="sk-…"
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
/>
</label>
</>
) : null}
{tab === "agent" ? (
<>
<label className="field">
<span>Approval</span>
<select
value={draft.approval}
onChange={(e) =>
setDraft({
...draft,
approval: e.target.value as Settings["approval"],
})
}
>
<option value="ask">始终询问 (ask)</option>
<option value="auto">必要时询问 (auto)</option>
<option value="yolo">完全自动 (yolo)</option>
</select>
</label>
<label className="field field-row">
<span>Plan mode 默认</span>
<input
type="checkbox"
checked={!!draft.plan_mode}
onChange={(e) =>
setDraft({ ...draft, plan_mode: e.target.checked })
}
/>
</label>
</>
) : null}
{tab === "workspace" ? (
<label className="field">
<span>Workspace</span>
<input
value={draft.workspace}
onChange={(e) => setDraft({ ...draft, workspace: e.target.value })}
/>
</label>
) : null}
{tab === "about" ? (
<div className="about-block">
<p>
<strong>Hermit</strong> desktop 1.1.0
</p>
<p>
Engine: <code>{engine}</code>
</p>
<p className="muted">暗色色卡 · 本地 agent 工作台</p>
</div>
) : null}
<div className="modal-actions">
<button type="button" onClick={onClose}>
Cancel
</button>
{tab !== "about" ? (
<button
type="button"
className="primary"
onClick={() => {
onUseProvider(draft.provider, draft.model, draft.api_key);
onSave(draft);
onClose();
}}
>
Save
</button>
) : (
<button type="button" className="primary" onClick={onClose}>
Close
</button>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from "react";
import type { ChatItem } from "@/lib/bridge";
import { isShellTool } from "@/lib/bridge";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import chat from "@/dsh/chat/ChatView.module.css";
import msg from "@/dsh/chat/MessageItem.module.css";
import md from "@/dsh/chat/AssistantMarkdown.module.css";
import reason from "@/dsh/chat/ReasoningRow.module.css";
import tool from "@/dsh/chat/GenericCommandCard.module.css";
type Props = { items: ChatItem[]; busy: boolean };
function fmtTs(ts?: number): string {
if (!ts) return "";
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
export function Transcript({ items, busy }: Props) {
const scroller = useRef<HTMLDivElement>(null);
const [follow, setFollow] = useState(true);
const [showJump, setShowJump] = useState(false);
useEffect(() => {
if (!follow) return;
const el = scroller.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [items, busy, follow]);
return (
<div className={chat.frame}>
<div className={chat.root} data-chat-following-tail={follow || undefined}>
<div
className={chat.scroll}
ref={scroller}
onScroll={() => {
const el = scroller.current;
if (!el) return;
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 48;
setFollow(atBottom);
setShowJump(!atBottom);
}}
>
<div className={chat.column}>
{items.map((it) => {
if (it.kind === "user") {
return (
<div className={msg.userRow} key={it.id}>
<div className={msg.userStack}>
<div className="hermit-user-bubble">{it.text}</div>
{it.ts ? (
<div className={msg.referenceSummary}>{fmtTs(it.ts)}</div>
) : null}
</div>
</div>
);
}
if (it.kind === "assistant") {
return (
<div className={md.root} key={it.id}>
<div className={md.body}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{it.text}
</ReactMarkdown>
{busy && items[items.length - 1]?.id === it.id ? (
<span className="hermit-cursor" aria-hidden />
) : null}
</div>
<div className={md.actions}>
{it.ts ? <span>{fmtTs(it.ts)}</span> : null}
<button
type="button"
className="hermit-toolbar-btn"
onClick={() =>
void navigator.clipboard?.writeText(it.text)
}
>
复制
</button>
</div>
</div>
);
}
if (it.kind === "thought") {
return (
<details
className={reason.root}
key={it.id}
open={busy}
data-expanded={busy || undefined}
data-preview
>
<summary className={reason.row}>
<span className={reason.leading}>✦</span>
<span className={reason.title}>Thought</span>
<span className={reason.separator} />
<span className={reason.summary}>
<span className={reason.summaryText}>
{firstLine(it.text)}
</span>
</span>
</summary>
<div className={reason.thinkBody}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{it.text}
</pre>
</div>
</details>
);
}
return <ToolCard key={it.id} item={it} />;
})}
</div>
</div>
</div>
{showJump ? (
<button
type="button"
className="hermit-toolbar-btn"
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
background: "var(--dsw-alias-bg-layer-2)",
boxShadow: "var(--dsw-elevation-soft)",
}}
onClick={() => {
setFollow(true);
setShowJump(false);
const el = scroller.current;
if (el) el.scrollTop = el.scrollHeight;
}}
>
Jump to latest
</button>
) : null}
</div>
);
}
function firstLine(s: string) {
return s.split("\n").find((l) => l.trim())?.slice(0, 80) ?? "";
}
function ToolCard({ item }: { item: Extract<ChatItem, { kind: "tool" }> }) {
const shell = isShellTool(item.name);
return (
<details
className={tool.root}
open={item.status === "running"}
data-error={
item.status === "error" || item.status === "denied" || undefined
}
>
<summary
style={{ display: "flex", alignItems: "center", cursor: "pointer" }}
>
<span className={tool.leading}>{shell ? "$" : "⚙"}</span>
<span className={tool.title}>{item.name}</span>
<span className={tool.separator} />
<span
className={tool.summary}
data-error={
item.status === "error" || item.status === "denied" || undefined
}
>
{item.detail || item.status}
</span>
</summary>
{(item.output || item.detail) && (
<pre
className={tool.body}
data-error={
item.status === "error" || item.status === "denied" || undefined
}
>
{item.output || item.detail}
</pre>
)}
</details>
);
}
@@ -0,0 +1,35 @@
import {
IconChevronDown,
IconFolder,
} from "@/components/Icons";
import hero from "@/dsh/conversation/HeroShell.module.css";
type Props = {
workspace: string;
onPick: () => void;
};
/** DSH EmptyHero WorkspaceChip — folder + label + chevron. */
export function WorkspaceChip({ workspace, onPick }: Props) {
const has =
workspace !== "." && workspace !== "" && workspace.trim().length > 0;
const label = has
? workspace.split(/[/\\]/).filter(Boolean).pop() || workspace
: undefined;
return (
<button
type="button"
className={hero.workspace}
title={has ? workspace : undefined}
aria-label="Choose workspace"
onClick={onPick}
>
<IconFolder size={16} className={hero.folder} />
<span className={hero.workspaceLabel}>
{label ?? "Choose workspace"}
</span>
<IconChevronDown size={12} className={hero.chevron} />
</button>
);
}
@@ -0,0 +1,92 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px calc(var(--dsh-composer-side-clearance) + 16px) 12px;
}
.card {
overflow: hidden;
width: 100%;
max-width: var(--dsh-chat-content-width);
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: var(--dsw-radius-xl);
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.strip {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
}
.body {
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
padding: 12px 16px 0;
}
.headline {
color: var(--dsw-alias-label-primary);
font-size: 15px;
font-weight: 500;
line-height: 24px;
}
.command {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 13px;
line-height: 20px;
word-break: break-all;
}
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 16px;
}
@media (hover: hover) {
.reject:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
--dsh-button-outline-color: transparent;
}
}
.subject {
display: flex;
align-items: center;
gap: 12px;
color: var(--dsw-alias-label-primary);
font-size: 16px;
}
.subject img {
flex-shrink: 0;
object-fit: contain;
}
.description {
margin: 0;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
}
.withSubject .body { text-align: left; }
.withSubject .actionRow { flex-wrap: wrap; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,78 @@
/* Assistant flow body: full-width narration (figma 16/28 scaled 0.875 → 14/24
with the markdown token ladder), block gap 16. The Settings font-size
preference rides the body-published axis: size follows
--dsh-content-font-size and the line height keeps the 10px leading via the
shared px delta. IconActions sit below the body with an explicit 16px top
margin (figma 43:32997) — separate from the body's internal gap so the
footer spacing stays fixed when the body is a single block. */
.root {
display: flex;
flex-direction: column;
font-size: var(--dsh-content-font-size, 14px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span
the whole transcript width instead of clipping at the 748px message
column, while the table content keeps starting at its normal x (the lead
padding compensates the negative margin) — deepsuite chat TableWrapper
parity, with 100cqw (the ChatView scroll container) standing in for its
JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero
when the transcript is narrower than the message column, so narrow
viewports keep the plain in-column scroll with no sideways shift.
Percentages resolve against the wrapper's containing block, so a table
indented inside a list still reaches the same transcript edges. */
.body :global(.md-table-wide) {
--dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2));
--dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%);
box-sizing: border-box;
width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare));
/* The base .tableScroll caps at the column (max-width: 100%); the breakout
is exactly the case that must exceed it. */
max-width: none;
margin-left: calc(-1 * var(--dsh-table-lead));
padding-left: var(--dsh-table-lead);
}
/* Only the painted table outranks Conversation's z-index 0 width handle. The
full-width scroll wrapper and its transparent alignment padding stay below,
so a fitting four-column table does not disable the surrounding gutter. */
.body :global(.md-table-wide) > table {
position: relative;
z-index: 1;
}
/* hidden="until-found" keeps a zero-height reasoning box in flex layout.
Cancel the one gap that box would otherwise leave before the visible reply;
visible Assistant blocks retain the ordinary 16px rhythm. */
.body > [data-turn-process-inline][hidden] {
margin-bottom: -16px;
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed
size like the small/code token variants — 11px is dense secondary text
that would fall to an illegible 9px at the 12px floor. */
.stopped {
align-self: flex-start;
padding: 0 6px;
border-radius: var(--dsw-radius-sm);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 18px;
}
/* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */
.actions {
margin-top: 16px;
/* Optical align with the 28px action hit targets. */
margin-left: -6px;
}
@@ -0,0 +1,409 @@
.frame {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
/* Floating controls query this frame outside the clipped transcript. */
container-type: inline-size;
}
.root {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1 1 auto;
overflow-x: visible;
overflow-y: clip;
}
.scroll {
flex: 1 1 auto;
min-height: 0;
/* Never expose horizontal scrollable overflow: the absolutely positioned
text-interaction surface adds it transiently, and the changing scroll range
makes the transcript jitter while the tail is pinned. */
overflow-x: clip;
overflow-y: auto;
/* Sides = composer clearance + 16px: on narrow viewports the transcript
stays exactly 32px narrower than the input card (the shared width rule). */
padding: 16px calc(var(--dsh-composer-side-clearance) + 16px);
/* Inline-size query container: wide markdown tables size their breakout
against this box's content width (100cqw in AssistantMarkdown's
md-table-wide rule) — the CSS stand-in for deepsuite chat's JS-measured
--dsl-virtual-list-width. */
container-type: inline-size;
}
.root[data-chat-following-tail] .scroll,
:global([data-conversation-scroll]):has(.root[data-chat-following-tail]) {
overflow-anchor: none;
}
:global([data-conversation-scroll]) .frame,
:global([data-conversation-scroll]) .root {
flex: 0 0 auto;
min-height: auto;
height: auto;
}
:global([data-conversation-scroll]) .scroll {
/* A host scrollport still owns vertical overflow; horizontal overflow is
clipped for the same reason as the self-scrolled case above. */
overflow-x: clip;
overflow-y: visible;
flex: 0 0 auto;
min-height: auto;
}
/* Message column: shared chat width (ConversationRoot --dsh-chat-content-width),
centered on the same axis as the input box (which caps at chat + 16px); the
scroller itself stays full-bleed. */
.column {
position: relative;
max-width: var(--dsh-chat-content-width);
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
}
/* The flow slot's display:contents anchor preserves layout but not selector ancestry.
`hidden="until-found"` retains a searchable zero-height box; hidden and empty
Seats do not contribute spacing. The older-history control precedes the anchor;
an ancestor :has() here also invalidates the history on unrelated descendant edits. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))
~ :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))),
.column > .older + :global([data-slot="conversation.chat.flow"])
> :not([hidden]):not(.textInteraction):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))) {
margin-top: var(--dsh-chat-flow-gap, 4px);
}
/* Process rows sit 4px apart; an Assistant response is separated from its
neighbours by 12px. */
.flowItem[data-chat-group-part="response"] {
--dsh-chat-flow-gap: 12px;
}
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-group-part="response"]:not([hidden]):not(:empty)
+ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) {
--dsh-chat-flow-gap: 12px;
}
/* Settled-flow identity boundary. It is neutral until it becomes the natural
measurement/mount unit for a virtualizer without changing the column gap. */
.flowItem {
min-width: 0;
}
/* Bottom room: sized by the viewport owner while rows above the reader close,
given back only as later content grows into it. */
.turnSpacer {
height: 0;
}
/* While a Turn folds, spacing changes share the closing rows' timing. */
.column[data-chat-motion] > :not([data-chat-motion]),
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
transition: margin-top 160ms cubic-bezier(0.4, 0, 0.2, 1);
}
.column[data-input-fold] > :not([data-chat-motion]),
.column[data-input-fold] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
transition: none;
}
/* A Turn's duration control renders empty until the Turn closes; its gap appears with it in the same
frame, so the layout below changes once instead of sliding after the control's own height. */
.column[data-chat-motion] > [data-chat-flow-kind="turn-process"],
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > [data-chat-flow-kind="turn-process"] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.column[data-chat-motion] > :not([data-chat-motion]),
.column[data-chat-motion] > :global([data-slot="conversation.chat.flow"]) > :not([data-chat-motion]) {
transition: none;
}
}
/* The Turn header has 16px clearance from its input and following content. A
running Turn's control renders empty until the Turn closes; an echo after it
is still an opening input of that Turn, so it keeps the input gap. */
.flowItem[data-chat-flow-kind="turn-process"],
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"]:not(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])),
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not([data-submission-echo], [data-pending-steering]):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) {
--dsh-chat-flow-gap: 16px;
}
/* Tail slot: the running status and a completed Turn's actions row both take
a 16px offset plus a 28px row. The steering renderer marks the first
steering row of each claim that Chat orders after the Turn's process
control; that row keeps the slot above it, the way a completed Turn's
actions row precedes the next user message, while rows claimed together
stay 4px apart and opening inputs keep ordinary spacing. The first waiting
echo sits directly below the running status, so admission swaps the two
without moving any bubble. Neighbour gap rules exclude the batch start so
this slot always applies. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > .flowItem:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch="start"]) {
--dsh-chat-flow-gap: calc(44px + var(--dsh-content-font-delta, 0px));
}
.running + [data-pending-steering] {
--dsh-chat-flow-gap: 0px;
}
/* A completed Turn's actions row owns the tail slot; the status can trail it
for one frame before the Session stops running, or between queued Turns. */
.flowItem[data-chat-flow-kind="turn-tail"] + .running {
display: none;
}
/* Steering rows keep their full layout from insertion. Only the status moves:
it enters its new slot below them from one slot higher, independent of how
many admitted rows it passes or how tall they are. The start therefore sits
below the status's previous position by the admitted rows' height; the
fade-in covers that difference, so this entrance approximates the move. */
@media (prefers-reduced-motion: no-preference) {
:where(.column > :global([data-slot="conversation.chat.flow"]) > .flowItem[data-chat-flow-kind="steering"]) + .running {
animation: running-enters-slot 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* An input fold runs the entrance at zero duration. The switch only changes
the duration of an entrance its structural change already created, as if
the entrance had it from its start: lifting the switch never replays an
entrance older than its duration, and setting it completes a running one.
The structural rule is wrapped in :where() so this rule outranks it. */
.column[data-input-fold] .running {
animation-duration: 0s;
}
}
@keyframes running-enters-slot {
from {
opacity: 0;
translate: 0 calc(-44px - var(--dsh-content-font-delta, 0px));
}
40% {
opacity: 1;
}
}
/* Empty Seats, including retained empty slot anchors, preserve Turn lookup
without contributing height or the sibling gap. */
.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) {
height: 0;
}
.callRow {
border-radius: var(--dsw-radius-sm);
}
/* Selection still sets data-selected for details linkage; no outline —
tool rows match Think chrome (no selected ring). */
.hint {
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.running {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
--dsh-chat-flow-gap: 12px;
--dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer);
color: var(--dsw-alias-label-deep-diving);
font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
}
/* After the Session stops running and before the Turn's actions row arrives, the status keeps its slot
unseen so the tail does not shrink for that interval. */
.running[data-holding] {
visibility: hidden;
opacity: 0;
}
/* The last visible row before the status must contain output, not an input.
Hidden and empty seats do not separate a user message from the status, and
the steering/Inbox echoes after the status are outside the count. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
[data-chat-group-key]
) ~ .running > .runningDivider {
opacity: 1;
}
/* Drawn inside the 16px offset above the status row, so showing or hiding it
never changes the tail slot's height. */
.runningDivider {
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 0.5px;
background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2));
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.runningDivider {
transition: opacity 160ms ease;
}
}
/* Same offset and height as the completed Turn's tail actions row. */
.runningContent {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 6px;
margin-top: 4px;
height: calc(28px + var(--dsh-content-font-delta, 0px));
}
.runningText {
min-width: 0;
font-variant-numeric: tabular-nums;
}
.runningIcon {
position: relative;
display: inline-flex;
flex: none;
width: calc(14px + var(--dsh-content-font-delta, 0px));
height: calc(14px + var(--dsh-content-font-delta, 0px));
/* The fixed footprint contains the animation's layout and paint. */
contain: strict;
overflow: hidden;
}
.runningWhaleAnimated {
display: none;
position: absolute;
inset: 0;
}
.runningWhaleStill {
display: initial;
}
@supports (mask-mode: alpha) and (mask-image: url('')) {
@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
.runningWhaleAnimated {
display: block;
background: currentColor;
mask: url('../assets/running-whale@2x.png') center / 100% 100% no-repeat;
mask-mode: alpha;
/* Keep animated-mask repaints on an icon-sized composited layer. */
will-change: transform;
}
.runningWhaleStill {
display: none;
}
}
}
.openError {
color: var(--dsw-alias-state-error-primary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.older {
display: flex;
justify-content: center;
}
.older button {
border: none;
border-radius: var(--dsw-radius-sm);
padding: 4px 12px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-interactive-bg-hover-solid);
cursor: pointer;
}
.older button:disabled {
cursor: default;
opacity: 0.6;
}
/* Floating controls sit outside the clipped transcript. A standalone Chat
anchors them to its frame; the shared host supplies the sticky scrollport. */
.toBottomSlot {
position: absolute;
right: 0;
bottom: 16px;
left: 0;
/* Above the sticky composer (z-index 7) so the control stays clickable and
visible over the input card. Exception: while an @/slash menu is open the
seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never
covered by this control. */
z-index: 8;
height: 0;
display: flex;
justify-content: flex-end;
padding-right: max(
calc(var(--dsh-composer-side-clearance) + 16px),
calc((100% - var(--dsh-chat-content-width)) / 2)
);
pointer-events: none;
}
:global([data-conversation-scroll]) .toBottomSlot {
position: sticky;
/* Clears the sticky composer stack (docks + input card + stats): the live
height rides --dsh-composer-height (ConversationRoot's seat observer) so
the control follows a growing textarea; the fallback covers the first
paint before the observer fires. */
bottom: calc(var(--dsh-composer-height, 152px) + 16px);
}
.toBottom {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
margin-top: -34px;
padding: 0;
border: 0;
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
border-radius: 100px;
corner-shape: round;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-button-floating-fill);
box-shadow: var(--dsw-elevation-panel);
cursor: pointer;
pointer-events: auto;
}
@media (hover: hover) {
.toBottom:hover {
background: var(--dsw-alias-button-floating-hover);
}
}
/* Host open-path refusal: same dialog family as the workspace folder error. */
.modalAction {
min-width: 72px;
}
/* Local surfaces do not contribute flex spacing or selected message text. */
.column > .textInteraction {
position: absolute;
/* Remain below the sticky composer while scroll placement catches up. */
z-index: 6;
visibility: hidden;
margin: 0;
width: max-content;
}
@@ -0,0 +1,49 @@
.root {
display: flex;
flex-direction: column;
}
.leading {
flex-shrink: 0;
}
.title {
font-weight: 400;
}
.separator {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.summary {
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
}
.summary[data-error],
.body[data-error] {
color: var(--dsw-alias-state-error-primary);
}
.body {
max-height: 260px;
margin: 4px 0 4px 4px;
padding: 12px 16px;
overflow: auto;
border: 0.5px solid var(--dsw-alias-border-l1);
border-radius: var(--dsw-radius-lg);
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-markdown-code-block-small);
white-space: pre-wrap;
}
@@ -0,0 +1,360 @@
/* User messages align their bubble and action row to the right. */
.userRow {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.userStack {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
min-width: 0;
/* The figma 525px cap held as a share of the width axis (525/748 ≈ 0.702):
resolves to the original 525px on the default column and widens with a
dragged or adaptive column. The 748px fallback covers mounts outside the
conversation column, where the axis variable is absent. */
max-width: min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%);
}
.referenceSummary {
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.contextRow {
padding: 2px 0;
}
/* Compaction marker: one 24px row with a context icon at rest and a
hover/focus disclosure for the summary body. */
.compactionRow {
/* The toggle's line, and the band a pinned toggle occupies. The button's
height and the offset that holds a summary's code banner below that band
are the same measurement, so both read it from here. */
--dsh-compaction-header-height: calc(24px + var(--dsh-content-font-delta, 0px));
padding: 2px 0;
}
.compactionButton {
display: flex;
align-items: center;
width: 100%;
height: var(--dsh-compaction-header-height);
min-width: 0;
padding: 0;
border: none;
border-radius: var(--dsw-radius-sm);
background: none;
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
font: inherit;
text-align: left;
}
/* The summary body flows uncapped with the page, so a long checkpoint summary
carries this toggle off the top of the viewport. Pin it to the conversation
scroll container's top while the row is open (the body is a sibling rendered
only when expanded); the base background masks the prose scrolling under it.
The square corners apply for as long as the row is open, pinned or not: the
fill is what masks the prose, and rounded corners would leave that prose visible
in the corners. z-index outranks the markdown code block's own sticky banner
(z-index 6 in CodeBlock.module.css); that banner itself stops below this
toggle (rule below), so a summary's fenced code block never puts a control
inside the toggle's band. */
.compactionRow:has(.compactionBody) .compactionButton {
position: sticky;
top: 0;
z-index: 7;
border-radius: 0;
background: var(--dsw-alias-bg-base);
}
/* A fenced code block in the summary pins its banner at the scrollport top,
where this pinned toggle already sits, and the toggle's band covers the
banner's Copy control. Stop the banner below the toggle instead, on the same
measurement the toggle's own height uses. This rule beats CodeBlock's
`.bannerWrap { top: 0 }` by specificity (0,2,0 against 0,1,0), not by
declaration order: the two sheets load in their own packages. */
.compactionBody :has(> [data-code-block-banner]) {
top: var(--dsh-compaction-header-height);
}
/* Pinned, the toggle needs an opaque hover fill: the default translucent hover
token below would let the scrolling prose show through the moment the pointer
lands on the toggle to collapse it. */
@media (hover: hover) {
.compactionRow:has(.compactionBody) .compactionButton:hover {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
}
.compactionButton:not(:disabled) {
cursor: pointer;
}
@media (hover: hover) {
.compactionButton:hover {
color: var(--dsw-alias-label-secondary);
}
.compactionButton:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.compactionLeading {
flex: none;
display: inline-grid;
place-items: center;
width: calc(16px + var(--dsh-content-font-delta, 0px));
height: calc(16px + var(--dsh-content-font-delta, 0px));
margin-right: 6px;
color: inherit;
}
/* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides
the svg attributes so the 14px glyphs follow the font-size delta. */
.compactionLeading svg {
width: calc(14px + var(--dsh-content-font-delta, 0px));
height: calc(14px + var(--dsh-content-font-delta, 0px));
}
.compactionContextIcon,
.compactionDisclosureIcon {
display: inline-flex;
grid-area: 1 / 1;
align-items: center;
justify-content: center;
}
.compactionDisclosureIcon {
opacity: 0;
}
.compactionButton:not(:disabled):focus-visible .compactionContextIcon {
opacity: 0;
}
@media (hover: hover) {
.compactionButton:not(:disabled):hover .compactionContextIcon {
opacity: 0;
}
}
.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
opacity: 1;
}
@media (hover: hover) {
.compactionButton:not(:disabled):hover .compactionDisclosureIcon {
opacity: 1;
}
}
.compactionTitle {
flex: none;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: inherit;
}
.compactionSep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.compactionSummary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: inherit;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
}
.compactionBody {
/* Indent under the title, which starts at leading (16 + delta) + gap 6. */
padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
}
.retryRow {
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
}
.retrySummary {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
padding: 2px 0;
gap: 7px;
border-radius: var(--dsw-radius-sm);
color: inherit;
cursor: pointer;
list-style: none;
user-select: none;
}
.retrySummary::-webkit-details-marker {
display: none;
}
.retrySummary::after {
flex: none;
width: 6px;
height: 6px;
margin-right: 2px;
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
content: '';
transform: rotate(-45deg);
transition: transform 120ms ease;
}
@media (hover: hover) {
.retrySummary:hover {
color: var(--dsw-alias-label-secondary);
}
}
@media (prefers-reduced-motion: reduce) {
.compactionButton {
transition: none;
}
}
.retrySummary:focus-visible {
outline: 1.5px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: 2px;
}
.retryText {
min-width: 0;
overflow-wrap: anywhere;
}
.retryRow[open] .retrySummary::after {
transform: rotate(45deg);
}
.retryDetails {
display: grid;
gap: 2px;
margin-top: 3px;
padding-left: 14px;
overflow-wrap: anywhere;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.retryDetailLabel {
color: var(--dsw-alias-label-secondary);
}
.turnErrorRow {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 2px 0;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
}
.turnErrorDot {
margin-top: 5px;
}
.turnErrorCopy {
min-width: 0;
overflow-wrap: anywhere;
}
.turnErrorTitle {
margin-right: 6px;
color: var(--dsw-alias-state-error-primary);
font-weight: 600;
}
.turnErrorMessage {
color: var(--dsw-alias-label-secondary);
}
.turnErrorCode {
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-markdown-code-block-small);
}
.maxTokensTitle {
margin-right: 6px;
color: var(--dsw-alias-state-warn-primary);
font-weight: 600;
}
.attachmentRow {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 100%;
gap: 8px;
}
.fileCard {
display: inline-flex;
flex: 0 0 240px;
align-items: center;
gap: 10px;
width: 240px;
min-height: 64px;
padding: 8px 12px;
border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12));
border-radius: var(--dsw-radius-lg);
background: var(--dsw-specific-input-major, transparent);
box-sizing: border-box;
}
.fileIcon {
flex: none;
width: 28px;
height: 28px;
}
.fileContent {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.fileName {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--dsw-alias-label-primary);
font-size: 14px;
font-weight: 500;
line-height: 22px;
}
.fileMeta {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45));
font-size: 12px;
line-height: 15px;
}
@@ -0,0 +1,77 @@
.root {
display: flex;
flex-direction: column;
}
.root:not([data-expanded]) {
contain: size layout;
height: calc(24px + var(--dsh-content-font-delta, 0px));
}
.row {
overflow: hidden;
}
/* The uncapped body scrolls with the conversation; keep its collapse toggle
reachable. Compact Markdown code banners stay in flow below this header. */
.root[data-expanded] [data-open] [data-disclosure-header] {
position: sticky;
top: 0;
z-index: 1;
background: var(--dsw-alias-bg-base);
}
.leading {
flex-shrink: 0;
}
.title {
font-weight: 400;
}
.separator {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
/* Think text reads the secondary tier — one step under the body size
(setting −1 at ≤14, setting −2 above), shared with every flow-row title
and summary. */
.summary {
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
white-space: nowrap;
}
.summaryText {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.summary[data-streaming] {
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent);
}
.summary[data-streaming] .summaryText {
overflow: visible;
text-overflow: clip;
}
.root:not([data-preview]) .separator,
.root:not([data-preview]) .summary {
display: none;
}
.thinkBody {
/* Indent under the title, which starts at leading (16 + delta) + gap 6. */
padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
min-width: 0;
}
@@ -0,0 +1,160 @@
/* Context-occupancy control below the composer plus its click-open breakdown
panel (menu surface: elevation-prominent). */
.root {
display: inline-flex;
flex: none;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
padding: 1px 8px;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-family: inherit;
font-size: var(--dsh-content-font-size-secondary, 13px);
font-variant-numeric: tabular-nums;
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
white-space: nowrap;
cursor: pointer;
}
.trigger[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
@media (hover: hover) {
.trigger:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
}
.track {
fill: none;
stroke: var(--dsw-alias-border-l3);
stroke-width: 2;
}
.fill {
fill: none;
stroke: var(--dsw-alias-label-tertiary);
stroke-width: 2;
stroke-linecap: round;
}
.panel {
position: fixed;
z-index: 1100;
box-sizing: border-box;
width: min(264px, calc(100vw - 24px));
padding: 12px;
border: 0;
border-radius: var(--dsw-radius-lg);
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
cursor: default;
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.figures {
margin-left: auto;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
.percent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.headline {
color: var(--dsw-alias-label-tertiary);
}
/* The headline brackets the reading, so the side a locale leaves empty must
drop out of the flex row rather than spend a gap. */
.headline:empty {
display: none;
}
.bar {
display: flex;
gap: 1px;
margin: 10px 0 12px;
height: 4px;
border-radius: 999px;
corner-shape: round;
background: var(--dsw-alias-interactive-bg-hover);
overflow: hidden;
}
.segment {
flex: none;
min-width: 2px;
height: 100%;
border-radius: 1px;
background: var(--meter-tint, var(--dsw-alias-label-tertiary));
}
.swatch {
display: inline-block;
margin-right: 6px;
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--meter-tint);
vertical-align: baseline;
}
.colorSystem {
--meter-tint: var(--dsw-static-neutral-bluish-400);
}
.colorTools {
/* The design platform ships no purple static token; violet-400 literal. */
--meter-tint: rgb(167, 139, 250);
}
.colorMessages {
--meter-tint: var(--dsw-static-blue-450);
}
.rows {
margin: 6px 0 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
}
.row dt {
color: var(--dsw-alias-label-secondary);
}
.row dd {
margin: 0;
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-primary);
}
@@ -0,0 +1,636 @@
.root {
position: relative; /* positioning context for slot-owned absolute chrome */
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
background: var(--dsw-alias-bg-base);
}
/* 76px with its rule: the height of the Sidebar's tab strip and header row
(ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules
meet at the column edge. The rows add up to it — 10px top inset, the 30px
title row, the tab strip's 10px margin, and a 25px view row. When the views
and the More launcher do not fit on one row, the row wraps and the header
grows past 76px. */
.header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
flex: none;
box-sizing: border-box;
min-height: 76px;
padding: 10px 28px 0 20px;
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
}
.header:where(:not(:has(.tabs))) {
min-height: 0;
padding-bottom: 10px;
}
/* Blank Sessions retain navigation controls without conversation title or tabs. */
.headerBlank {
min-height: 0;
padding-bottom: 0;
border-bottom: none;
}
.headerBlank .headerCorner {
margin-left: auto;
}
/* Only the macOS titlebar needs reserved drag space before a Session exists. */
:global(html:not([data-platform='darwin'])) .headerSessionless {
padding-top: 0;
}
:global(html:not([data-platform='darwin'])) .headerSessionless .titleRow {
min-height: 0;
}
/* macOS hiddenInset titlebar: the header block owns the window drag over its
own box — title row plus the view tab strip, so the draggable geometry is the
header's geometry and no frame band has to match its height. The header marks
itself `data-window-drag` (ConversationHeader); below it the title-row clusters
subtract their whole boxes, and individual controls are covered by the base.css
interactive-element rule. A covering overlay lives beside #root
(ui-settings-general), which puts it after this row in document order. */
:global([data-platform='darwin']) .headerLeading,
:global([data-platform='darwin']) .headerActions,
:global([data-platform='darwin']) .headerUtilities,
:global([data-platform='darwin']) .headerCorner {
-webkit-app-region: no-drag;
}
.titleRow {
grid-column: 2;
/* Anonymous inline-size container (CSS modules hash container-names per
module, so occupants match the nearest container): header occupants such
as the preset label and slot actions shed their text at row-width
breakpoints. The minmax(0, 1fr) column sizes the row, so inline-size
containment cannot collapse it. */
container-type: inline-size;
display: flex;
align-items: center;
gap: 0;
min-height: 30px;
/* Clears the frame's window-chrome band (traffic lights + shell.leading
controls, ui-layout AppFrame) when it is published; the header already
pads 20px of it. Zero everywhere else (the fallback makes the max
negative). */
padding-inline-start: max(0px, calc(var(--dsh-frame-leading-clearance, 0px) - 20px));
}
/* Header actions do not shrink, so on a narrow row they overflow the cluster.
The cluster clips them, hit testing included, so the next control keeps
every click. The trailing padding spans the gap before that control and
holds the fade: content that fits ends before it, and only overflowing
content reaches it. The 4px block and start padding, offset by negative
margins, keeps focus rings inside the clip. `clip` rather than `hidden`
keeps the cluster from becoming a scroll container that focus could scroll. */
.titleCluster {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
--title-cluster-fade: 20px;
margin: -4px calc(-1 * var(--title-cluster-fade)) -4px -4px;
padding: 4px var(--title-cluster-fade) 4px 4px;
overflow: clip;
mask-image: linear-gradient(to left, transparent calc(0.3 * var(--title-cluster-fade)), #000 var(--title-cluster-fade));
}
/* Without utilities the corner's 8px margin is the gap the fade spans. */
.titleCluster:has(+ .headerUtilities:empty) {
--title-cluster-fade: 8px;
}
/* The auto grid column collapses with an empty or CSS-hidden occupant.
The occupant owns its trailing gap so hidden controls reserve no space. */
.headerLeading {
grid-column: 1;
grid-row: 1;
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
.crumbs {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.crumbSeg {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.crumbSep {
/* figma: "/" separators are 14px caption gray (75:7903), one tint lighter than crumb text. */
color: var(--dsw-alias-label-caption);
font-size: 14px;
line-height: 20px;
}
.crumb {
display: inline-block;
max-width: 220px;
overflow: hidden;
padding: 4px 8px;
border: none;
border-radius: var(--dsw-radius-md);
background: transparent;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.crumbSubagent {
font-size: 12px;
line-height: 18px;
}
@media (hover: hover) {
button.crumb:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.crumbCurrent {
font-weight: 500;
color: var(--dsw-alias-label-primary);
cursor: default;
}
.headerActions {
display: flex;
flex: none;
align-items: center;
gap: 8px;
}
.headerUtilities {
display: flex;
flex: none;
align-items: center;
gap: 8px;
margin-left: 20px;
}
.headerUtilities:empty {
display: none;
}
/* The far-right corner seat reaches 16px into the header's 28px right padding,
so its control sits past the utilities' edge; it is laid out only while its
occupant renders something. Its left margin equals the utilities' 8px gap so
the header's trailing controls space evenly. */
.headerCorner {
display: flex;
flex: none;
align-items: center;
margin-left: 8px;
margin-right: -16px;
}
.headerCorner:empty {
display: none;
}
/* The tab row sits above the header paint so the active bar covers its rule. */
.tabs {
grid-column: 1 / -1;
position: relative;
z-index: 1;
display: flex;
gap: 16px 20px;
width: 100%;
min-width: 0;
box-sizing: border-box;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 10px;
padding-left: 8px;
}
@media (max-width: 767px) {
:global(html:not([data-platform])) .tabs {
padding-left: 0;
}
}
.viewTabs {
display: flex;
flex-wrap: wrap;
gap: 32px;
min-width: 0;
min-height: 25px;
}
/* The 16px text line retains 9px below it. The press target extends 10px
above the text into the strip's top margin and 10px to each side, matching
the inline padding of the Sidebar tab chips (ui-dockkit `.tab`); negative
margins keep the layout box at its 25px row and text width. The active bar
spans the text and reaches 1px past the tab box to sit over the header
rule. */
.tab {
--dsh-tab-hit-inline: 10px;
position: relative;
box-sizing: border-box;
min-height: 35px;
margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0;
padding: 10px var(--dsh-tab-hit-inline) 9px;
border: none;
background: transparent;
font-size: 13px;
line-height: 16px;
font-weight: 500;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.tab::after {
content: '';
position: absolute;
right: var(--dsh-tab-hit-inline);
bottom: -1px;
left: var(--dsh-tab-hit-inline);
height: 2px;
border-radius: 2px;
background: transparent;
}
/* Selected tab is blue, not ink: brand-primary resolves to neutral black in
this token sheet, so the selected state rides the business blue — the
nearest semantic token that stays blue in both themes. */
.tabActive {
color: var(--dsw-alias-state-business-primary);
}
.tabActive::after {
background: var(--dsw-alias-state-business-primary);
}
/* The More launcher centers its 22px box on the tab's 16px text line. */
.viewsMoreAnchor {
flex: none;
}
.viewsMore {
width: 22px;
height: 22px;
margin-top: -3px;
padding: 0;
color: var(--dsw-alias-label-secondary);
}
.viewsMoreActive { color: var(--dsw-alias-state-business-primary); }
.tabLabel { display: block; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.viewsMore[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
.viewsMoreMenu {
width: 288px;
max-width: calc(100vw - 24px);
}
.viewArea {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
/* Width handles: 10px col-resize strips beside the transcript, absolute in
.body (NOT the scrollport: an absolute strip there would extend the
scrollable range; NOT .root: a strip from the column top would paint its
glow through the header's transparent background). The inner edge sits
24px outside the content column and the strip extends 10px outward; the
outer edge is clamped to keep a 24px safe zone against the
column edges (sidebar side and scrollbar side stay drag-free). When the
margin cannot fit inset + strip + safe zone the computed width goes
negative and the strip resolves to zero — no mispositioned hit area on
narrow columns. Known half-scrollbar (~4px) centering offset vs the
gutter-reserved content box is accepted: it consumes part of the 10px
strip but remains within it. */
.widthHandle {
position: absolute;
top: 0;
bottom: 0;
/* Concrete overflow content establishes z-index 1, while the sticky
composer remains above both layers. */
z-index: 0;
width: min(
10px,
calc((100% - var(--dsh-chat-content-width)) / 2 - 24px - 24px)
);
cursor: col-resize;
}
.widthHandle[data-side='left'] {
right: calc(50% + var(--dsh-chat-content-width) / 2 + 24px);
}
.widthHandle[data-side='right'] {
left: calc(50% + var(--dsh-chat-content-width) / 2 + 24px);
}
/* The glow: a short 2px bar following the captured pointer's Y, centered in the strip
(29px from the content column), not a full-height rule — a 16px solid core
centered on the pointer
(published during drag as --dsh-width-handle-pointer-y), fading to
transparent over 28px each side (72px total). A solid CORE, not a single
solid stop: a lone peak washes out over the fade. The resting scrollbar
tint keeps this secondary affordance quiet in both themes. Before the first
drag move the fallback centers the glow at 50% height; hover leaves its position unchanged. */
.widthHandle::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 2px;
border-radius: 2px;
background: linear-gradient(
to bottom,
transparent calc(var(--dsh-width-handle-pointer-y, 50%) - 36px),
var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) - 8px),
var(--dsw-alias-scrollbar-bg-l1) calc(var(--dsh-width-handle-pointer-y, 50%) + 8px),
transparent calc(var(--dsh-width-handle-pointer-y, 50%) + 36px)
);
opacity: 0;
pointer-events: none;
}
.widthHandle[data-side='left']::after {
right: 4px;
}
.widthHandle[data-side='right']::after {
left: 4px;
}
.widthHandle[data-dragging]::after {
opacity: 1;
}
@media (hover: hover) {
.widthHandle:hover::after {
opacity: 1;
}
}
/* A drag starts above the composer band. Pointer capture keeps it active when
crossing the footer; lift only the active handle so its indicator remains
visible without making the resting footer gutter draggable. */
.widthHandle[data-dragging] {
z-index: 8;
}
/* Views that elect a composer overlay (trajectory etc.) own their full-bleed
scrollers; the strips would sit over their content and steal clicks. */
.root:has([data-conversation-composer-overlay]) .widthHandle {
display: none;
}
/* Composer context stack (Figma 9:937): standalone dock cards share one
rhythm above the input card. */
.composerStack {
/* Horizontal geometry (card width, clearance, dock inset) rides the shared
.root variables above so takeover siblings match the stack. */
--dsh-composer-stack-gap: 6px;
display: flex;
flex-direction: column;
gap: var(--dsh-composer-stack-gap);
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
.composerSeat {
display: flex;
flex: none;
flex-direction: column;
/* One cap for every scrolling text region a composer seat can hold: the
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
takeover panels' bodies top out at the same height, so electing a
takeover never grows the footer past the card it replaces. Declared on
the seat because it is the chain's only shared ancestor — fallback and
elected overlay are siblings — and custom properties inherit down to
whichever entry is mounted. */
--dsh-composer-text-max-height: 336px;
}
/* Active phase: header is ordinary column chrome above the scrollport (not
sticky). The scroll body holds the transcript and the sticky composer seat
so wheel over the footer moves the flow. */
.root[data-phase='active'] {
overflow: hidden;
}
.root[data-phase='active'] .header {
flex: none;
}
/* Band below the header: the scrollport plus the width handles. Anchoring the
handles here keeps their full-height strips and glow under the header's
bottom edge at every header height. */
.body {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
/* Shared width axis for the transcript, dock cards, composer, and takeover
cards. Main width controls publish the live body width and optional user
preference; embedded occurrences override this adaptive axis below. */
--dsh-chat-content-width: var(
--dsh-chat-user-width,
clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px)
);
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
--dsh-composer-side-clearance: 16px;
--dsh-composer-dock-inset: 8px;
}
.embeddedBody {
overflow: hidden;
--dsh-chat-content-width: min(calc(100% - 32px), 920px);
--dsh-composer-card-max-width: min(calc(100% - 16px), 952px);
--dsh-composer-side-clearance: 8px;
--dsh-composer-dock-inset: 8px;
}
.scrollBody {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
/* The product-wide 2px scrollbar offset (ui-workspace WorkspaceBrowser
`.list`, ui-sidebar-files FilesBody `.body`): the bar sits 2px clear of
the column's edge instead of flush against it. */
margin-right: 2px;
overflow-y: auto;
/* Reserved unconditionally: the composer seat rides this box's content box in
Chat and its padding box under a view's composer overlay, so an `auto`
gutter moves the input card sideways by the bar's width whenever the two
differ ([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
scrollbar-gutter: stable;
}
/* The bar also stops 2px short of the scroller's ends, matching its 2px edge
offset. WebKit-only: the Firefox path has no track to inset. */
.scrollBody::-webkit-scrollbar-track {
margin: 2px;
}
.root[data-phase='active'] .viewArea,
.embeddedBody[data-content-phase='active'] .viewArea {
flex: 1 0 auto;
min-height: auto;
}
.root[data-phase='active'] .composerSeat,
.embeddedBody[data-content-phase='active'] .composerSeat {
position: sticky;
bottom: 0;
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
paints under a sticking code header while scrolling. The pinned compaction
header (MessageItem.module.css, ui-chat) and the turn-navigation rail slot
(TurnNavigator.module.css, ui-chat) also take 7 to clear the same banners.
Among elements of equal rank the later one in DOM order wins, so the
composer stays above the pinned header on the extreme small-viewport
overlap and the pinned header stays above the rail. */
z-index: 7;
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
band at the seat's top (the figma 24% of the resting ~150px composer),
solid below — px stops, not %, so a growing draft only widens the solid
region and the fade band never stretches. The 0px stop is bg-base at
zero alpha (not white, which the figma export hardcodes) so both themes
fade from their own base. */
background: linear-gradient(
180deg,
color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
var(--dsw-alias-bg-base) 36px
);
}
/* An open @/slash menu (data-trigger-menu, ui-input-trigger) renders inside
the input card, so the seat's stacking context caps its z-index; lift the
seat above the back-to-bottom control (z-index 8, ui-chat ChatView) while
the menu is open. The steady state stays 7 so the control keeps painting
over the card. */
.root[data-phase='active'] .composerSeat:has([data-trigger-menu]) {
z-index: 9;
}
/* Views may opt into a composer overlay while ConversationRoot retains
ownership of the seat geometry and its active-phase precedence. */
.scrollBody:has([data-conversation-composer-overlay]) {
position: relative;
/* A clipping box nothing scrolls out of, stated as a scroll container on both
axes rather than `overflow: hidden`: a single-axis scroller computes the
other axis to `auto`, and the seat's absolute positioning below needs the
box to stay a containing block in every engine. */
overflow-x: hidden;
overflow-y: auto;
/* The view owns its own scrollers (the trajectory ledger scrolls inside the
view), so this box never scrolls here; reserving a gutter would only
narrow the view's content by the bar's width. Chat keeps the reservation
on the same element, so the seat below compensates with the bar's width
([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
scrollbar-gutter: auto;
}
.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
position: absolute;
/* Chat's seat rides this box's content box, which its own scrolling shortens
by the bar's width; the overlay seat is against the padding box, so it
must give back the same width to keep the input card in place across
tabs. Reads ui-theme's --dsh-scrollbar-width, which mirrors the WebKit
bar's layout width. Chat reserves the engine-resolved width, so on an
engine where the resolved width differs from this fixed 8px the card
drifts by half the difference — accepted residual risk, recorded in
([decision](../../../../../../.agents/notes/archived/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md)). */
right: var(--dsh-scrollbar-width);
bottom: 0;
left: 0;
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
flex-centered in the column; composer phase docks it at the bottom. Flex,
NOT absolute+transform: a transform would make this box the containing
block for position:fixed descendants (pickers/modals), shrinking them. */
.composerHero {
align-self: center;
/* figma 75:8208 drew 12 between all three rows; the workspace row now sits
8 above the card (its margin-top restores 12 under the hero chrome). */
gap: 8px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
/* Card cap + both clearances: the hero input card lands at exactly the same
width as the docked composer at every viewport. */
width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance)), 100%);
z-index: 1;
}
.heroWorkspaceRow {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
margin-top: 4px;
/* figma drew px 8; left nudged +12 so the chip's folder glyph lines up
closer to the card's inner controls below; right keeps the trailing
preset seat off the card edge. */
padding: 0 16px 0 20px;
}
/* Plugin options share the row, pushed to its right edge. */
.heroOptions {
display: flex;
flex: none;
align-items: center;
gap: 2px;
min-width: 0;
margin-left: auto;
}
.heroOptions:empty {
display: none;
}
/* Hero: the composer sits inside the session scroll body; center there so
the tree seat matches active (sticky footer) without a Root remount. */
.root[data-phase='hero'] .scrollBody,
.embeddedBody[data-content-phase='hero'] .scrollBody {
justify-content: center;
overflow-y: auto;
}
/* Settling (session replaying, hero/docked unknown): keep the composer seat
mounted but invisible so no wrong layout flashes before the phase lands. */
.root[data-phase='settling'] .composerSeat,
.embeddedBody[data-content-phase='settling'] .composerSeat {
visibility: hidden;
}
@@ -0,0 +1,237 @@
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
above the shared InputBar card. The input itself is InputBar — only stack
geometry and the chip live here. */
.root {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-width: 0;
padding: 0 24px;
}
/* Cap matches the InputBar card. Glow may paint past the sides. */
.stack {
display: flex;
flex-direction: column;
align-items: stretch;
/* figma 75:8208: 12 between title block / workspace / card. */
gap: 12px;
width: 100%;
max-width: var(--dsh-composer-card-max-width);
overflow: visible;
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
badge is a product addition outside that source: a mono superscript pill
riding the title's top-right. */
.headline {
display: flex;
/* Overflow-driven: the fish and the title group are two wrap units sharing
one centered line while they fit; squeezed, the fish takes its own line
above the group. No breakpoint. */
flex-wrap: wrap;
column-gap: 10px;
/* Wrapped, the fish sits on its own line; more air than the 10px side gap
keeps it reading as a mark above the title, not a squeezed inline glyph. */
row-gap: 12px;
align-items: center;
justify-content: center;
font-size: 26px;
line-height: 32px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
/* Title text + badge as one wrap unit so the title never breaks mid-text with
the badge stranded beside a fragment. When the badge no longer fits beside
the title it wraps to its own centered line below; while both fit, the group
is content-sized and centering is a no-op, keeping the superscript snug. */
.titleGroup {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
/* Superscript sits 7 from the title. */
column-gap: 7px;
row-gap: 4px;
min-width: 0;
}
.previewBadge {
align-self: flex-start;
margin-top: 2px;
padding: 1px 7px 0;
border: 0.5px solid var(--dsw-alias-interactive-bg-hover);
border-radius: 999px;
corner-shape: round;
background: var(--dsw-alias-state-business-tertiary);
color: var(--dsw-alias-label-primary-bluish);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
font-weight: 500;
white-space: nowrap;
}
/* Keep hover detection on a stationary box while the mark moves within it;
hover rules target the hitbox so a slot-occupant mark swims too. */
.fishHitbox {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
}
/* The swim morph's curves poke slightly past the viewBox edges, so clipping
would slice the tail mid-wag. The hero mark stays in the same primary ink
as its headline. */
.fish {
display: block;
overflow: visible;
transform-origin: 50% 60%;
color: var(--dsw-alias-label-primary);
}
/* Hover swim: a slow continuous sway about the body center — the head
(left) lifts as the whale rises, then settles back with a small
counter-tilt, like treading water. Negative rotation = head up (the head
is the left half; origin 50% 60%). */
@keyframes hero-fish-swim {
0%,
100% {
transform: none;
}
35% {
transform: rotate(-4deg) translate(-0.4px, -0.9px);
}
70% {
transform: rotate(1.6deg) translate(0.3px, 0.2px);
}
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
.fishHitbox:hover .fish {
animation: hero-fish-swim 1.6s ease-in-out infinite;
}
}
/* Workspace row sits 12px above the input card (figma y80 → y112). */
.body {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
overflow: visible;
}
.body > * {
position: relative;
z-index: 1;
}
/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its
right-hand submenu) paints above the InputBar card. */
.body > .workspaceRow {
z-index: 10;
display: flex;
align-items: center;
min-width: 0;
/* figma 75:8208 workspace row: px 8 above the card. */
padding-left: 8px;
}
/* Folder + label + chevron — transparent at rest; fill only on hover / open.
Overflow-driven degradation, no breakpoint: the label shrinks with an
ellipsis down to nothing, so folder and chevron always sit snug against
whatever text remains — full text, ellipsized text, or none. */
.workspace {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: min(100%, 360px);
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
}
.workspace[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
@media (hover: hover) {
.workspace:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* Locked form (bound guidance state): a static echo — no hover feedback, no
pointer affordance; label keeps full contrast. */
.workspace:disabled {
cursor: default;
}
.folder {
flex: none;
color: var(--dsw-alias-label-primary);
}
.workspaceLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: none;
color: var(--dsw-alias-label-caption);
}
/* Dialog field: 44 tall on the modal's 332 content column, hairline
border, pad 14/7, 14/22 wt400 primary text, caption placeholder. Focus
keeps the resting border (design shows no focus ring). */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-lg);
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.modalAction {
min-width: 72px;
}
.modalError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
@@ -0,0 +1,458 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
the sides narrow with the shared width axis); the bottom gradient mask
is owned by the chat scroller. No top pad: the composer stack's gap owns
the space above; the status strip still carries its own margin. */
padding: 0 var(--dsh-composer-side-clearance) 4px;
}
.dock {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
max-width: 100%;
padding-top: 4px;
}
.hero .dock:empty {
display: none;
}
.hero {
/* No bottom pad in the centered hero, but the side clearance must survive:
the hero wrapper is full-width on narrow viewports, so this padding is
the only thing keeping the card off the edges there. */
padding: 0 var(--dsh-composer-side-clearance);
}
.notice {
width: 100%;
max-width: var(--dsh-composer-card-max-width);
margin-bottom: 6px;
padding: 4px 8px;
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
}
.card {
box-sizing: border-box;
position: relative; /* overlay anchor positioning context */
display: flex;
flex-direction: column;
/* figma Input 75:8208: 12px between the text area and the button row; the
card's top pad before .InputText tightened from the drawn 10 to 8. */
gap: 12px;
width: 100%;
max-width: var(--dsh-composer-card-max-width);
padding-top: 8px;
/* Input stroke: the light neutral (l2, one step above the menus' l1),
drawn as the elevation hairline so it costs no layout; the shadow is the
soft elevation tier. Fill: the input surface token (elevated in dark). */
border: 0;
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
border-radius: var(--dsw-radius-panel);
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-elevation-soft);
/* The draft text follows the Settings font-size preference (the textarea,
mirror, and backdrop all inherit from here, so the three layers stay in
step); the line height keeps the default 10px leading via the shared px
delta. */
font-size: var(--dsh-content-font-size, 14px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
/* Elevated surface in dark, same as the menus: the draft scrollport inside
scrolls once the composer hits its height cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the box that actually scrolls
(see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
@media (pointer: coarse) {
.card {
font-size: max(16px, var(--dsh-content-font-size, 14px));
}
}
/* No-workspace trigger state: an inset 1px dashed outline marks the card as a
workspace picker rather than a live composer. Keeping the complete stroke
inside the rounded box prevents clipped corner pixels from reading heavier. */
.cardWorkspaceTrigger {
--dsw-elevation-stroke-color: transparent;
cursor: pointer;
}
.cardWorkspaceTrigger::after {
content: '';
position: absolute;
inset: 0;
box-sizing: border-box;
border: 1px dashed currentColor;
border-radius: var(--dsw-radius-panel);
color: var(--dsw-alias-border-l4);
transition: color 100ms ease;
pointer-events: none;
}
/* Disabled toolbar controls neither receive nor swallow clicks in the trigger
state: pointer events fall through to the card's own click handler, making
the whole card one pick target. */
.cardWorkspaceTrigger :disabled {
pointer-events: none;
}
@media (hover: hover) {
.cardWorkspaceTrigger:hover::after {
color: var(--dsw-alias-state-business-primary);
}
}
.accessory {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px 0;
}
/* Floating overlay anchor (menu / popupSelect shell): entries position
themselves against the card (bottom: 100% + gap); closed entries render null. */
.overlayAnchor {
position: absolute;
inset: 0 0 auto;
height: 0;
}
/* The draft's scrollport, and the ONLY scrolling box in the composer. The
contenteditable grows with its content inside; the 14-line cap lives here
because this is the box the cap describes. */
.scroll {
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
/* Keeps the bar off the card's right border; .input's right pad gives the
4px back so the text column is unchanged. */
margin-right: 4px;
}
/* The card's corner arc still overlaps the scrollport's top (8px top
pad reaches only y=8): start the thumb's travel below the arc so it never
pokes outside the capsule. WebKit-path only; Firefox's thin bar has no
track margin, an accepted remainder. */
.scroll::-webkit-scrollbar-track {
margin-top: 8px;
}
/* Auto-grow anchor: the contenteditable is in normal flow and sets the
draft's height; the placeholder rides it absolutely. */
.grow {
position: relative;
}
/* Machine pending dot (adjudicating / submitting). */
.pending {
width: 8px;
height: 8px;
border-radius: 50%;
corner-shape: round;
background: var(--dsw-alias-state-business-primary);
animation: input-pending 1s ease-in-out infinite alternate;
}
@keyframes input-pending {
from { opacity: 0.35; }
to { opacity: 1; }
}
/* The contenteditable draft surface (grows with its content; .scroll caps
and scrolls it). figma .InputText 34:10434 drew pl 16 / pr 12 / pt 4; the
left pad tightened to 14, and the right pad is 8 here plus .scroll's 4px
bar inset. */
.input {
box-sizing: border-box;
/* Docked floor: one 24px line + 4px top pad = 28 content height; 36 leaves
an 8px breath below the line (the hero keeps its own 52px floor below). */
min-height: 36px;
padding: 4px 8px 0 14px;
font-family: var(--dsw-font-family);
font-size: inherit;
line-height: inherit;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
outline: none;
color: var(--dsw-alias-label-primary);
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
caret-color: var(--dsw-alias-state-business-primary);
}
/* Lexical paragraphs are <p> blocks: strip the UA margins so the surface
keeps the textarea's line rhythm. */
.input p {
margin: 0;
}
/* Claim ghost hint as generated content after the last paragraph: the bar
sets --dsh-composer-hint (a quoted string) while the claim's args are
blank; without the variable the declaration is invalid and nothing shows. */
.input p:last-child::after {
content: var(--dsh-composer-hint);
color: var(--dsw-alias-label-caption);
}
.input[data-composer-composing] p:last-child::after {
content: none;
}
/* A selected command chip stands in for the caret until the next key. */
.input[data-composer-chip-selected] {
caret-color: transparent;
}
.input[data-composer-composing] + .placeholder {
visibility: hidden;
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly.
Insets mirror .input's pads (the other right 4px sits on .scroll). One
ellipsized line: a wrapped second line would clip against the docked
36px floor. */
.placeholder {
position: absolute;
inset: 4px 8px auto 14px;
color: var(--dsw-alias-label-caption);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
user-select: none;
}
.inputDisabled {
color: var(--dsw-alias-label-tertiary);
cursor: not-allowed;
}
.input[aria-haspopup='menu'] {
cursor: pointer;
}
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
line + 4pt); the docked composer collapses to the content height. */
.hero .input {
min-height: 52px;
}
/* The hero's 52px floor fits two lines, so its placeholder clamps at two
instead of the docked single line. */
.hero .placeholder {
white-space: normal;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
/* 2px moved from the bottom pad to the top: the whole control row sits 2px
lower in the card (it read too high against the textarea) while the
controls' own centering stays untouched. */
padding: 2px 8px 6px;
min-width: 0;
/* Size container so the chips inside can collapse to icon-only when the
card runs out of row width (PermissionSelect @container rule). Anonymous
on purpose: CSS modules hash container-name per module, so a name declared
here can never match a query in another module's sheet. */
container-type: inline-size;
}
.row[data-model-compact] {
--dsh-composer-model-text-display: none;
--dsh-composer-model-icon-display: block;
}
.tools,
.modes,
.trailing {
display: flex;
align-items: center;
min-width: 0;
}
/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan /
Read-only; both gaps settled on 12 so the pill chips read as separate
controls without drifting from the + control. */
.tools {
gap: 12px;
}
.modes {
gap: 12px;
}
.trailing {
flex: none;
/* Wrap keeps the left mode chips and the right controls apart when the card
runs out of row width: the trailing group (model + send) moves to its own
line instead of the left group shrinking until its chip overlaps the
model trigger (external:107). The auto margin re-anchors it right on the
wrapped line; on a single line space-between already pins it right. */
margin-left: auto;
gap: 12px;
}
/* Compact spacing preserves room for the fixed-width controls in narrow cards. */
@container (max-width: 560px) {
.tools,
.modes,
.trailing {
gap: 8px;
}
}
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
.add {
display: grid;
place-items: center;
flex: none;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
corner-shape: round;
background: var(--dsw-specific-selector);
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
@media (hover: hover) {
.add:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
}
.add:disabled {
opacity: 0.5;
cursor: default;
}
/* Plan / Read-only / model — native <select>, chip-like closed chrome
(figma ToggleButton: 13/20 medium secondary, 12px chevron). */
.select {
max-width: 220px;
height: 28px;
padding: 0 20px 0 8px;
border: none;
border-radius: var(--dsw-radius-sm);
outline: none;
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 4px center;
background-size: 12px 12px;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
appearance: none;
}
@media (hover: hover) {
.select:hover:not(:disabled) {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.select:disabled {
opacity: 0.5;
cursor: default;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
#679EFE dark — the info-fill pair (500→400), NOT button-primary (ink);
white glyph; empty text = 0.4 opacity. */
.primary {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
border: none;
border-radius: 999px;
corner-shape: round;
background: var(--dsw-alias-button-info-fill);
/* Static white, not the foreground token: the arrow stays white on the blue
fill in both themes (design 34:10465). */
color: #fff;
cursor: pointer;
transition: background-color 100ms ease;
/* Opts out of the row's 2px downward shift (.row top pad): the send circle
keeps its original seat while the smaller chips sit lower. Transform, not
margin, so flex centering math is untouched. */
transform: translateY(-2px);
}
@media (hover: hover) {
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
}
.primary:disabled {
opacity: 0.4;
cursor: default;
}
/* Sending: the message waits for the Host to accept it. The circle keeps its
full fill rather than the disabled fade, and an arc turns where the arrow was. */
.primary[data-sending]:disabled {
opacity: 1;
cursor: progress;
}
.sendingSpinner {
animation: input-sending 800ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.sendingSpinner {
animation-duration: 2400ms;
}
}
@keyframes input-sending {
to { transform: rotate(360deg); }
}
.retry {
margin-left: 8px;
padding: 1px 8px;
border: 1px solid currentColor;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
}
.standardControls { display: flex; align-items: center; gap: 12px; min-width: 0; }
.tools[hidden], .standardControls[hidden] { display: none; }
.trailingActive { flex: 1; margin-left: 0; }
.activity { display: flex; align-items: center; flex: none; }
.activity:empty { display: none; }
.activityExpanded { flex: 1; min-width: 0; }
@@ -0,0 +1,123 @@
.root {
box-sizing: border-box;
flex: none;
overflow: hidden;
margin: 0 auto;
width: calc(
100% -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
max-width: calc(
var(--dsh-composer-card-max-width) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
border: 0;
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
border-radius: var(--dsw-radius-lg);
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
box-shadow: var(--dsw-elevation-panel);
/* The menu surface and the scrolling `.list` use the l2 scrollbar colors.
Declared here because the custom properties inherit into `.list` (see
ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 6px 12px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
}
.lead {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 13px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.progress {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: 13px;
line-height: 20px;
font-weight: 400;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
max-height: 180px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.glyph {
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
}
/* Figma strip is single-line; long items ellipsize with no inline expand. */
.content {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+9
View File
@@ -0,0 +1,9 @@
/* Vendored DeepSeek Harness design tokens + surfaces (Phase 1).
* NOTE: do NOT import corner-shape.css — `*,*::before,*::after{corner-shape}`
* freezes WebKitGTK (Tauri) with continuous layout thrash. */
@import "./styles/base.css";
@import "./styles/design-platform.css";
@import "./styles/scrollbar.css";
@import "./styles/focus.css";
@import "./styles/gradient-shadow-text.css";
@import "./shell-vars.css";
@@ -0,0 +1,388 @@
.frame {
position: relative; /* anchors the drag handles, which straddle column borders */
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
height: 100%;
box-sizing: border-box;
overflow: hidden;
background: var(--dsw-alias-bg-base);
}
/* Only discrete collapse/expand and fold changes ease the tracks (the TSX holds
data-animating across the toggle on the deepsuite sider curve). Steady-state
tracks move only with the viewport, and those updates must land instantly:
an eased track chases the live window edge and the centre column visibly
rubber-bands while the user resizes the window. */
.frame[data-animating] {
transition: grid-template-columns var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Dragging writes widths at pointer cadence; easing them would detach the
column from the handle. */
.frame[data-dragging] {
transition: none;
}
/* The Windows caption row remains outside every conversation and file panel. */
:global([data-windows-titlebar]) .frame {
--dsh-windows-content-radius: 16px;
box-sizing: border-box;
padding-top: var(--dsh-windows-titlebar-height);
background: var(--dsw-specific-sidebar-fill);
}
:global([data-windows-titlebar]) .centerCol {
background: var(--dsw-alias-bg-base);
border-radius: var(--dsh-windows-content-radius) 0 0 0;
corner-shape: round;
}
:global([data-windows-titlebar]) .frame::before {
content: '';
position: absolute;
inset: 0 0 auto;
height: var(--dsh-windows-titlebar-height);
background: var(--dsw-specific-sidebar-fill);
-webkit-app-region: drag;
}
:global([data-windows-titlebar]) .sidebarCol {
border-right: none;
}
@media (prefers-reduced-motion: reduce) {
.frame[data-animating] {
transition: none;
}
}
.sidebarCol {
grid-column: 1;
grid-row: 1;
min-width: 0;
overflow: hidden;
background: var(--dsw-specific-sidebar-fill);
border-right: 0.5px solid var(--dsw-alias-border-l3);
}
.centerCol {
grid-column: 2;
grid-row: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mainPanel {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
}
/* macOS desktop: the frame goes transparent so window vibrancy reaches the
sidebar column, which keeps only a translucent tint; the centre paints the
base background itself to stay opaque. The sidebar/centre separator moves
to the opaque centre: on the translucent sidebar the alpha border mixes
with the wallpaper and reads as a dark seam. */
:global([data-platform='darwin']) .frame {
background: transparent;
}
/* The constant step below the window's top strip (traffic lights, sidebar
toggle, shell.leading controls), the same in both sidebar states. Entry
pages in the main panel (plugin manager, settings-like pages — not the
conversation, which owns its header) pad their top by it so their leading
content clears the strip, and overlay primitives add their own safe inset.
Declared on the root element rather than the frame so
overlays portalled to document.body inherit it too. */
:global(html[data-platform='darwin']) {
--dsh-frame-top-clearance: 48px;
}
/* In-frame Windows content already clears the caption through .frame padding. */
:global(html[data-windows-titlebar]) {
--dsh-frame-top-clearance: var(--dsh-windows-titlebar-height);
--dsh-frame-chrome-top: var(--dsh-windows-titlebar-height);
}
/* Portalled overlays share a viewport inset without adding space to entry pages. */
:global(html[data-platform='darwin']),
:global(html[data-windows-titlebar]) {
--dsh-frame-overlay-top: calc(var(--dsh-frame-top-clearance) + 20px);
}
:global(html[data-platform='darwin'][data-fullscreen]),
:global(html[data-windows-titlebar][data-fullscreen]) {
--dsh-frame-overlay-top: 20px;
--dsh-frame-chrome-top: 0px;
}
:global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol {
/* A blue wash over the top of the translucent tint, gone by a third of the
column; a faint blue-grey-violet rises through the bottom third; and a
faint blue cast is mixed into the tint itself. Every layer stays
transparent so vibrancy shows. */
background:
linear-gradient(
to bottom,
rgb(122 155 240 / 0.1),
rgb(122 155 240 / 0) 35%,
rgb(143 137 184 / 0) 68%,
rgb(143 137 184 / 0.09)
),
color-mix(in srgb, color-mix(in srgb, var(--dsw-specific-sidebar-fill) 97%, rgb(122 155 240)) 40%, transparent);
border-right: none;
}
/* Dark drops the blue cast (the light-mode wash reads purple over dark fills)
and thins the fill so more wallpaper shows; the washes stay. */
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .frame:not([data-sidebar-drawer]) .sidebarCol {
background:
linear-gradient(
to bottom,
rgb(122 155 240 / 0.08),
rgb(122 155 240 / 0) 35%,
rgb(143 137 184 / 0) 68%,
rgb(143 137 184 / 0.07)
),
color-mix(in srgb, var(--dsw-specific-sidebar-fill) 50%, transparent);
}
/* macOS Reduce Transparency: the OS flattens the window's vibrancy layer to a
solid grey, so the thin tint and washes read washed-out over it. Replace
them with a 90%-opaque fill of the same token in both themes (the dark
selector repeats so this outranks its thinner fill). */
@media (prefers-reduced-transparency: reduce) {
:global([data-platform='darwin']) .frame:not([data-sidebar-drawer]) .sidebarCol,
:global([data-platform='darwin']) :global([data-ds-dark-theme]) .frame:not([data-sidebar-drawer]) .sidebarCol {
background: color-mix(in srgb, var(--dsw-specific-sidebar-fill) 90%, transparent);
}
}
:global([data-platform='darwin']) .centerCol {
background: var(--dsw-alias-bg-base);
border-left: 0.5px solid var(--dsw-alias-border-l3);
}
/* The right track paints the base ground on the transparent frame: while the
track and the occupant's translateX slide ride the same curve, the grid
eases in layout and the transform on the compositor, so frames where they
diverge would otherwise show vibrancy glass between the centre's right edge
and the incoming panel. The occupant's opaque panel covers this at rest. */
:global([data-platform='darwin']) .rightbarCol {
background: var(--dsw-alias-bg-base);
}
/* The darwin closed sidebar has no rail: the centre reaches the window edge,
where a separator would draw a seam against nothing. */
:global([data-platform='darwin']) [data-sidebar-collapsed] .centerCol {
border-left: none;
}
/* Published while the shell.leading seat is mounted (same condition as the
TSX mount: darwin with the sidebar collapsed — the collapse that hides the
column entirely): the inline band the window chrome occupies, measured from
the frame's left edge — traffic lights to x 68, controls at x 88
(28 + 8 + 28), then 8px of air. Main panels whose content reaches the
top-left corner pad by this. */
:global([data-platform='darwin']) .frame[data-sidebar-collapsed] {
--dsh-frame-leading-clearance: 160px;
}
/* Fullscreen hides the traffic lights (preload mirrors the window state onto
html[data-fullscreen]): the seat takes their band and the clearance keeps
only the controls (12 + 28 + 8 + 28 + 8). The 12px start matches the
expanded sidebar strip's inline padding (ui-sidebar .topStrip), so
collapsing never shifts the toggle. */
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-collapsed] {
--dsh-frame-leading-clearance: 84px;
}
:global([data-platform='darwin'][data-fullscreen]) .leadingSeat {
left: 12px;
}
/* The frame declares no window drag of its own: every chrome row owns the drag
over its own box (ui-web base.css holds the one rule; the ui-theme app-region
gate lists the rows). The ordering rule that replaces the old "band first"
invariant: a surface that covers the window and must stay clickable portals
beside #root, which puts it after every drag row in document order. The Windows
caption row above is the other platform's chrome and stays. */
/* The seat itself: the two 28px controls centered on the conversation title
row (10px header inset + 30px row → centre y 25) and level with the traffic
lights (centre y ≈ 24). Sits above column content, below frame overlays
(20) and the fullscreen right panel (40). The box subtracts itself from any
window drag row beneath (Electron app-regions are geometry, not stacking). */
.leadingSeat {
position: absolute;
top: 11px;
left: 88px;
z-index: 15;
display: flex;
align-items: center;
-webkit-app-region: no-drag;
}
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. No
handle draws a visible pill: the details column was the only one that did,
and the right column never had one. The right handle sits above the right
panel (which states z-index 10) so it stays grabbable when the panel hangs
over the centre. */
.handle {
position: absolute;
grid-area: 1 / 1 / 2 / -1;
top: 0;
bottom: 0;
width: 8px;
margin-left: -4px;
cursor: col-resize;
z-index: 11;
touch-action: none;
}
/* Rides the same curve as the tracks so the pill stays on the moving border
during collapse/expand; instant at steady state (viewport-driven moves)
and while dragging. */
.frame[data-animating] .handle {
transition: left var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.frame[data-dragging] .handle {
transition: none;
}
/* Fullscreen covers the frame while its underlying columns enter or exit. */
.frame[data-rightbar-fullscreen],
.frame[data-rightbar-fullscreen] .handle,
.frame[data-rightbar-instant],
.frame[data-rightbar-instant] .handle {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
.frame[data-animating] .handle {
transition: none;
}
}
/*
* The right column never clips: its occupant anchors a fixed-width panel to the
* column's right edge (the frame's edge, which never moves) and lets the track
* decide whether the centre makes room. With no track the panel hangs over the
* centre from a zero-width column, and while the track animates the panel stays
* where it is. The occupant draws its own left border.
*/
.rightbarCol {
grid-column: 3;
grid-row: 1;
position: relative;
min-width: 0;
overflow: visible;
}
.frame[data-sidebar-drawer] {
--dsh-frame-leading-clearance: 84px;
overflow: clip;
}
:global(html:not([data-platform])) .frame[data-sidebar-drawer] {
/* 12px seat offset + 28px opener + 8px gap; Desktop keeps room for two controls. */
--dsh-frame-leading-clearance: 48px;
}
.frame[data-sidebar-drawer] .mainPanel[data-global-panel] {
padding-top: max(0px, calc(48px - var(--dsh-frame-top-clearance, 0px)));
}
.frame[data-sidebar-drawer] .sidebarCol {
position: absolute;
grid-area: auto;
inset: 0 auto 0 0;
z-index: 71;
box-sizing: border-box;
border-right: none;
transform: translateX(0);
visibility: visible;
transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol {
transform: translateX(-100%);
visibility: hidden;
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
visibility 0s linear var(--ds-transition-duration-slow);
}
.frame[data-sidebar-drawer][data-sidebar-instant] .sidebarCol {
transition: none;
}
.drawerMask {
position: absolute;
inset: var(--dsh-frame-chrome-top, 0px) 0 0;
z-index: 70;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
opacity: 0;
pointer-events: none;
transition: opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.frame[data-sidebar-drawer]:not([data-sidebar-collapsed]) .drawerMask {
opacity: 1;
pointer-events: auto;
}
.frame[data-sidebar-drawer] .leadingSeat {
top: 11px;
left: 12px;
}
:global([data-platform='darwin']) .frame[data-sidebar-drawer] {
--dsh-frame-leading-clearance: 160px;
}
:global([data-platform='darwin']) .frame[data-sidebar-drawer] .leadingSeat {
left: 88px;
}
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] {
--dsh-frame-leading-clearance: 84px;
}
:global([data-platform='darwin'][data-fullscreen]) .frame[data-sidebar-drawer] .leadingSeat {
left: 12px;
}
@media (prefers-reduced-motion: reduce) {
.frame[data-sidebar-drawer] .sidebarCol,
.frame[data-sidebar-drawer][data-sidebar-collapsed] .sidebarCol,
.drawerMask {
transition: none;
}
}
.bottomRow {
grid-column: 1 / -1;
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--dsw-alias-bg-base);
}
.overlayLayer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
.overlayLayer > * {
pointer-events: auto;
}
@@ -0,0 +1,121 @@
/* Layout vars published by DSH shells that we don't mount wholesale. */
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
}
body {
font-family: var(--dsw-font-family);
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-base);
-webkit-font-smoothing: antialiased;
}
html[data-ds-dark-theme],
body[data-ds-dark-theme] {
color-scheme: dark;
}
/* WebKitGTK: component sheets still set corner-shape on pills; neutralize. */
*,
*::before,
*::after {
corner-shape: initial !important;
}
/* Ensure dark aliases win even if a sheet only keyed body without attribute specificity. */
html[data-ds-dark-theme] body,
body[data-ds-dark-theme] {
background-color: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
}
/* Sidebar row metrics (normally on SidebarRoot). */
.hermit-sidebar {
--dsh-sidebar-inline-padding: 8px;
--dsh-sidebar-row-padding: 12px;
--dsh-sidebar-label-gap: 6px;
--dsh-sidebar-paint-inset: 3px;
box-sizing: border-box;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
padding: 6px var(--dsh-sidebar-inline-padding);
background: var(--dsw-specific-sidebar-fill);
}
/* Conversation width axis fallback when column width isn't measured. */
.hermit-conversation-body {
--dsh-conversation-column-width: 100%;
--dsh-chat-content-width: clamp(680px, 64vw, 920px);
--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);
--dsh-composer-side-clearance: 16px;
--dsh-composer-dock-inset: 8px;
--dsh-composer-text-max-height: 336px;
}
@media (max-width: 900px) {
.hermit-conversation-body {
--dsh-chat-content-width: min(calc(100% - 32px), 920px);
--dsh-composer-card-max-width: min(calc(100% - 16px), 952px);
--dsh-composer-side-clearance: 8px;
}
}
/* Engine / host status chip in conversation header */
.hermit-engine-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
font-family: var(--ds-font-family-code);
font-size: 11px;
line-height: 16px;
letter-spacing: 0.02em;
border: 0.5px solid var(--dsw-alias-border-l3);
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
text-transform: uppercase;
}
.hermit-engine-chip[data-mode="engine"] {
color: var(--dsw-alias-state-success-primary, #3dd68c);
border-color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #3dd68c) 40%, transparent);
}
.hermit-engine-chip[data-mode="host"],
.hermit-engine-chip[data-mode="browser"] {
color: var(--dsw-alias-state-warn-primary);
border-color: color-mix(in srgb, var(--dsw-alias-state-warn-primary) 40%, transparent);
}
.hermit-engine-banner {
width: 100%;
max-width: var(--dsh-composer-card-max-width);
margin: 0 auto 8px;
padding: 8px 12px;
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 12px;
line-height: 18px;
}
.hermit-brand {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px 14px;
color: var(--dsw-alias-label-primary);
font-weight: 600;
font-size: 15px;
line-height: 20px;
letter-spacing: -0.01em;
user-select: none;
}
@@ -0,0 +1,39 @@
/* Base variables referenced by the token sheets and component CSS but defined
* upstream (deepsuite theme/global.css) — supplied here so the composite
* --dsw-font-* variables resolve and motion rides the upstream curve. Code
* font stack deliberately omits a bare `monospace` tail (Windows CJK falls
* back to SimSun otherwise). */
:root {
--dsh-font-family-text-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
--dsw-font-family: var(--dsh-font-family-text-default);
--dsw-font-family-brand: 'Montserrat', var(--dsh-font-family-text-default);
--dsh-font-family-code-default: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
--ds-font-family-code: var(--dsh-font-family-code-default);
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ds-transition-duration: 0.2s;
--ds-transition-duration-fast: 0.1s;
--ds-transition-duration-slow: 0.3s;
--dsw-radius-xs: 4px;
--dsw-radius-sm: 8px;
--dsw-radius-md: 12px;
--dsw-radius-lg: 16px;
--dsw-radius-xl: 20px;
--dsw-radius-panel: 28px;
}
/* User font lists (--dsh-font-family-text/-code, written on body by the theme
* boot script and ThemePresenter) lead the built-in stacks. An unset list
* falls back to the built-in stack itself; the repeated families are inert. */
body {
--dsw-font-family: var(--dsh-font-family-text, var(--dsh-font-family-text-default)), var(--dsh-font-family-text-default);
--ds-font-family-code: var(--dsh-font-family-code, var(--dsh-font-family-code-default)), var(--dsh-font-family-code-default);
--dsw-alias-settings-card-fill: var(--dsw-alias-bg-layer-2);
--dsw-alias-settings-card-stroke: var(--dsw-alias-border-l4);
}
[data-dsh-automatic-focus]:focus,
[data-dsh-automatic-focus]:focus:focus-visible {
outline: none;
}
@@ -0,0 +1,26 @@
/* Smooth corner curvature for every rounded surface. Inside the support
* guard, all corners curve along superellipse(1.5) — between a circular arc
* (round = superellipse(1)) and a squircle (superellipse(2)) — instead of the
* default arc; engines without corner-shape keep circular corners because the
* token and the rule exist only inside the guard.
*
* corner-shape does not inherit, so the universal selector applies the token
* to elements and their ::before/::after; other pseudo-elements (for example
* the ::-webkit-scrollbar parts) stay circular.
*
* Full-round shapes opt out where they are declared: a superellipse deforms a
* circle into a squircle and squares off capsule ends, so every effectively
* uncapped radius (50%, 100%, or a pill radius far above the box size) pairs
* `corner-shape: round` with its `border-radius` in the owning component
* sheet. The corner-shape stylesheet spec enforces that pairing. */
@supports (corner-shape: superellipse(1.5)) {
:root {
--dsw-corner-shape: superellipse(1.5);
}
*,
*::before,
*::after {
corner-shape: var(--dsw-corner-shape);
}
}
@@ -0,0 +1,430 @@
/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as
font-weight: 500 in this UI — non-variable webfonts snap intermediate
weights unpredictably across platforms. */
body {
--dsw-static-amber-100: rgb(254, 245, 231);
--dsw-static-amber-400: rgb(247, 173, 49);
--dsw-static-amber-500: rgb(245, 158, 11);
--dsw-static-amber-600: rgb(221, 134, 41);
--dsw-static-amber-900: rgb(39, 36, 31);
--dsw-static-blue-100: rgb(219, 234, 254);
--dsw-static-blue-300: rgb(147, 197, 253);
--dsw-static-blue-400: rgb(96, 165, 250);
--dsw-static-blue-450: rgb(77, 147, 248);
--dsw-static-blue-500: rgb(59, 130, 246);
--dsw-static-blue-50: rgb(239, 246, 255);
--dsw-static-blue-50p: rgb(234, 243, 255);
--dsw-static-blue-600: rgb(37, 99, 235);
--dsw-static-blue-75: rgb(229, 240, 255);
--dsw-static-blue-800: rgb(30, 64, 175);
--dsw-static-blue-900: rgb(14, 48, 116);
--dsw-static-blue-950: rgb(23, 37, 84);
--dsw-static-deepseek-100: rgb(228, 237, 253);
--dsw-static-deepseek-200: rgb(211, 226, 255);
--dsw-static-deepseek-300: rgb(183, 200, 254);
/* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */
--dsw-static-deepseek-400: rgb(122, 170, 255);
--dsw-static-deepseek-450: rgb(86, 134, 254);
--dsw-static-deepseek-500: rgb(65, 118, 230);
--dsw-static-deepseek-50: rgb(237, 243, 254);
--dsw-static-deepseek-600: rgb(72, 104, 178);
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
--dsw-static-deepseek-800: rgb(52, 65, 91);
--dsw-static-deepseek-900: rgb(40, 49, 66);
--dsw-static-green-100: rgb(230, 250, 237);
--dsw-static-green-400: rgb(78, 209, 126);
--dsw-static-green-500: rgb(34, 197, 94);
--dsw-static-green-500-a08: rgb(34 197 94 / 8%);
--dsw-static-green-500-a12: rgb(34 197 94 / 12%);
--dsw-static-green-900: rgb(35, 60, 44);
--dsw-static-neutral-00: rgb(255, 255, 255);
--dsw-static-neutral-1000: rgb(0, 0, 0);
--dsw-static-neutral-100: rgb(245, 245, 245);
--dsw-static-neutral-150: rgb(237, 237, 237);
--dsw-static-neutral-200: rgb(229, 229, 229);
--dsw-static-neutral-250: rgb(220, 220, 220);
--dsw-static-neutral-300: rgb(212, 212, 212);
--dsw-static-neutral-400: rgb(162, 164, 166);
--dsw-static-neutral-500: rgb(127, 130, 135);
--dsw-static-neutral-50: rgb(250, 250, 250);
--dsw-static-neutral-550: rgb(101, 103, 107);
--dsw-static-neutral-600: rgb(84, 85, 87);
--dsw-static-neutral-700: rgb(60, 60, 61);
--dsw-static-neutral-800: rgb(41, 41, 41);
--dsw-static-neutral-850: rgb(33, 33, 35);
--dsw-static-neutral-900: rgb(15, 15, 15);
--dsw-static-neutral-bluish-00: rgb(255, 255, 255);
--dsw-static-neutral-bluish-1000: rgb(15, 17, 21);
--dsw-static-neutral-bluish-100: rgb(235, 238, 242);
--dsw-static-neutral-bluish-150: rgb(233, 236, 242);
--dsw-static-neutral-bluish-200: rgb(225, 229, 238);
--dsw-static-neutral-bluish-300: rgb(207, 211, 214);
--dsw-static-neutral-bluish-310: rgb(202, 210, 219);
--dsw-static-neutral-bluish-400: rgb(173, 178, 184);
--dsw-static-neutral-bluish-500: rgb(151, 157, 166);
--dsw-static-neutral-bluish-50: rgb(249, 250, 251);
--dsw-static-neutral-bluish-55: rgb(245, 247, 250);
--dsw-static-neutral-bluish-600: rgb(129, 133, 140);
--dsw-static-neutral-bluish-60: rgb(245, 246, 247);
--dsw-static-neutral-bluish-700: rgb(97, 102, 107);
--dsw-static-neutral-bluish-750: rgb(67, 69, 74);
--dsw-static-neutral-bluish-75: rgb(241, 243, 245);
--dsw-static-neutral-bluish-800: rgb(53, 54, 56);
--dsw-static-neutral-bluish-850: rgb(44, 44, 46);
--dsw-static-neutral-bluish-875: rgb(35, 35, 36);
--dsw-static-neutral-bluish-900: rgb(27, 27, 28);
--dsw-static-neutral-bluish-90: rgb(237, 240, 244);
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
--dsw-static-red-100: rgb(254, 226, 226);
--dsw-static-red-400: rgb(242, 90, 90);
--dsw-static-red-400-a12: rgb(242 90 90 / 12%);
--dsw-static-red-500: rgb(239, 68, 68);
--dsw-static-red-50: rgb(254, 242, 242);
--dsw-static-red-600: rgb(236, 19, 19);
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
--dsw-static-red-900: rgb(87, 12, 12);
--dsw-static-violet-400: rgb(167, 139, 250);
--dsw-static-violet-600: rgb(124, 58, 237);
}
body[data-ds-dark-theme] {
--dsw-static-amber-100: rgb(254, 245, 231);
--dsw-static-amber-400: rgb(247, 173, 49);
--dsw-static-amber-500: rgb(245, 158, 11);
--dsw-static-amber-600: rgb(221, 134, 41);
--dsw-static-amber-900: rgb(39, 36, 31);
--dsw-static-blue-100: rgb(219, 234, 254);
--dsw-static-blue-300: rgb(147, 197, 253);
--dsw-static-blue-400: rgb(96, 165, 250);
--dsw-static-blue-450: rgb(77, 147, 248);
--dsw-static-blue-500: rgb(59, 130, 246);
--dsw-static-blue-50: rgb(239, 246, 255);
--dsw-static-blue-50p: rgb(234, 243, 255);
--dsw-static-blue-600: rgb(37, 99, 235);
--dsw-static-blue-75: rgb(229, 240, 255);
--dsw-static-blue-800: rgb(30, 64, 175);
--dsw-static-blue-900: rgb(14, 48, 116);
--dsw-static-blue-950: rgb(23, 37, 84);
--dsw-static-deepseek-100: rgb(228, 237, 253);
--dsw-static-deepseek-200: rgb(211, 226, 255);
--dsw-static-deepseek-300: rgb(183, 200, 254);
/* Brightened from the platform rgb(103, 158, 254) at the designer's request: legibility on the dark toast surface. */
--dsw-static-deepseek-400: rgb(122, 170, 255);
--dsw-static-deepseek-450: rgb(86, 134, 254);
--dsw-static-deepseek-500: rgb(65, 118, 230);
--dsw-static-deepseek-50: rgb(237, 243, 254);
--dsw-static-deepseek-600: rgb(72, 104, 178);
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
--dsw-static-deepseek-800: rgb(52, 65, 91);
--dsw-static-deepseek-900: rgb(40, 49, 66);
--dsw-static-green-100: rgb(230, 250, 237);
--dsw-static-green-400: rgb(78, 209, 126);
--dsw-static-green-500: rgb(34, 197, 94);
--dsw-static-green-500-a08: rgb(34 197 94 / 8%);
--dsw-static-green-500-a12: rgb(34 197 94 / 12%);
--dsw-static-green-900: rgb(35, 60, 44);
--dsw-static-neutral-00: rgb(255, 255, 255);
--dsw-static-neutral-1000: rgb(0, 0, 0);
--dsw-static-neutral-100: rgb(245, 245, 245);
--dsw-static-neutral-150: rgb(237, 237, 237);
--dsw-static-neutral-200: rgb(229, 229, 229);
--dsw-static-neutral-250: rgb(220, 220, 220);
--dsw-static-neutral-300: rgb(212, 212, 212);
--dsw-static-neutral-400: rgb(162, 164, 166);
--dsw-static-neutral-500: rgb(127, 130, 135);
--dsw-static-neutral-50: rgb(250, 250, 250);
--dsw-static-neutral-550: rgb(101, 103, 107);
--dsw-static-neutral-600: rgb(84, 85, 87);
--dsw-static-neutral-700: rgb(60, 60, 61);
--dsw-static-neutral-800: rgb(41, 41, 41);
--dsw-static-neutral-850: rgb(33, 33, 35);
--dsw-static-neutral-900: rgb(15, 15, 15);
--dsw-static-neutral-bluish-00: rgb(255, 255, 255);
--dsw-static-neutral-bluish-1000: rgb(15, 17, 21);
--dsw-static-neutral-bluish-100: rgb(235, 238, 242);
--dsw-static-neutral-bluish-150: rgb(233, 236, 242);
--dsw-static-neutral-bluish-200: rgb(225, 229, 238);
--dsw-static-neutral-bluish-300: rgb(207, 211, 214);
--dsw-static-neutral-bluish-310: rgb(202, 210, 219);
--dsw-static-neutral-bluish-400: rgb(173, 178, 184);
--dsw-static-neutral-bluish-500: rgb(151, 157, 166);
--dsw-static-neutral-bluish-50: rgb(249, 250, 251);
--dsw-static-neutral-bluish-55: rgb(245, 247, 250);
--dsw-static-neutral-bluish-600: rgb(129, 133, 140);
--dsw-static-neutral-bluish-60: rgb(249, 250, 251);
--dsw-static-neutral-bluish-700: rgb(97, 102, 107);
--dsw-static-neutral-bluish-750: rgb(67, 69, 74);
--dsw-static-neutral-bluish-75: rgb(241, 243, 245);
--dsw-static-neutral-bluish-800: rgb(53, 54, 56);
--dsw-static-neutral-bluish-850: rgb(44, 44, 46);
--dsw-static-neutral-bluish-875: rgb(35, 35, 36);
--dsw-static-neutral-bluish-900: rgb(27, 27, 28);
--dsw-static-neutral-bluish-90: rgb(237, 240, 244);
--dsw-static-neutral-bluish-950: rgb(21, 21, 23);
--dsw-static-red-100: rgb(254, 226, 226);
--dsw-static-red-400: rgb(242, 90, 90);
--dsw-static-red-400-a12: rgb(242 90 90 / 12%);
--dsw-static-red-500: rgb(239, 68, 68);
--dsw-static-red-50: rgb(254, 242, 242);
--dsw-static-red-600: rgb(236, 19, 19);
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
--dsw-static-red-900: rgb(87, 12, 12);
--dsw-static-violet-400: rgb(167, 139, 250);
--dsw-static-violet-600: rgb(124, 58, 237);
}
body {
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-00);
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-100);
--dsw-alias-bg-document-selection: color-mix(in srgb, var(--dsw-static-blue-500) 40%, transparent);
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-700);
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-00);
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-00);
--dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-00);
--dsw-alias-bg-mask-1: rgba(0, 0, 0, 0.24);
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.12);
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
--dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7);
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-60);
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-bluish-60);
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-150);
--dsw-alias-bg-skeleton: rgba(0, 0, 0, 0.04);
--dsw-alias-border-inverted2: rgba(0, 0, 0, 0);
--dsw-alias-border-inverted: rgba(0, 0, 0, 0);
--dsw-alias-border-l1: rgba(0, 0, 0, 0.04);
--dsw-alias-border-l2-darkmode-thin: rgba(0, 0, 0, 0.1);
--dsw-alias-border-l2: rgba(0, 0, 0, 0.1);
--dsw-alias-border-l3: rgba(0, 0, 0, 0.12);
--dsw-alias-border-l4: rgba(0, 0, 0, 0.16);
--dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230);
--dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700);
--dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-00);
--dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-00);
--dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-75);
--dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-500);
--dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-100);
--dsw-alias-button-ghost-active-hover: var(--dsw-static-neutral-bluish-150);
--dsw-alias-button-info-fill: var(--dsw-static-deepseek-500);
--dsw-alias-button-info-hover: var(--dsw-static-deepseek-400);
--dsw-alias-button-primary-dimmed: var(--dsw-static-neutral-bluish-100);
--dsw-alias-button-primary-fill: var(--dsw-alias-brand-primary);
--dsw-alias-button-primary-hover: var(--dsw-static-neutral-bluish-750);
--dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36);
--dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5);
--dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6);
--dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-55);
--dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-310);
--dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-310);
--dsw-alias-illustration-inset: var(--dsw-alias-illustration-surface);
--dsw-alias-illustration-shadow: var(--dsw-alias-illustration-surface);
--dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-90);
--dsw-alias-interactive-bg-active: rgba(38, 49, 72, 0.1);
--dsw-alias-interactive-bg-hover-accent: rgba(38, 49, 72, 0.14);
--dsw-alias-interactive-bg-hover-danger: rgba(236, 19, 19, 0.05);
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75);
--dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06);
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400);
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-500) 70%, var(--dsw-static-blue-950));
--dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-deepseek-500) 30%, var(--dsw-static-blue-950));
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200);
--dsw-alias-label-primary-bluish: var(--dsw-static-blue-900);
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950);
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-00);
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00);
--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700);
--dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-1000) 30%, transparent);
--dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600);
--dsw-alias-menu-icon: var(--dsw-static-neutral-bluish-800);
--dsw-alias-link: var(--dsw-static-deepseek-500);
--dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-100);
--dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-50);
--dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-50);
--dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-00);
--dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-75);
--dsw-alias-markdown-inline-code: var(--dsw-static-neutral-50);
--dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-60);
--dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-75);
--dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-200);
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-200);
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-300);
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-300);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100);
--dsw-alias-code-diff-added: var(--dsw-static-green-500-a08);
--dsw-alias-code-diff-deleted: var(--dsw-static-red-600-a08);
--dsw-alias-file-diff-added-bg: rgb(230, 244, 231);
--dsw-alias-file-diff-added-gutter: rgb(237, 247, 237);
--dsw-alias-file-diff-added-marker: rgb(1, 162, 65);
--dsw-alias-file-diff-deleted-bg: rgb(252, 230, 226);
--dsw-alias-file-diff-deleted-gutter: rgb(253, 236, 233);
--dsw-alias-file-diff-deleted-marker: rgb(186, 39, 35);
--dsw-alias-state-done-primary: var(--dsw-static-violet-600);
--dsw-alias-state-error-primary: var(--dsw-static-red-600);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-300);
--dsw-alias-state-success-primary: var(--dsw-static-green-500);
--dsw-alias-state-success-secondary: var(--dsw-static-green-400);
--dsw-alias-state-success-tertiary: var(--dsw-static-green-100);
--dsw-alias-state-warn-label: var(--dsw-static-amber-600);
--dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
--dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
--dsw-alias-state-warn-tertiary: var(--dsw-static-amber-100);
--dsw-alias-switch-thumb: var(--dsw-static-neutral-bluish-00);
--dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-800);
--dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00);
--dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-850);
--dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%);
--dsw-alias-turn-trigger-bg: var(--dsw-alias-markdown-code-block);
--dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-hover);
--dsw-specific-bubble-highlight: var(--dsw-static-deepseek-200);
--dsw-specific-bubble: var(--dsw-static-deepseek-50);
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-00);
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-50);
--dsw-menu-surface-fill: rgba(252, 253, 254, 0.7);
--dsw-alias-menu-group-header-fill: rgba(248, 249, 250, 0.94);
--dsw-specific-menu: var(--dsw-menu-surface-fill);
--dsw-specific-selector: var(--dsw-static-neutral-bluish-60);
--dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-50);
--dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-deepseek-100);
--dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-100);
--dsw-specific-tip: var(--dsw-static-neutral-bluish-60);
background-color: var(--dsw-alias-bg-base);
}
body[data-ds-dark-theme] {
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-950);
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-950);
--dsw-alias-bg-document-selection: color-mix(in srgb, var(--dsw-static-blue-500) 40%, transparent);
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-300);
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-875);
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-850);
--dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-800);
--dsw-alias-bg-mask-1: rgba(0, 0, 0, 0.5);
--dsw-alias-bg-mask-2: rgba(0, 0, 0, 0.2);
--dsw-alias-bg-mask-3: rgba(0, 0, 0, 0.48);
--dsw-alias-bg-mask-photo: rgba(0, 0, 0, 0.88);
--dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7);
--dsw-alias-bg-module-platform: var(--dsw-static-neutral-bluish-800);
--dsw-alias-bg-multi-select: var(--dsw-static-neutral-850);
--dsw-alias-bg-overlay: var(--dsw-static-neutral-bluish-700);
--dsw-alias-bg-skeleton: rgba(255, 255, 255, 0.08);
--dsw-alias-border-inverted2: rgba(255, 255, 255, 0.08);
--dsw-alias-border-inverted: rgba(255, 255, 255, 0.06);
--dsw-alias-border-l1: rgba(255, 255, 255, 0.06);
--dsw-alias-border-l2-darkmode-thin: rgba(255, 255, 255, 0.06);
--dsw-alias-border-l2: rgba(255, 255, 255, 0.12);
--dsw-alias-border-l3: rgba(255, 255, 255, 0.16);
--dsw-alias-border-l4: rgba(255, 255, 255, 0.2);
--dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50);
--dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450);
--dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50);
--dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50);
--dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50);
--dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750);
--dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850);
--dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800);
--dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600);
--dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750);
--dsw-alias-button-ghost-active-hover: var(--dsw-static-neutral-bluish-700);
--dsw-alias-button-info-fill: var(--dsw-static-deepseek-400);
--dsw-alias-button-info-hover: var(--dsw-static-deepseek-500);
--dsw-alias-button-primary-dimmed: var(--dsw-static-neutral-bluish-750);
--dsw-alias-button-primary-fill: var(--dsw-alias-brand-primary);
--dsw-alias-button-primary-hover: var(--dsw-static-neutral-bluish-100);
--dsw-alias-button-tool-bar-fill-invisible: rgba(31, 31, 31, 0.36);
--dsw-alias-button-tool-bar-fill: rgba(84, 85, 87, 0.5);
--dsw-alias-button-tool-bar-hover: rgba(84, 85, 87, 0.6);
--dsw-alias-illustration-background: var(--dsw-static-neutral-bluish-850);
--dsw-alias-illustration-backing: var(--dsw-static-neutral-bluish-750);
--dsw-alias-illustration-detail: var(--dsw-static-neutral-bluish-600);
--dsw-alias-illustration-inset: var(--dsw-alias-illustration-backing);
--dsw-alias-illustration-shadow: var(--dsw-alias-illustration-backing);
--dsw-alias-illustration-surface: var(--dsw-static-neutral-bluish-700);
--dsw-alias-interactive-bg-active: rgba(255, 255, 255, 0.14);
--dsw-alias-interactive-bg-hover-accent: rgba(255, 255, 255, 0.24);
--dsw-alias-interactive-bg-hover-danger: rgba(242, 90, 90, 0.15);
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800);
--dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08);
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600);
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-450) 55%, var(--dsw-static-neutral-bluish-400));
--dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-blue-300) 65%, var(--dsw-static-deepseek-400));
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750);
--dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50);
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100);
--dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800);
--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50);
--dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300);
--dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-00) 45%, transparent);
--dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400);
--dsw-alias-menu-icon: var(--dsw-alias-label-primary-dimmed);
--dsw-alias-link: var(--dsw-static-deepseek-400);
--dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-800);
--dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-850);
--dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-900);
--dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-800);
--dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-900);
--dsw-alias-markdown-inline-code: var(--dsw-static-neutral-800);
--dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-850);
--dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-850);
--dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-700);
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600);
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600);
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
--dsw-alias-code-diff-added: var(--dsw-static-green-500-a12);
--dsw-alias-code-diff-deleted: var(--dsw-static-red-400-a12);
--dsw-alias-file-diff-added-bg: rgb(31, 49, 36);
--dsw-alias-file-diff-added-gutter: rgb(19, 32, 22);
--dsw-alias-file-diff-added-marker: rgb(65, 201, 119);
--dsw-alias-file-diff-deleted-bg: rgb(60, 31, 27);
--dsw-alias-file-diff-deleted-gutter: rgb(40, 19, 14);
--dsw-alias-file-diff-deleted-marker: rgb(250, 66, 62);
--dsw-alias-state-done-primary: var(--dsw-static-violet-400);
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-600);
--dsw-alias-state-success-primary: var(--dsw-static-green-500);
--dsw-alias-state-success-secondary: var(--dsw-static-green-400);
--dsw-alias-state-success-tertiary: var(--dsw-static-green-900);
--dsw-alias-state-warn-label: var(--dsw-static-amber-600);
--dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
--dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
--dsw-alias-state-warn-tertiary: var(--dsw-static-amber-900);
--dsw-alias-switch-thumb: var(--dsw-static-neutral-bluish-400);
--dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-750);
--dsw-alias-toast-label: var(--dsw-static-neutral-bluish-00);
--dsw-alias-turn-trigger-bg: var(--dsw-alias-interactive-bg-hover);
--dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-active);
--dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-750);
--dsw-alias-tooltip-key-bg: color-mix(in srgb, var(--dsw-alias-tooltip-bg), white 18%);
--dsw-specific-bubble-highlight: var(--dsw-static-neutral-bluish-750);
--dsw-specific-bubble: var(--dsw-static-neutral-bluish-850);
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-850);
--dsw-specific-login-input: var(--dsw-static-neutral-bluish-900);
--dsw-menu-surface-fill: rgba(48, 49, 54, 0.64);
--dsw-alias-menu-group-header-fill: rgba(48, 49, 54, 0.94);
--dsw-specific-menu: var(--dsw-menu-surface-fill);
--dsw-specific-selector: var(--dsw-static-neutral-bluish-800);
--dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-900);
--dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-neutral-bluish-800);
--dsw-specific-sidebar-nav-item-active: var(--dsw-static-neutral-bluish-750);
--dsw-specific-tip: var(--dsw-static-neutral-bluish-800);
}
/* Overlays without MenuSurface's opaque backing cannot blur page content over
* native vibrancy reliably, so their fill stays nearly opaque on macOS. */
html[data-platform='darwin'] body {
--dsw-specific-menu: rgba(248, 249, 250, 0.94);
}
html[data-platform='darwin'] body[data-ds-dark-theme] {
--dsw-specific-menu: rgba(48, 49, 54, 0.94);
}
@@ -0,0 +1,21 @@
/* Components own offsets; dense tables and toolbars may keep a 1px ring. */
:root {
--dsw-focus-ring-width: 2px;
}
/* The fallback names the colour and the standard width, never the style: a control that
* disables its outline keeps `none` and paints nothing. Without the width, Chromium's
* unnamed ring stayed at `auto 1px` while every declared ring used the standard geometry.
* Declared rings also read this color, including rings on descendants and pseudo-elements. */
:focus-visible {
outline-color: var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-width: var(--dsw-focus-ring-width);
}
/* ui-primitives/input-modality publishes the attribute. Editable controls keep
* their focus feedback on click; other controls reveal rings on keyboard
* navigation. Only ring paint is suppressed, not elevation or selected borders. */
html[data-input-modality='pointer'] body :focus-visible:not(:read-write) {
--dsw-focus-ring-color: transparent;
outline-color: transparent;
}
@@ -0,0 +1,275 @@
body {
--dsw-linear-gradient-think: linear-gradient(180deg, #fff 20.19%, rgba(255, 255, 255, 0) 100%);
--dsw-linear-think-select: linear-gradient(180deg, #f5f6f7 20.19%, rgba(245, 246, 247, 0) 100%);
/* 阴影 */
--dsw-shadow-lv1: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
--dsw-shadow-lv1-blur: 0 4px 12px 0 rgba(0, 0, 0, 0.02);
--dsw-shadow-lv2: 0 4px 12px 0 rgba(0, 0, 0, 0.02), 0 2px 8px 0 rgba(0, 0, 0, 0.04);
--dsw-shadow-lv3:
0 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 4px 0 rgba(0, 0, 0, 0.02), 0 12px 32px 0 rgba(0, 0, 0, 0.08);
/* Elevation:浮层/面板/面型按钮的描边+投影三件套。0.5px 发丝描边画在
box-shadow 里(组件配 border: 0,不占布局),叠两层极淡柔光;深色主题下
柔光几乎不可见,由描边承担分离。描边颜色经 --dsw-elevation-stroke-color
间接,组件可重绑(菜单面重绑最浅的 l1、输入框重绑 l2,输入框的
workspace-trigger 态置 transparent 只保留柔光)。soft 供输入框:更大
模糊、更低透明度的柔光。默认色只声明在 body 上,让表面的重绑沿继承
传给真正消费投影的后代。 */
--dsw-elevation-stroke-color: var(--dsw-alias-border-l4);
/* Blocking masks dim the page; menu backgrounds retain their blur. */
--dsw-mask-blur: none;
--dsw-menu-backdrop-filter: blur(40px) saturate(150%);
}
body[data-ds-dark-theme] [data-menu-material] {
--dsw-elevation-stroke-color: var(--dsw-alias-border-l3);
}
/* 派生 elevation 值逐元素声明而非从 body 继承:继承传下来的是在 body 上就
已替换完 var() 的值,组件重绑 --dsw-elevation-stroke-color 将无法进入
var(--dsw-elevation-*);逐元素声明让每个元素按自己看到的描边色重新替换
(同 scrollbar.css 对 --dsh-scrollbar-thumb 的处理)。 */
body,
body * {
--dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color);
--dsw-elevation-panel:
var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.03), 0 0 16px 0 rgba(0, 0, 0, 0.02);
--dsw-elevation-prominent:
var(--dsw-elevation-stroke), 0 3px 8px 0 rgba(0, 0, 0, 0.04), 0 0 20px 0 rgba(0, 0, 0, 0.05);
--dsw-elevation-soft:
var(--dsw-elevation-stroke), 0 4px 16px 0 rgba(0, 0, 0, 0.03), 0 0 24px 0 rgba(0, 0, 0, 0.03);
}
body[data-ds-dark-theme] {
--dsw-linear-gradient-think: linear-gradient(180deg, #151517 20.19%, rgba(21, 21, 23, 0) 100%);
--dsw-linear-think-select: linear-gradient(180deg, #232325 20.19%, rgba(35, 35, 37, 0) 100%);
}
/* ---------- 字体 ---------- */
/* 由 @deepseek-figma-plugin/custom-variable-name 插件导出 */
/* Markdown 阶梯整体按 0.875(正文 16 → 14)等比缩放,四舍五入到整数像素;
重新从 Figma 导出时需要保留这一缩放。 */
/* Bootstrap and ThemePresenter set the integer content size on body (10–22px,
default 14px). Headings and body variants share its pixel delta. Secondary
text uses size − 1 through 14px, then size − 2: 10→9, 14→13, 15→13, 22→20.
max(13px, ...) selects the delta, not a font-size floor. Small typography keeps a
fixed size; code typography follows the separate code size setting. */
body {
--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px);
--dsh-content-font-size-secondary: min(calc(var(--dsh-content-font-size, 14px) - 1px), max(13px, calc(var(--dsh-content-font-size, 14px) - 2px)));
--dsh-content-font-delta-secondary: calc(var(--dsh-content-font-size-secondary) - 13px);
/* Code size setting (--dsh-code-font-size, integer px 10–16, default 11)
is the code-block size; inline code and the small tool variant move by
the same increment. */
--dsh-code-font-delta: calc(var(--dsh-code-font-size, 11px) - 11px);
--dsw-font-markdown-h1: 700 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-h1-font-family: var(--dsw-font-family);
--dsw-font-markdown-h1-font-weight: 700;
--dsw-font-markdown-h1-line-height: calc(30px + var(--dsh-content-font-delta));
--dsw-font-markdown-h1-font-size: calc(21px + var(--dsh-content-font-delta));
--dsw-font-markdown-h1-font-style: normal;
--dsw-font-markdown-h2: 700 calc(19px + var(--dsh-content-font-delta)) / calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-h2-font-family: var(--dsw-font-family);
--dsw-font-markdown-h2-font-weight: 700;
--dsw-font-markdown-h2-line-height: calc(28px + var(--dsh-content-font-delta));
--dsw-font-markdown-h2-font-size: calc(19px + var(--dsh-content-font-delta));
--dsw-font-markdown-h2-font-style: normal;
--dsw-font-markdown-h3: 700 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-h3-font-family: var(--dsw-font-family);
--dsw-font-markdown-h3-font-weight: 700;
--dsw-font-markdown-h3-line-height: calc(26px + var(--dsh-content-font-delta));
--dsw-font-markdown-h3-font-size: calc(18px + var(--dsh-content-font-delta));
--dsw-font-markdown-h3-font-style: normal;
--dsw-font-markdown-h4: 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-h4-font-family: var(--dsw-font-family);
--dsw-font-markdown-h4-font-weight: 600;
--dsw-font-markdown-h4-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-h4-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-h4-font-style: normal;
--dsw-font-markdown-base: var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-base-font-family: var(--dsw-font-family);
--dsw-font-markdown-base-font-weight: 400;
--dsw-font-markdown-base-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-base-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-base-font-style: normal;
--dsw-font-markdown-base-strong: 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-base-strong-font-family: var(--dsw-font-family);
--dsw-font-markdown-base-strong-font-weight: 600;
--dsw-font-markdown-base-strong-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-base-strong-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-base-strong-font-style: normal;
--dsw-font-markdown-base-italic: italic var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-base-italic-font-family: var(--dsw-font-family);
--dsw-font-markdown-base-italic-font-weight: 400;
--dsw-font-markdown-base-italic-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-base-italic-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-base-italic-font-style: italic;
--dsw-font-markdown-base-strong-italic: italic 600 var(--dsh-content-font-size, 14px) / calc(24px + var(--dsh-content-font-delta)) var(--dsw-font-family);
--dsw-font-markdown-base-strong-italic-font-family: var(--dsw-font-family);
--dsw-font-markdown-base-strong-italic-font-weight: 600;
--dsw-font-markdown-base-strong-italic-line-height: calc(24px + var(--dsh-content-font-delta));
--dsw-font-markdown-base-strong-italic-font-size: var(--dsh-content-font-size, 14px);
--dsw-font-markdown-base-strong-italic-font-style: italic;
--dsw-font-markdown-table: var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family);
--dsw-font-markdown-table-font-family: var(--dsw-font-family);
--dsw-font-markdown-table-font-weight: 400;
--dsw-font-markdown-table-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px));
--dsw-font-markdown-table-font-size: var(--dsh-content-font-size-secondary, 13px);
--dsw-font-markdown-table-font-style: normal;
--dsw-font-markdown-table-head: 500 var(--dsh-content-font-size-secondary, 13px)/calc(22px + var(--dsh-content-font-delta-secondary, 0px)) var(--dsw-font-family);
--dsw-font-markdown-table-head-font-family: var(--dsw-font-family);
--dsw-font-markdown-table-head-font-weight: 500;
--dsw-font-markdown-table-head-line-height: calc(22px + var(--dsh-content-font-delta-secondary, 0px));
--dsw-font-markdown-table-head-font-size: var(--dsh-content-font-size-secondary, 13px);
--dsw-font-markdown-table-head-font-style: normal;
--dsw-font-markdown-small: 12px/20px var(--dsw-font-family);
--dsw-font-markdown-small-font-family: var(--dsw-font-family);
--dsw-font-markdown-small-font-weight: 400;
--dsw-font-markdown-small-line-height: 20px;
--dsw-font-markdown-small-font-size: 12px;
--dsw-font-markdown-small-font-style: normal;
--dsw-font-markdown-small-strong: 600 12px/20px var(--dsw-font-family);
--dsw-font-markdown-small-strong-font-family: var(--dsw-font-family);
--dsw-font-markdown-small-strong-font-weight: 600;
--dsw-font-markdown-small-strong-line-height: 20px;
--dsw-font-markdown-small-strong-font-size: 12px;
--dsw-font-markdown-small-strong-font-style: normal;
--dsw-font-markdown-small-italic: italic 12px/20px var(--dsw-font-family);
--dsw-font-markdown-small-italic-font-family: var(--dsw-font-family);
--dsw-font-markdown-small-italic-font-weight: 400;
--dsw-font-markdown-small-italic-line-height: 20px;
--dsw-font-markdown-small-italic-font-size: 12px;
--dsw-font-markdown-small-italic-font-style: italic;
--dsw-font-markdown-small-strong-italic: italic 600 12px/20px var(--dsw-font-family);
--dsw-font-markdown-small-strong-italic-font-family: var(--dsw-font-family);
--dsw-font-markdown-small-strong-italic-font-weight: 600;
--dsw-font-markdown-small-strong-italic-line-height: 20px;
--dsw-font-markdown-small-strong-italic-font-size: 12px;
--dsw-font-markdown-small-strong-italic-font-style: italic;
--dsw-font-markdown-code: calc(12px + var(--dsh-code-font-delta)) / calc(19px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
--dsw-font-markdown-code-font-family: var(--ds-font-family-code);
--dsw-font-markdown-code-font-weight: 400;
--dsw-font-markdown-code-line-height: calc(19px + var(--dsh-code-font-delta));
--dsw-font-markdown-code-font-size: calc(12px + var(--dsh-code-font-delta));
--dsw-font-markdown-code-font-style: normal;
--dsw-font-markdown-code-block: var(--dsh-code-font-size, 11px) / calc(19px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
--dsw-font-markdown-code-block-font-family: var(--ds-font-family-code);
--dsw-font-markdown-code-block-font-weight: 400;
--dsw-font-markdown-code-block-line-height: calc(19px + var(--dsh-code-font-delta));
--dsw-font-markdown-code-block-font-size: var(--dsh-code-font-size, 11px);
--dsw-font-markdown-code-block-font-style: normal;
/* 手工补充(非插件导出):tool row 展开卡片内的小号 code 字体。 */
--dsw-font-markdown-code-block-small: var(--dsh-code-font-size, 11px) / calc(16px + var(--dsh-code-font-delta)) var(--ds-font-family-code);
--dsw-font-markdown-code-block-small-font-family: var(--ds-font-family-code);
--dsw-font-markdown-code-block-small-font-weight: 400;
--dsw-font-markdown-code-block-small-line-height: calc(16px + var(--dsh-code-font-delta));
--dsw-font-markdown-code-block-small-font-size: var(--dsh-code-font-size, 11px);
--dsw-font-markdown-code-block-small-font-style: normal;
--dsw-font-xl-24: 600 24px/32px var(--dsw-font-family);
--dsw-font-xl-24-font-family: var(--dsw-font-family);
--dsw-font-xl-24-font-weight: 600;
--dsw-font-xl-24-line-height: 32px;
--dsw-font-xl-24-font-size: 24px;
--dsw-font-xl-24-font-style: normal;
--dsw-font-l-20: 500 20px/28px var(--dsw-font-family);
--dsw-font-l-20-font-family: var(--dsw-font-family);
--dsw-font-l-20-font-weight: 500;
--dsw-font-l-20-line-height: 28px;
--dsw-font-l-20-font-size: 20px;
--dsw-font-l-20-font-style: normal;
--dsw-font-m-18: 500 16px/28px var(--dsw-font-family);
--dsw-font-m-18-font-family: var(--dsw-font-family);
--dsw-font-m-18-font-weight: 500;
--dsw-font-m-18-line-height: 28px;
--dsw-font-m-18-font-size: 16px;
--dsw-font-m-18-font-style: normal;
--dsw-font-base-16: 16px/24px var(--dsw-font-family);
--dsw-font-base-16-font-family: var(--dsw-font-family);
--dsw-font-base-16-font-weight: 400;
--dsw-font-base-16-line-height: 24px;
--dsw-font-base-16-font-size: 16px;
--dsw-font-base-16-font-style: normal;
--dsw-font-base-strong-16: 500 16px/24px var(--dsw-font-family);
--dsw-font-base-strong-16-font-family: var(--dsw-font-family);
--dsw-font-base-strong-16-font-weight: 500;
--dsw-font-base-strong-16-line-height: 24px;
--dsw-font-base-strong-16-font-size: 16px;
--dsw-font-base-strong-16-font-style: normal;
--dsw-font-s-14: 14px/22px var(--dsw-font-family);
--dsw-font-s-14-font-family: var(--dsw-font-family);
--dsw-font-s-14-font-weight: 400;
--dsw-font-s-14-line-height: 22px;
--dsw-font-s-14-font-size: 14px;
--dsw-font-s-14-font-style: normal;
--dsw-font-s-strong-14: 500 14px/22px var(--dsw-font-family);
--dsw-font-s-strong-14-font-family: var(--dsw-font-family);
--dsw-font-s-strong-14-font-weight: 500;
--dsw-font-s-strong-14-line-height: 22px;
--dsw-font-s-strong-14-font-size: 14px;
--dsw-font-s-strong-14-font-style: normal;
--dsw-font-xs-13: 13px/20px var(--dsw-font-family);
--dsw-font-xs-13-font-family: var(--dsw-font-family);
--dsw-font-xs-13-font-weight: 400;
--dsw-font-xs-13-line-height: 20px;
--dsw-font-xs-13-font-size: 13px;
--dsw-font-xs-13-font-style: normal;
--dsw-font-xs-strong-13: 500 13px/20px var(--dsw-font-family);
--dsw-font-xs-strong-13-font-family: var(--dsw-font-family);
--dsw-font-xs-strong-13-font-weight: 500;
--dsw-font-xs-strong-13-line-height: 20px;
--dsw-font-xs-strong-13-font-size: 13px;
--dsw-font-xs-strong-13-font-style: normal;
--dsw-font-xxs-12: 12px/18px var(--dsw-font-family);
--dsw-font-xxs-12-font-family: var(--dsw-font-family);
--dsw-font-xxs-12-font-weight: 400;
--dsw-font-xxs-12-line-height: 18px;
--dsw-font-xxs-12-font-size: 12px;
--dsw-font-xxs-12-font-style: normal;
--dsw-font-xxs-strong-12: 500 12px/18px var(--dsw-font-family);
--dsw-font-xxs-strong-12-font-family: var(--dsw-font-family);
--dsw-font-xxs-strong-12-font-weight: 500;
--dsw-font-xxs-strong-12-line-height: 18px;
--dsw-font-xxs-strong-12-font-size: 12px;
--dsw-font-xxs-strong-12-font-style: normal;
--dsw-font-xxxs-11: 11px/14px var(--dsw-font-family);
--dsw-font-xxxs-11-font-family: var(--dsw-font-family);
--dsw-font-xxxs-11-font-weight: 400;
--dsw-font-xxxs-11-line-height: 14px;
--dsw-font-xxxs-11-font-size: 11px;
--dsw-font-xxxs-11-font-style: normal;
--dsw-font-xxxs-strong-11: 500 11px/14px var(--dsw-font-family);
--dsw-font-xxxs-strong-11-font-family: var(--dsw-font-family);
--dsw-font-xxxs-strong-11-font-weight: 500;
--dsw-font-xxxs-strong-11-line-height: 14px;
--dsw-font-xxxs-strong-11-font-size: 11px;
--dsw-font-xxxs-strong-11-font-style: normal;
}
@@ -0,0 +1,97 @@
/* Scrollbar skin: the sole consumer of the four --dsw-alias-scrollbar-*
* tokens. Without it every scrolling region renders the UA scrollbar, which
* ignores the theme — a light native bar over the dark palette.
*
* The rules sit on `body`, not `html`: design-platform.css declares the
* --dsw-alias-* tokens on `body` (and the dark overrides on
* `body[data-ds-dark-theme]`), and custom properties only inherit downward,
* so an `html` rule resolves them to the guaranteed-invalid value and
* `scrollbar-color` falls back to `auto`.
*
* Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}:
* the l1 pair here is the base-surface default, and an elevated surface
* (menu, popover, dialog) rebinds to the l2 pair on its own container. A
* scroll container may also rebind the three geometry variables below. */
body {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
--dsh-scrollbar-thumb-border: 0px;
--dsh-scrollbar-track-margin: 0px;
/* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule
below. A surface that must align itself beside a space-consuming bar
(ConversationRoot's overlay composer seat) reads this instead of
hardcoding the number. */
--dsh-scrollbar-width: 5px;
}
/* The two paths are mutually exclusive, and the gate is load-bearing rather
than defensive. A non-`auto` `scrollbar-width` or `scrollbar-color` makes
Chromium and Safari drop every `::-webkit-scrollbar*` rule for that
element, including `::-webkit-scrollbar-thumb:hover` — measured in chromium
as a `::-webkit-scrollbar` width taking effect on its own and being
ignored as soon as `scrollbar-width: thin` is added. Declaring both
unconditionally therefore leaves the hover tokens with no rendering at all,
because the engines that implement the hover pseudo-element are exactly the
ones the standard properties silence, and Firefox has no hover
pseudo-element to fall back on.
`not selector(::-webkit-scrollbar)` is true only where the pseudo-element
is unimplemented, so Firefox takes the standard path and WebKit-based
engines take the pseudo-element path. An engine too old for the
`selector()` function makes the condition invalid, which evaluates false
and selects the pseudo-element path — the correct side for the pre-16.4
Safari that is the realistic case. */
@supports not selector(::-webkit-scrollbar) {
/* Declared on every element rather than inherited from `body`. Inheriting
would pass down the COLOUR already substituted at `body`, so a descendant
rebinding --dsh-scrollbar-thumb could not change it; re-declaring makes
each element substitute the variable as it sees it, which is what gives
an elevated surface a working rebind. `scrollbar-width` is not an
inherited property at all, so it needs the per-element declaration
regardless.
No hover counterpart exists on this path: `scrollbar-color` states one
thumb colour and the engine derives its own hover treatment. */
body,
body * {
scrollbar-width: thin;
scrollbar-color: var(--dsh-scrollbar-thumb) transparent;
}
}
/* Not gated in turn: an engine that does not implement these pseudo-elements
drops the rules as unknown selectors, so the gate would only restate what
selector matching already does. Not inherited either, hence the unscoped
selectors. */
::-webkit-scrollbar {
width: var(--dsh-scrollbar-width);
height: var(--dsh-scrollbar-width);
}
/* Track stays transparent so the thumb reads against whatever surface scrolls
under it; only the thumb carries a token colour. */
::-webkit-scrollbar-track {
margin-block: var(--dsh-scrollbar-track-margin);
background: transparent;
}
::-webkit-scrollbar-thumb {
border: var(--dsh-scrollbar-thumb-border) solid transparent;
border-radius: 999px;
corner-shape: round;
background: var(--dsh-scrollbar-thumb);
background-clip: content-box;
}
@media (hover: hover) {
::-webkit-scrollbar-thumb:hover {
background-color: var(--dsh-scrollbar-thumb-hover);
}
}
/* Both scrollbars meeting in a corner: no separate token, so the corner
matches the transparent track rather than the UA's opaque default. */
::-webkit-scrollbar-corner {
background: transparent;
}
@@ -0,0 +1,491 @@
.projectRow,
.sessionRow {
display: flex;
align-items: center;
gap: var(--dsh-sidebar-label-gap);
border-radius: var(--dsw-radius-md);
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
background-clip: padding-box;
box-sizing: border-box;
padding: 0 8px;
padding-inline-start: calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset) + var(--dsh-workspace-indent, 0px));
cursor: pointer;
-webkit-user-select: none;
user-select: none;
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.projectRow:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.sessionRow.selected {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.dragPreview {
background: var(--dsw-specific-sidebar-fill);
}
.searchResultRow {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
min-height: 48px;
box-sizing: border-box;
border: none;
border-radius: var(--dsw-radius-lg);
padding: 4px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
background: transparent;
background-clip: padding-box;
cursor: pointer;
text-align: left;
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.searchResultRow:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.searchResultRow.selected {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.searchResultHeading {
display: flex;
align-items: center;
min-width: 0;
}
.searchResultTitle {
flex: 0 1 auto;
min-width: 0;
margin: 0 var(--dsh-sidebar-label-gap);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 20px;
}
.searchResultMeta {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
margin-left: calc(16px + var(--dsh-sidebar-label-gap));
}
.searchResultWorkspace,
.searchResultSnippet {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 17px;
}
.searchResultWorkspace {
flex: none;
max-width: 40%;
color: var(--dsw-alias-label-tertiary);
}
/* The 40% share only protects a snippet; a snippetless match keeps the row. */
.searchResultWorkspace:only-child {
max-width: 100%;
}
.searchResultSnippet {
flex: 1;
min-width: 0;
color: var(--dsw-alias-label-secondary);
}
/* Compact one-line Workspace row after removing the session-count subtitle. */
.projectRow {
height: 34px;
align-items: center;
box-sizing: border-box;
}
.projectRow .rowActions {
height: 20px;
}
/* Session titles share the workspace icon slot and compact label gap. */
.sessionRow {
height: 32px;
gap: 0;
}
.sessionRow .title {
margin: 0 6px 0 var(--dsh-sidebar-label-gap);
}
.slot {
flex: none;
width: 16px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.folderActive {
color: var(--dsw-alias-state-business-primary);
}
/* Project leading slot: folder by default, expand arrow on row hover. */
.projectRow .chevron { display: none; }
@media (hover: hover) {
.projectRow:hover .chevron { display: inline-flex; }
.projectRow:hover .folder { display: none; }
}
/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
.arrow {
transition: transform 150ms var(--ds-ease-in-out);
}
.arrowOpen {
transform: rotate(90deg);
}
.projectText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 20px;
}
.renameInput {
min-width: 0;
font-size: 14px;
line-height: 20px;
padding: 0 2px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-sm);
background: var(--dsw-alias-button-elevated-fill);
color: inherit;
outline: none;
}
/* Hovering a session row marquees a title wider than its cell: Rows.tsx crawls
it at a constant speed to its far edge while the pointer rests on the row,
holds it there, and returns it to the start in one step when the pointer
leaves. The motion is scripted frame by frame (reduced motion jumps), so no
scroll-behavior applies here. */
.sessionRow .title {
flex: 1;
}
/* While the marquee travels, both clipping edges fade in over 12px instead of
hard-cutting a character: the left once the title has moved off its start
(data-scrolled), the right while text still remains beyond the cell
(data-clipped) — at the far edge the right fade lifts so the final character
reads at full strength. The mask rides the title span itself, so the status
slot and every other leading cell keep their full color. */
.sessionRow .title[data-scrolled] {
mask-image: linear-gradient(to right, transparent, #000 12px);
}
.sessionRow .title[data-clipped] {
mask-image: linear-gradient(to left, transparent, #000 12px);
}
.sessionRow .title[data-scrolled][data-clipped] {
mask-image: linear-gradient(to right, transparent, #000 12px, #000 calc(100% - 12px), transparent);
}
.meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
.time {
flex: none;
font-size: 10px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
margin-inline-end: -1px;
}
.pinIndicator {
display: inline-flex;
flex: none;
width: 16px;
height: 20px;
align-items: center;
justify-content: center;
margin-left: 6px;
color: var(--dsw-alias-label-caption);
}
/* Archived rows stay in place but read as inactive: titles drop to
the caption label step (one step above dimmed, which reads as too faint
over the sidebar fill). Archived search rows drop their meta line to the
same step so both lines read as one unit. */
.sessionRow.archived .title,
.searchResultRow.archived .searchResultTitle,
.searchResultRow.archived .searchResultWorkspace,
.searchResultRow.archived .searchResultSnippet {
color: var(--dsw-alias-label-caption);
}
.dot {
flex: none;
}
/* The trailing strip keeps a shared gap between its fixed-size control boxes. */
.rowActions {
flex: none;
display: none;
align-items: center;
gap: 13px;
}
@media (hover: hover) {
.projectRow:hover .rowActions,
.searchResultRow:hover .rowActions {
display: inline-flex;
}
}
.projectRow.menuOpen .rowActions {
display: inline-flex;
}
.rowQuickActions {
display: contents;
}
/* The search heading has no trailing time cell, so the unarchive action
right-aligns itself. */
.searchResultHeading .rowActions {
margin-left: auto;
}
.rowResting {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--dsw-alias-label-tertiary);
}
.rowResting .pinIndicator {
margin-left: 0;
}
/* The unclipped hover state drops the ellipsis, which would otherwise cover
the characters the marquee reached. Guarded because a touch tap
latches `:hover` on the row after the title has already returned: an
unguarded rule would strand that row without its ellipsis. */
@media (hover: hover) {
.sessionRow:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.sessionRow:hover .rowActions,
.sessionRow.menuOpen .rowActions,
:global(html[data-input-modality='keyboard']) .sessionRow:focus-within .rowActions {
display: inline-flex;
}
.sessionRow:hover .rowResting,
.sessionRow.menuOpen .rowResting,
:global(html[data-input-modality='keyboard']) .sessionRow:focus-within .rowResting {
display: none;
}
.sessionRow:hover .title,
.sessionRow.menuOpen .title {
text-overflow: clip;
}
}
@media (hover: none) {
.projectRow .rowActions,
.searchResultRow .rowActions,
.sessionRow .rowActions {
display: inline-flex;
}
/* Only update timestamps disappear; pending-interaction labels remain visible. */
.sessionRow .timestamp,
.sessionRow:has(.pinnedAction) .pinIndicator,
.sessionRow .rowQuickActions > :global([data-slot]) > :not(.pinnedAction),
.projectRow[aria-expanded='false'] .rowActions {
display: none;
}
.sessionRow .pinnedAction {
/* Keep More aligned across rows. Tab order remains More, then Unpin. */
order: -1;
}
}
/* An open row menu pins the hover affordances (figma: the row keeps its
hover fill while its dropdown is up). */
.projectRow.menuOpen,
.sessionRow.menuOpen {
background-color: var(--dsw-alias-interactive-bg-hover);
}
/* Session drag insert marker: a leading chevron and 2px rule between rows,
absolutely positioned so it neither resembles a row border nor changes layout. */
.sessionRow.dropBefore,
.sessionRow.dropAfter {
position: relative;
}
.sessionRow.dropBefore::before,
.sessionRow.dropAfter::after {
content: '';
position: absolute;
z-index: 1;
left: calc(-1 * var(--dsh-sidebar-paint-inset));
right: calc(4px - var(--dsh-sidebar-paint-inset));
height: 12px;
background:
linear-gradient(
55deg,
transparent calc(50% - 1px),
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
transparent calc(50% + 1px)
) 0 0 / 5px 7px no-repeat,
linear-gradient(
125deg,
transparent calc(50% - 1px),
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
transparent calc(50% + 1px)
) 0 5px / 5px 7px no-repeat,
linear-gradient(
var(--dsw-alias-state-business-primary) 0 0
) 4px 5px / calc(100% - 4px) 2px no-repeat;
pointer-events: none;
}
.sessionRow.dropBefore::before {
top: -7px;
}
.sessionRow.dropAfter::after {
bottom: -7px;
}
/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
.hoverContent {
display: flex;
flex-direction: column;
gap: 8px;
}
.hoverTitle {
font-size: 14px;
line-height: 20px;
color: #FFFFFF;
overflow-wrap: break-word;
}
.hoverPath {
font-size: 12px;
line-height: 16px;
color: #CFD3D6;
word-break: break-all;
}
.hoverTime {
font-size: 12px;
line-height: 16px;
color: #CFD3D6;
}
.hoverStatus {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 20px;
color: #ADB2B8;
}
/* Archive glyph rides the status row's text color; slight inset aligns it with the 6px status dots. */
.hoverArchived svg {
flex-shrink: 0;
margin: 0 -4px;
}
/* Compensated margins keep the 24px target on the compact icon's center. */
.iconButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: -4px;
border: none;
border-radius: var(--dsw-radius-xs);
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-tertiary);
}
/* Keep the focus ring on its original compact outline. */
.iconButton:focus-visible {
outline-offset: -2px;
}
@media (hover: hover) {
.iconButton:hover {
color: var(--dsw-alias-label-primary);
}
}
/* Chevrons ride the caption grey (#ADB2B8); the folder glyph stays one step
darker (tertiary, #81858C) per the cell spec. */
.chevron {
color: var(--dsw-alias-label-caption);
}
@media (prefers-reduced-motion: reduce) {
.arrow {
animation: none;
transition: none;
}
}
@@ -0,0 +1,686 @@
/* Sidebar column: vertical stack, padding 8/6, sidebar
fill and desktop divider painted by the layout column. Collapse is a
slide + crossfade, not a morph: the content holds its frozen expanded
layout (inline width set by the component) and fades in place (.fading)
while the sliding column (AppFrame grid tracks) clips it; the rail layout
(.collapsed) only applies after the fade settles, so nothing reflows
mid-slide. */
.root {
--dsh-sidebar-inline-padding: 8px;
--dsh-sidebar-row-padding: 12px;
--dsh-sidebar-label-gap: 6px;
--dsh-sidebar-paint-inset: 3px;
display: flex;
flex-direction: column;
height: 100%;
padding: 6px var(--dsh-sidebar-inline-padding);
box-sizing: border-box;
background: var(--dsw-specific-sidebar-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
/* The sidebar is elevated above the conversation surface, so a revealed
scrollbar uses the l2 pair. .quietBars hides it without changing layout. */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
:global([data-animating]:not([data-dragging])) .root {
transition: width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* macOS desktop: the AppFrame column owns the translucent sidebar tint; an
opaque fill here would sit on top of it and block the window vibrancy. */
:global([data-platform='darwin']) .root {
background: transparent;
}
/* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px
rail (10px side padding), 12px vertical rhythm, 18px from the rail top to
the whale's box (24px to the 24-wide whale glyph itself). */
.root.collapsed {
padding: 18px 10px 6px;
}
/* The brand stays in the sidebar; fixed caption controls survive zero-width collapse. */
:global([data-windows-titlebar]) .root .logoRow {
height: 40px;
margin: 0;
padding: 0;
}
:global([data-windows-titlebar]) .toggle {
position: fixed;
top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
left: 12px;
z-index: 30;
-webkit-app-region: no-drag;
}
:global([data-windows-titlebar]) .root.collapsed .logoRow {
height: 0;
}
:global([data-windows-titlebar]) .root:not(.collapsed) .brand {
padding-left: 8px;
}
:global([data-windows-titlebar]) .brandIdentity {
transform: translateY(1px);
}
:global([data-windows-titlebar]) .brandMark {
transform: translateX(1px);
}
:global([data-windows-titlebar]) .brandName {
font-weight: 400;
}
:global([data-windows-titlebar]) .root.collapsed {
padding: 0;
}
:global([data-windows-titlebar]) .root:not(.collapsed) .newSession {
margin-top: 6px;
}
:global([data-windows-titlebar]) .collapsed .panelList,
:global([data-windows-titlebar]) .collapsed .regionArea,
:global([data-windows-titlebar]) .collapsed .footArea {
display: none;
}
:global(html[data-windows-titlebar]:has([data-sidebar-collapsed='true'])) {
--dsh-windows-menu-start: 84px;
}
:global([data-windows-titlebar]) .collapsed .newSession {
position: fixed;
top: calc((var(--dsh-windows-titlebar-height) - 28px) / 2);
left: 48px;
margin: 0;
padding: 0;
border: none;
z-index: 30;
-webkit-app-region: no-drag;
}
:global([data-windows-titlebar]) .railIn .iconButton,
:global([data-windows-titlebar]) .railIn .newSession {
animation: none;
}
:global([data-windows-titlebar]) .collapsed .toggle,
:global([data-windows-titlebar]) .collapsed .newSession {
width: 28px;
height: 28px;
border-radius: var(--dsw-radius-sm);
color: var(--dsw-alias-label-secondary);
}
:global([data-windows-titlebar]) .collapsed .toggle .panelIcon {
display: inline;
}
/* Scrollbars in the column are a pointer affordance: the shell adds this
class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
and rebinding ui-theme's indirection pair to `transparent` takes the thumb
out of every scroll region nested under it — the workspace browser's
session list. `transparent` rather than `display: none` on the bar:
the reservation (`scrollbar-gutter: stable` on the list) stays in force, so
revealing the thumb never reflows a row. Rebinding contract and the two
rendering paths it reaches: ui-theme's README. */
.root.quietBars {
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
/* Collapse phase 1: the whole frozen-width content fades out in place over
150ms; at settle the children unmount/snap to the rail layout. */
.fading > * {
opacity: 0;
transition: opacity 150ms var(--ds-ease-in-out);
}
/* Wide-only content fades back in on expand remount. */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
}
@keyframes wide-in {
from { opacity: 0; }
}
/* At the 150ms rail settle, the upper controls enter from the former
rail right edge over the remaining 150ms of the AppFrame track transition.
The bottom-pinned settings seat shares their opacity timeline but stays
horizontally fixed. Only a live collapse gets .railIn; a cold collapsed
render stays static. */
.railIn .iconButton,
.railIn .newSession,
.railIn .panelList,
.railIn .regionArea {
animation: rail-in 150ms var(--ds-ease-in-out) backwards;
}
.railIn .footArea {
animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards;
}
@keyframes rail-in {
from {
opacity: 0;
transform: translateX(49px);
}
}
@keyframes rail-fade-in {
from { opacity: 0; }
}
/* macOS hiddenInset titlebar: the strip spans the column's top edge (negative
margins cancel the root padding) so it clears the traffic lights at
(16, 18). The strip marks itself `data-window-drag` in markup (SidebarRoot), so
its own run is the column's draggable geometry and no frame band has to match
the strip's height; the toggle inside subtracts via the base.css rule. */
.topStrip {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
height: 52px;
box-sizing: border-box;
margin: -6px calc(-1 * var(--dsh-sidebar-inline-padding)) 0;
/* The 2px foot centres the 28px toggle at y 25, level with the collapsed
seat (ui-layout .leadingSeat, top 11) and the conversation title row, so
collapse/expand never shifts it vertically. */
padding: 0 12px 2px;
}
:global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed) > .topStrip > .toggle {
transform: translateX(4px);
}
/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop
preload): the toggle takes their spot at the strip's left edge. */
:global([data-fullscreen]) .topStrip {
justify-content: flex-start;
}
/* The strip's full 52px foot leaves the wordmark too low; the content column
tucks 12px back under it, still clear of the toggle (28px control centered
in the strip ends at y 40). */
.topStrip + .logoRow {
margin-top: -12px;
}
/* Logo row: 8px vertical and leading padding, with no trailing padding or
bottom margin. Expanded keeps the panel toggle at the
right edge; the rail gives every control the same base left anchor before
the shared entry translation is applied. macOS hiddenInset titlebar: the row
is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's
single darwin rule carries the drag over its own box — below the top strip
(40–100px: the strip's 52px foot with the row's -12px tuck). The brand rides
that box (SidebarRoot renders the wordmark as a plain span there, so the
global interactive no-drag rule never subtracts it; only the local-build
fallbacks opt out through .copyVersion); the dedicated New
Session button below keeps the pointer and keyboard paths. Safe as a drag
source: it precedes the sidebar's overlay slots in document order, so their
no-drag still wins. */
.logoRow {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 60px;
padding: 8px 0 8px 8px;
margin-bottom: 0;
box-sizing: border-box;
overflow: hidden;
}
.collapsed .logoRow {
height: 36px;
padding: 0;
margin-bottom: 12px;
justify-content: flex-start;
/* The settled rail's 36px toggle is wider than this 35px row; the base row's clip would cut
the control's right edge, so the rail stops clipping once the slide has settled. */
overflow: visible;
}
/* Brand group (figma I133:7632): the full wordmark rides the text ink
(figma flows: the main-screen instance is black; blue is brand
emphasis only). A button only in behavior (New Session shortcut): the
pointer cursor is the sole affordance — no hover chrome on the mark. */
.brand {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
overflow: hidden;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
/* On macOS the brand is a window-drag surface, not a New Session button;
a pointer cursor would promise a click that drags the window instead. */
:global([data-platform='darwin']) .brand {
cursor: default;
}
/* Mark and name are independent slots; the shell owns their shared baseline,
spacing, and height so either occupant can vary in width. */
.brandIdentity {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
min-width: 0;
}
.brandMark {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brandName {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
height: 24px;
font-size: 18px;
font-weight: 600;
line-height: 24px;
letter-spacing: 0.04em;
}
.fallbackBrandName {
font-size: 17px;
letter-spacing: 0px;
white-space: nowrap;
}
.localBuildBrand {
flex: none;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1px;
height: 24px;
white-space: nowrap;
}
/* Local-build fallbacks copy the version, also on macOS, where the
surrounding brand is a window-drag surface. */
.copyVersion {
display: inline-flex;
cursor: pointer;
-webkit-app-region: no-drag;
}
.localBuildTitle {
font-size: 12px;
line-height: 13px;
letter-spacing: 0;
}
.iconButton {
position: relative;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
@media (hover: hover) {
.iconButton:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
/* The toggle is flush with .logoRow, which clips its own overflow, so an outward ring loses
the pixels that leave the row. Draw this control's ring inside its own box instead. */
.iconButton.toggle:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: -2px;
}
.collapsed .iconButton {
width: 36px;
height: 36px;
/* Rail hovers share the panel rows' and New Session's 12px rounding. */
border-radius: var(--dsw-radius-md);
}
/* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink,
no hover circle) and hovering reveals the panel icon — the expand
affordance (figma sidebar-hover flow). Expanded it is a plain panel icon. */
.collapsed .toggle .panelIcon {
display: none;
}
@media (hover: hover) {
.collapsed .toggle:hover .panelIcon {
display: inline;
}
.collapsed .toggle:hover .railMark {
display: none;
}
}
.railMark {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Rail icons ride the primary ink (figma rail spec); expanded keeps the
secondary icon-button ink. */
.collapsed .iconButton {
color: var(--dsw-alias-label-primary);
}
.buildVersion {
flex: none;
display: inline-flex;
align-items: center;
height: 10px;
padding: 0 3px;
border-radius: 2px;
color: var(--dsw-alias-label-primary-inverted);
background: var(--dsw-alias-label-primary);
font-family: var(--ds-font-family-code);
font-size: 6px;
font-weight: 500;
line-height: 10px;
white-space: nowrap;
}
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
corners); collapsed it renders as the rail's plain icon control. */
.newSession {
container-type: inline-size;
position: relative;
flex: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0;
height: 38px;
padding: 8px 12px;
margin: 0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px;
box-sizing: border-box;
border: 0.5px solid var(--dsw-alias-border-l3);
border-radius: var(--dsw-radius-md);
background: var(--dsw-alias-button-elevated-fill);
color: var(--dsw-alias-label-primary);
font-size: 14px;
font-weight: 500;
line-height: 22px;
cursor: pointer;
overflow: hidden;
}
@media (hover: hover) {
.newSession:hover {
background: var(--dsw-alias-button-floating-hover);
}
}
/* macOS: the themed elevated fill reads as a foreign slab on the vibrancy
sidebar; a white wash sits in the glass like native macOS sidebar
controls. Dark keeps the same white ink at lower alpha. */
:global([data-platform='darwin']) :where(.root:not([role='dialog'])) .newSession {
background: rgb(255 255 255 / 0.55);
}
@media (hover: hover) {
:global([data-platform='darwin']) :where(.root:not([role='dialog'])) .newSession:hover {
background: rgb(255 255 255 / 0.3);
}
}
:global([data-platform='darwin']) :global([data-ds-dark-theme]) :where(.root:not([role='dialog'])) .newSession {
background: rgb(255 255 255 / 0.15);
}
@media (hover: hover) {
:global([data-platform='darwin']) :global([data-ds-dark-theme]) :where(.root:not([role='dialog'])) .newSession:hover {
background: rgb(255 255 255 / 0.2);
}
}
.collapsed .newSession {
align-self: flex-start;
width: 36px;
height: 36px;
padding: 0;
margin: 0 0 12px;
gap: 0;
border-color: transparent;
background: transparent;
}
@media (hover: hover) {
.collapsed .newSession:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.newSessionLabel {
max-width: 200px;
overflow: hidden;
white-space: nowrap;
}
.collapsed .newSessionLabel {
max-width: 0;
}
.newSessionLabelMask { flex: 1; min-width: 0; }
.newSession:focus-visible .newSessionLabelMask:has(+ .newSessionShortcut) {
overflow: hidden;
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
}
@media (hover: hover) {
.newSession:hover .newSessionLabelMask:has(+ .newSessionShortcut) {
overflow: hidden;
mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
}
}
.newSessionContent { display: flex; width: 100cqw; align-items: center; justify-content: center; gap: 6px; }
.newSessionContent > svg { flex: none; }
.newSessionShortcut {
opacity: 0;
display: inline-flex;
flex: none;
pointer-events: none;
font-weight: 400;
}
.newSession:focus-visible .newSessionShortcut {
opacity: 1;
}
@media (hover: hover) {
.newSession:hover .newSessionShortcut {
opacity: 1;
}
}
.panelList {
flex: none;
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 8px;
}
/* Panel entries use a 3px paint inset, the New Session bar's 12px radius,
the session rows' hover/selected fill, and the primary ink. */
.panelRow {
display: flex;
align-items: center;
gap: var(--dsh-sidebar-label-gap);
margin: 0;
min-height: 36px;
padding: 7px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset));
box-sizing: border-box;
border: none;
border-inline: var(--dsh-sidebar-paint-inset) solid transparent;
border-radius: var(--dsw-radius-md);
background: transparent;
background-clip: padding-box;
color: var(--dsw-alias-label-primary);
font: inherit;
line-height: 22px;
text-align: left;
cursor: pointer;
}
@media (hover: hover) {
.panelRow:hover {
background-color: var(--dsw-alias-interactive-bg-hover);
}
}
.panelRow.panelActive {
background-color: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.panelRow:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: -2px;
}
/* The More launcher keeps the panel rows' geometry inside the menu's anchor wrapper. */
.moreAnchor {
display: flex;
flex-direction: column;
}
.panelRow[aria-expanded='true'] {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.moreMenu {
width: 288px;
max-width: calc(100vw - 24px);
}
.panelGlyph {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.panelTitle {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collapsed .panelList {
gap: 12px;
margin-bottom: 12px;
}
.collapsed .panelRow {
width: 36px;
height: 36px;
justify-content: center;
margin: 0;
border-inline: 0;
background-clip: border-box;
padding: 0;
color: var(--dsw-alias-label-primary);
}
/* Region seat: always mounted so the foot never moves. Its trailing margin
cancels the wide shell inset so the nested scrollbar can sit at the sidebar
edge; the browser restores that inset inside its own rows. */
.regionArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-left: -4px;
margin-right: calc(-1 * var(--dsh-sidebar-inline-padding));
padding-left: 4px;
overflow: hidden;
}
.collapsed .regionArea {
margin-left: 0;
margin-right: 0;
padding-left: 0;
}
/* Footer seats: additive actions stack above Settings. Each occupant owns its
button geometry and hover chrome. */
.footArea {
flex: none;
display: flex;
flex-direction: column;
}
.settingsArea,
.footerActions {
flex: none;
min-width: 0;
width: 100%;
}
.footerActions {
display: flex;
}
.collapsed .footArea {
align-items: center;
}
.collapsed .settingsArea,
.collapsed .footerActions {
display: flex;
justify-content: center;
width: auto;
}
@media (prefers-reduced-motion: reduce) {
:global([data-animating]:not([data-dragging])) .root,
.wide,
.fading > *,
.railIn .iconButton,
.railIn .newSession,
.railIn .panelList,
.railIn .footArea,
.railIn .regionArea {
transition: none;
animation: none;
}
}
@@ -0,0 +1,662 @@
.root {
--dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding);
--dsh-session-list-scrollbar-width: 5px;
--dsh-session-list-scrollbar-offset: 2px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-right: var(--dsh-session-list-edge-inset);
}
.root.rail {
padding-right: 0;
}
.iconButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
/* The action cluster animates its width and the search pill rounds its corners,
* so both clip an outer ring; these controls keep theirs inside instead. */
.iconButton:focus-visible,
.searchButton:focus-visible,
.clearButton:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: -2px;
}
@media (hover: hover) {
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* View-options dropdown card: labels are short, so a content-sized card reads
cramped and its width shifts with the locale — hold a stable floor. */
.viewOptionsMenu {
min-width: 200px;
}
/* Section header: title, an inline search control, and the two trailing
actions. Expanding search collapses the action cluster and takes its room. */
.sectionHeader {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 36px;
padding-left: var(--dsh-sidebar-row-padding);
margin-bottom: 4px;
box-sizing: border-box;
border-radius: var(--dsw-radius-md);
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
}
.root:not(.rail) .sectionHeader {
margin-top: 2px;
margin-right: -4px;
}
.root:not(.rail) .sectionHeader.searchSpacingMotion {
transition:
margin-right 180ms var(--ds-ease-in-out),
padding-inline 180ms var(--ds-ease-in-out),
gap 180ms var(--ds-ease-in-out);
}
.root:not(.rail) .sectionHeader:not(:has(.searchExpanded)) {
padding-right: 1px;
}
.sectionLabel {
position: relative;
left: -1px;
flex: none;
max-width: 45%;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
opacity: 1;
visibility: visible;
transition:
max-width 180ms var(--ds-ease-in-out),
margin-right 180ms var(--ds-ease-in-out),
opacity 120ms var(--ds-ease-in-out),
transform 180ms var(--ds-ease-in-out),
visibility 0s linear;
}
.sectionLabelHidden {
max-width: 0;
margin-right: -4px;
opacity: 0;
transform: translateX(-4px);
visibility: hidden;
transition-delay: 0s, 0s, 0s, 0s, 180ms;
}
.searchSlot {
flex: 1;
max-width: 28px;
min-width: 0;
display: flex;
align-items: center;
margin-left: auto;
padding-left: 0;
box-sizing: border-box;
transition:
max-width 180ms var(--ds-ease-in-out),
padding-left 180ms var(--ds-ease-in-out);
}
.searchSlotExpanded {
max-width: 100%;
padding-left: 0;
}
.headerActions {
flex: none;
display: flex;
align-items: center;
gap: 4px;
max-width: 60px;
opacity: 1;
overflow: hidden;
visibility: visible;
transition:
max-width 180ms var(--ds-ease-in-out),
opacity 120ms var(--ds-ease-in-out),
transform 180ms var(--ds-ease-in-out),
visibility 0s linear;
}
.headerActionsHidden {
max-width: 0;
opacity: 0;
transform: translateX(4px);
visibility: hidden;
pointer-events: none;
transition-delay: 0s, 0s, 0s, 180ms;
}
/* Inline search always fills the room between the title and trailing actions;
it grows farther right when the action cluster collapses. */
.search {
flex: none;
display: flex;
align-items: center;
gap: 0;
width: 100%;
height: 28px;
margin: 0;
padding: 0;
box-sizing: border-box;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
cursor: text;
color: var(--dsw-alias-label-secondary);
overflow: hidden;
transition:
width 180ms var(--ds-ease-in-out),
padding 180ms var(--ds-ease-in-out),
border-color 180ms var(--ds-ease-in-out),
background-color 180ms var(--ds-ease-in-out);
}
.root:not(.rail) .sectionHeader:has(.searchExpanded) {
margin-right: 0;
padding-inline: calc(var(--dsh-sidebar-paint-inset) + 1px);
gap: 0;
}
.sectionHeader:has(.searchExpanded) .sectionLabelHidden {
margin-right: 0;
}
.searchExpanded {
width: 100%;
height: 32px;
margin-inline: 0;
padding: 0 4px 0 0;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-md);
background: transparent;
color: var(--dsw-alias-label-caption);
}
.searchButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0;
background: transparent;
cursor: pointer;
color: inherit;
}
.searchExpanded .searchButton {
width: 28px;
height: 32px;
}
@media (hover: hover) {
.searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchExpanded .searchButton:hover {
background: transparent;
}
}
.searchInput {
flex: 1;
width: 0;
min-width: 0;
border: none;
outline: none;
background: transparent;
opacity: 0;
pointer-events: none;
font-size: 13px;
line-height: 18px;
color: var(--dsw-alias-label-primary);
transition: opacity 120ms var(--ds-ease-in-out);
}
.searchExpanded .searchInput {
margin-left: -2px;
opacity: 1;
pointer-events: auto;
}
.searchInput::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.clearButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
@media (hover: hover) {
.clearButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* Rail variant (own .rail class from the wide owner prop — the region never
reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. Both use the rail's shared base
left anchor so the outer shell can translate the whole region uniformly. */
.rail .sectionHeader {
gap: 0;
padding-left: 0;
margin-bottom: 12px;
justify-content: flex-start;
}
.rail .headerActions {
max-width: none;
}
.rail .iconButton {
width: 36px;
height: 36px;
/* Rail hovers share the shell rail's 12px rounding. */
border-radius: var(--dsw-radius-md);
color: var(--dsw-alias-label-primary);
}
.rail .search {
width: 36px;
height: 36px;
padding: 0;
margin: 0 0 12px;
gap: 0;
/* The collapsed search frame clips its button's hover, so it must carry the
rail's 12px rounding itself. */
border-radius: var(--dsw-radius-md);
border-color: transparent;
background: transparent;
}
.rail .searchButton {
width: 36px;
height: 36px;
border-radius: var(--dsw-radius-md);
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.rail .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
/* List seat: always mounted so the shell foot never moves. */
.listArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin-left: -4px;
margin-right: calc(-1 * var(--dsh-session-list-edge-inset));
padding-left: 4px;
/* The list remains the scroll clip. This seat stays visible so the
absolutely positioned first-boundary marker can occupy the header gap. */
overflow: visible;
}
.rail .listArea {
margin-left: 0;
margin-right: 0;
padding-left: 0;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* Bottom fade: compact overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: var(--dsh-session-list-edge-inset);
bottom: 0;
height: 24px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
/* macOS desktop: the sidebar is translucent over window vibrancy, so the
opaque fill gradient would paint a solid smear instead of a fade. */
:global([data-platform='darwin']) .fade {
display: none;
}
/* Wide-only content fades back in on expand remount (mirrors the shell). */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
}
@keyframes wide-in {
from { opacity: 0; }
}
/* List: the only scrolling region. Block children keep their design heights
under content overflow. The 2px edge offset, stable 5px themed scrollbar,
and remaining padding equal the shell's right inset, with or without
overflow, so moving the bar does not move the rows. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-left: -4px;
margin-right: var(--dsh-session-list-scrollbar-offset);
padding-left: 4px;
padding-right: calc(
var(--dsh-session-list-edge-inset)
- var(--dsh-session-list-scrollbar-width)
- var(--dsh-session-list-scrollbar-offset)
);
/* Clears the compact bottom fade overlay: at scroll end the last row sits
above the gradient instead of under it. */
padding-bottom: 16px;
scrollbar-gutter: stable;
}
.flatList > * + *,
.searchTree > [role='treeitem'] + [role='treeitem'],
.groupSection > * + * {
margin-top: 2px;
}
.searchStatus {
padding: 10px 12px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* Pending content search: skeleton rows on searchResultRow metrics — a round
leading glyph beside a title bar and a wider snippet bar, breathing through
opacity (same pulse as the input-trigger menu skeleton). */
.skeletonRow {
display: flex;
/* Top alignment lines the 16px dot up with the equally tall title bar;
the paddings center the 35px content in the 48px row. */
align-items: flex-start;
gap: var(--dsh-sidebar-label-gap);
box-sizing: border-box;
min-height: 48px;
padding: 6px var(--dsh-sidebar-row-padding) 7px;
}
.skeletonDot {
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
corner-shape: round;
}
.skeletonBars {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.skeletonDot,
.skeletonBar {
background: var(--dsw-alias-bg-skeleton);
animation: search-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
}
.skeletonBar {
width: 65%;
height: 16px;
border-radius: var(--dsw-radius-xs);
}
.skeletonBarWide {
width: 90%;
height: 13px;
}
@keyframes search-skeleton {
0% { opacity: 1; }
40% { opacity: 0.6; }
80%, 100% { opacity: 1; }
}
/* One workspace section: header row + a compact expanded session run. */
.groupSection {
position: relative;
}
.groupSection + .groupSection {
margin-top: 4px;
}
.listTopDropIndicator,
.workspaceDropBefore::before,
.workspaceDropAfter::after {
content: '';
position: absolute;
z-index: 1;
left: 0;
right: 0;
height: 12px;
background:
linear-gradient(
55deg,
transparent calc(50% - 1px),
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
transparent calc(50% + 1px)
) 0 0 / 5px 7px no-repeat,
linear-gradient(
125deg,
transparent calc(50% - 1px),
var(--dsw-alias-state-business-primary) calc(50% - 1px) calc(50% + 1px),
transparent calc(50% + 1px)
) 0 5px / 5px 7px no-repeat,
linear-gradient(
var(--dsw-alias-state-business-primary) 0 0
) 4px 5px / calc(100% - 4px) 2px no-repeat;
pointer-events: none;
}
/* The first insertion boundary keeps the same -8px coordinate as every
Workspace boundary, but lives outside the scrolling clip. */
.listTopDropIndicator {
top: -8px;
left: 0;
right: var(--dsh-session-list-edge-inset);
}
.listTopDropActive > .workspaceDropBefore:first-child::before {
display: none;
}
.workspaceDropBefore::before {
top: -8px;
}
.workspaceDropAfter::after {
bottom: -8px;
}
.sessionOverflowButton {
width: 100%;
height: 28px;
border: none;
border-radius: var(--dsw-radius-sm);
padding: 0 var(--dsh-sidebar-row-padding) 0 calc(var(--dsh-sidebar-row-padding) + 16px + var(--dsh-sidebar-label-gap) + var(--dsh-workspace-indent, 0px));
background: transparent;
cursor: pointer;
text-align: left;
font-size: 12px;
color: var(--dsw-alias-label-tertiary);
}
.groupSection > .sessionOverflowButton {
margin-top: 0;
}
@media (hover: hover) {
.sessionOverflowButton:hover {
background: transparent;
color: var(--dsw-alias-label-secondary);
}
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
}
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-top: 80px;
padding: 0 12px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.emptyState > svg {
margin-bottom: 4px;
color: var(--dsw-alias-label-caption);
}
.emptyAction {
padding: 0;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-link);
}
/* Rename dialog form (same figma dialog family as the create modals). */
.renameInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: var(--dsw-radius-lg);
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.renameInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.renameError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
.deleteAction:not(:disabled) {
color: var(--dsw-alias-state-error-primary);
}
.deleteStatus {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
/* The stop-and-archive dialog's list of work that will be stopped. */
.archiveActivity {
margin: 0 0 8px;
padding-left: 18px;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
}
.archiveActivity li {
overflow-wrap: anywhere;
}
@media (pointer: coarse) {
.searchInput,
.renameInput {
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.wide {
animation: none;
}
.skeletonDot,
.skeletonBar {
animation: none;
}
.root:not(.rail) .sectionHeader.searchSpacingMotion,
.search,
.sectionLabel,
.searchSlot,
.searchInput,
.headerActions {
transition: none;
}
}
+588
View File
@@ -0,0 +1,588 @@
/** IPC / mock bridge for Hermit desktop. */
export type SessionInfo = {
id: string;
title: string;
updated_at: number;
pinned?: boolean;
folder?: string | null;
};
export type ConsentRequest = {
id: string;
tool: string;
detail: string;
preview?: string;
};
export type QuestionRequest = {
id: string;
question: string;
options: string[];
};
export type AttachmentInfo = {
id: string;
name: string;
path: string;
};
export type ProviderInfo = {
name: string;
model: string;
has_key: boolean;
key_from?: string;
};
export type UsageInfo = {
prompt: number;
out: number;
cost: number;
priced: boolean;
pct: number;
};
export type BashRun = {
id: string;
command: string;
status: string;
output?: string;
exit_code?: number;
started_at: number;
ended_at?: number;
};
export type ChatItem =
| { kind: "user"; id: string; text: string; ts?: number }
| { kind: "assistant"; id: string; text: string; ts?: number }
| { kind: "thought"; id: string; text: string; ts?: number }
| {
kind: "tool";
id: string;
name: string;
status: "running" | "ok" | "denied" | "error";
detail: string;
output?: string;
exit_code?: number;
ts?: number;
};
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;
output?: string;
exit_code?: number;
}
| { type: "consent"; req: ConsentRequest }
| { type: "question"; req: QuestionRequest }
| { type: "todo"; text: string }
| { type: "status"; message: string }
| { type: "turn_end"; ok: boolean }
| { type: "error"; message: string };
export type Settings = {
provider: string;
model: string;
approval: "ask" | "auto" | "yolo";
workspace: string;
plan_mode?: boolean;
api_key?: string;
/** "" | low | medium | high | max */
reasoning_effort?: string;
font_size?: "s" | "m" | "l" | "xl";
language?: "zh" | "en";
native_titlebar?: boolean;
};
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,
pinned: true,
folder: "desktop",
},
{
id: "s2",
title: "Ship hermit-update",
updated_at: Date.now() - 86400_000,
folder: "desktop",
},
];
const mockTranscripts = new Map<string, ChatItem[]>([
[
"s1",
[
{ kind: "user", id: "u1", text: "右上角布局不对,帮我看看。", ts: Date.now() - 3600_000 },
{
kind: "thought",
id: "t1",
text: "Sidebar starts at row 0 while topbar only covers the main pane…",
ts: Date.now() - 3590_000,
},
{
kind: "tool",
id: "tool1",
name: "read_file",
status: "ok",
detail: "src/tui.c · draw_side / layout",
output: "…layout code…",
exit_code: 0,
ts: Date.now() - 3580_000,
},
{
kind: "assistant",
id: "a1",
text: "顶栏只盖主区、侧栏从第 0 行铺满,右上角会空出一块。已把 topbar 拉满并把 seat deck 对齐到顶栏下方。",
ts: Date.now() - 3570_000,
},
],
],
]);
let mockBusy = false;
let mockSettings: Settings = {
provider: "deepseek",
model: "deepseek-flash",
approval: "auto",
workspace: "~/hermit",
plan_mode: false,
api_key: "",
reasoning_effort: "high",
font_size: "m",
language: "zh",
native_titlebar: false,
};
let mockPending: AttachmentInfo[] = [];
let mockTodo = "";
let mockBash: BashRun[] = [];
const mockListeners = new Set<(e: EngineEvent) => void>();
let mockConsentResolve: ((n: number) => void) | null = null;
let mockQuestionResolve: ((s: string) => void) | null = null;
function emitMock(e: EngineEvent) {
mockListeners.forEach((fn) => fn(e));
}
function normalizeSession(s: SessionInfo): SessionInfo {
let updated_at = s.updated_at;
if (updated_at > 0 && updated_at < 1e12) updated_at *= 1000;
return { ...s, updated_at };
}
export async function listSessions(): Promise<SessionInfo[]> {
const real = await tryInvoke<SessionInfo[]>("session_list");
const list = real ?? mockSessions;
return list.map(normalizeSession);
}
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);
mockTranscripts.set(s.id, []);
mockBash = [];
return s;
}
export async function openSession(id: string): Promise<ChatItem[]> {
const real = await tryInvoke<ChatItem[]>("session_open", { id });
if (real) return real;
return mockTranscripts.get(id) ?? [];
}
export async function renameSession(id: string, title: string): Promise<boolean> {
const real = await tryInvoke<boolean>("session_rename", { id, title });
if (real !== null) return real;
const s = mockSessions.find((x) => x.id === id);
if (!s) return false;
s.title = title;
s.updated_at = Date.now();
return true;
}
export async function deleteSession(id: string): Promise<boolean> {
const real = await tryInvoke<boolean>("session_delete", { id });
if (real !== null) return real;
const i = mockSessions.findIndex((x) => x.id === id);
if (i < 0) return false;
mockSessions.splice(i, 1);
mockTranscripts.delete(id);
return true;
}
export async function pinSession(id: string): Promise<boolean> {
const real = await tryInvoke<boolean>("session_pin", { id });
if (real !== null) return real;
const s = mockSessions.find((x) => x.id === id);
if (!s) return false;
s.pinned = !s.pinned;
mockSessions.sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned));
return true;
}
export async function setSessionFolder(
id: string,
folder: string | null,
): Promise<boolean> {
const real = await tryInvoke<boolean>("session_set_folder", { id, folder });
if (real !== null) return real;
const s = mockSessions.find((x) => x.id === id);
if (!s) return false;
s.folder = folder;
return true;
}
export async function getSettings(): Promise<Settings> {
const real = await tryInvoke<Settings>("settings_get");
return real ?? { ...mockSettings };
}
export async function setSettings(settings: Settings): Promise<Settings> {
const real = await tryInvoke<Settings>("settings_set", { settings });
if (real) {
mockSettings = real;
return real;
}
mockSettings = { ...settings };
return mockSettings;
}
export async function pickWorkspace(): Promise<string | null> {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
directory: true,
multiple: false,
title: "Choose workspace",
});
if (typeof selected === "string") return selected;
return null;
} catch {
const next = window.prompt("Workspace path", mockSettings.workspace);
return next && next.trim() ? next.trim() : null;
}
}
export async function pickFiles(): Promise<string[]> {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: true,
title: "Attach files",
});
if (!selected) return [];
return Array.isArray(selected) ? selected : [selected];
} catch {
return [];
}
}
export async function listPending(): Promise<AttachmentInfo[]> {
const real = await tryInvoke<AttachmentInfo[]>("attach_list");
return real ?? mockPending;
}
export async function attachFile(path: string): Promise<AttachmentInfo | null> {
const real = await tryInvoke<AttachmentInfo>("attach_file", { path });
if (real) return real;
const name = path.split(/[/\\]/).pop() || path;
const info: AttachmentInfo = { id: `a${Date.now()}`, name, path };
mockPending.push(info);
return info;
}
export async function clearPending(): Promise<void> {
await tryInvoke("attach_clear");
mockPending = [];
}
export async function removePending(id: string): Promise<void> {
await tryInvoke("attach_remove", { id });
mockPending = mockPending.filter((a) => a.id !== id);
}
export async function listProviders(): Promise<ProviderInfo[]> {
const real = await tryInvoke<ProviderInfo[]>("providers_list");
return (
real ?? [
{ name: "deepseek", model: "deepseek-flash", has_key: true, key_from: "env" },
{ name: "openai", model: "gpt-4.1", has_key: false },
{ name: "anthropic", model: "claude-sonnet-4", has_key: false },
]
);
}
export async function useProvider(
provider: string,
model: string,
key?: string,
): Promise<string> {
const real = await tryInvoke<string>("providers_use", { provider, model, key: key ?? "" });
if (real !== null) return real;
mockSettings = { ...mockSettings, provider, model, api_key: key ?? mockSettings.api_key };
return "";
}
export async function getUsage(): Promise<UsageInfo> {
const real = await tryInvoke<UsageInfo>("usage_get");
return real ?? { prompt: 0, out: 0, cost: 0, priced: false, pct: 0 };
}
export async function listWorkspaceFiles(query: string): Promise<string[]> {
const real = await tryInvoke<string[]>("workspace_files", { query });
if (real) return real;
const sample = [
"src/main.c",
"src/tui.c",
"apps/desktop/src/App.tsx",
"README.md",
"include/cst_agent.h",
];
const q = query.toLowerCase();
return sample.filter((p) => !q || p.toLowerCase().includes(q)).slice(0, 20);
}
export async function compactSession(): Promise<string> {
const real = await tryInvoke<string>("session_compact");
return real ?? "Compacted (mock).";
}
export async function exportSession(): Promise<string> {
const real = await tryInvoke<string>("session_export");
return real ?? "# Session export (mock)\n";
}
export async function engineStatus(): Promise<string> {
return (await tryInvoke<string>("engine_status")) ?? "browser";
}
export async function getTodo(): Promise<string> {
const real = await tryInvoke<string>("todo_get");
if (real !== null) {
mockTodo = real;
return real;
}
return mockTodo;
}
export async function listBashRuns(): Promise<BashRun[]> {
const real = await tryInvoke<BashRun[]>("bash_list");
return real ?? mockBash;
}
export function isShellTool(name: string): boolean {
const n = name.toLowerCase();
return n.includes("shell") || n === "bash" || n === "sh" || n === "run_terminal";
}
/** Opt-in browser/host mock turns. Default: refuse so UI never silents as product. */
export function mockTurnsAllowed(): boolean {
return (
import.meta.env.VITE_HERMIT_ALLOW_MOCK === "1" ||
(typeof localStorage !== "undefined" &&
localStorage.getItem("hermit.allowMock") === "1")
);
}
export async function sendTurn(text: string): Promise<void> {
const real = await tryInvoke<void>("send_turn", { text });
if (real !== null) return;
if (!mockTurnsAllowed()) {
emitMock({
type: "error",
message:
"Engine unavailable (host/browser). Start with apps/desktop/run-dev.sh (needs libcstcore + DEEPSEEK_API_KEY or Settings key). Set hermit.allowMock=1 only for UI demos.",
});
return;
}
if (mockBusy) return;
mockBusy = true;
emitMock({ type: "status", message: "mock demo (not engine)" });
emitMock({ type: "thought_delta", text: "Planning a short reply…" });
await delay(300);
const tid = `m${Date.now()}`;
mockBash.push({
id: tid,
command: "hermit --version",
status: "running",
started_at: Date.now(),
});
emitMock({
type: "tool_start",
id: tid,
name: "shell",
detail: "hermit --version",
});
if (mockSettings.approval === "ask") {
const id = `c${Date.now()}`;
const ans = await new Promise<number>((resolve) => {
mockConsentResolve = resolve;
emitMock({
type: "consent",
req: {
id,
tool: "shell",
detail: "hermit --version",
preview: "$ hermit --version\n",
},
});
});
mockConsentResolve = null;
if (ans === 0) {
emitMock({
type: "tool_end",
id: tid,
status: "denied",
detail: "rejected",
});
const b = mockBash.find((x) => x.id === tid);
if (b) {
b.status = "denied";
b.ended_at = Date.now();
}
emitMock({ type: "turn_end", ok: false });
mockBusy = false;
return;
}
}
await delay(400);
const b = mockBash.find((x) => x.id === tid);
if (b) {
b.status = "ok";
b.output = "hermit 1.1.0\n";
b.exit_code = 0;
b.ended_at = Date.now();
}
emitMock({
type: "tool_end",
id: tid,
status: "ok",
detail: "hermit 1.1.0",
output: "hermit 1.1.0\n",
exit_code: 0,
});
mockTodo = "- [x] Inspect layout\n- [ ] Verify send path\n- [ ] Ship desktop polish";
emitMock({ type: "todo", text: mockTodo });
if (text.toLowerCase().includes("ask")) {
const qid = `q${Date.now()}`;
const answer = await new Promise<string>((resolve) => {
mockQuestionResolve = resolve;
emitMock({
type: "question",
req: {
id: qid,
question: "Which approach should we take?",
options: ["Polish first", "Features first", "Skip"],
},
});
});
mockQuestionResolve = null;
emitMock({ type: "status", message: `User answered: ${answer}` });
}
const reply =
"[mock] UI demo only. Use run-dev.sh with engine + API key for real turns.";
for (const ch of reply) {
emitMock({ type: "assistant_delta", text: ch });
await delay(8);
}
mockPending = [];
emitMock({ type: "turn_end", ok: true });
mockBusy = false;
}
export async function cancelTurn(): Promise<void> {
await tryInvoke("cancel_turn");
mockBusy = false;
if (mockConsentResolve) {
mockConsentResolve(0);
mockConsentResolve = null;
}
if (mockQuestionResolve) {
mockQuestionResolve("");
mockQuestionResolve = null;
}
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 });
if (mockConsentResolve) {
mockConsentResolve(answer === "deny" ? 0 : 1);
mockConsentResolve = null;
}
}
export async function answerQuestion(id: string, answer: string): Promise<void> {
await tryInvoke("question_answer", { id, answer });
if (mockQuestionResolve) {
mockQuestionResolve(answer);
mockQuestionResolve = null;
}
}
export function getMockTodo(): string {
return mockTodo;
}
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) => {
if (ev.payload.type === "todo") mockTodo = ev.payload.text;
fn(ev.payload);
});
return () => {
un();
};
} catch {
mockListeners.add(fn);
return () => {
mockListeners.delete(fn);
};
}
}
export async function onMenuEvent(fn: (id: string) => void): Promise<Unlisten> {
try {
const { listen } = await import("@tauri-apps/api/event");
const un = await listen<string>("hermit://menu", (ev) => fn(ev.payload));
return () => {
un();
};
} catch {
return () => undefined;
}
}
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "@/dsh/index.css";
import "@/styles/app.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+400
View File
@@ -0,0 +1,400 @@
/* Minimal Hermit glue — DSH modules own surfaces. Bypass old theme color card. */
.app-frame-fill {
height: 100%;
min-height: 0;
}
.hermit-palette {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(100% + 8px);
z-index: 20;
max-height: 240px;
overflow: auto;
border-radius: var(--dsw-radius-lg);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-elevation-panel);
border: 0;
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
}
.hermit-palette button {
display: flex;
width: 100%;
gap: 10px;
align-items: baseline;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
font: inherit;
}
.hermit-palette button.active,
.hermit-palette button:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.hermit-palette .pal-cmd {
font-family: var(--ds-font-family-code);
font-size: 12px;
color: var(--dsw-alias-state-business-primary);
}
.hermit-palette .pal-desc {
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.hermit-chip-menu {
position: absolute;
left: 0;
bottom: calc(100% + 6px);
z-index: 30;
min-width: 220px;
padding: 6px;
border-radius: var(--dsw-radius-lg);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-elevation-panel);
}
.hermit-chip-menu .menu-section {
padding: 6px 8px 4px;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.hermit-chip-menu button {
display: flex;
flex-direction: column;
width: 100%;
gap: 2px;
padding: 8px;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
font: inherit;
}
.hermit-chip-menu button.active,
.hermit-chip-menu button:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.hermit-chip-menu .menu-desc {
font-size: 12px;
color: var(--dsw-alias-label-secondary);
font-weight: 400;
}
.hermit-effort-row {
display: flex;
gap: 4px;
padding: 4px;
}
.hermit-effort-row button {
flex: 1;
align-items: center;
padding: 6px 4px;
font-size: 12px;
}
.hermit-attach-rail {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 14px;
}
.hermit-attach-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.hermit-attach-chip button {
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 14px;
line-height: 1;
}
.hermit-toolbar-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 8px;
border: none;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.hermit-toolbar-btn:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.hermit-toolbar-btn strong {
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.hermit-send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 999px;
background: var(--dsw-alias-state-business-primary);
color: #fff;
cursor: pointer;
}
.hermit-send:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.hermit-send.stop {
width: auto;
padding: 0 12px;
gap: 6px;
border-radius: 999px;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.hermit-textarea {
display: block;
width: 100%;
box-sizing: border-box;
resize: none;
border: none;
outline: none;
background: transparent;
color: var(--dsw-alias-label-primary);
font-family: var(--dsw-font-family);
font-size: inherit;
line-height: inherit;
caret-color: var(--dsw-alias-state-business-primary);
padding: 4px 8px 0 14px;
min-height: 52px;
}
.hermit-textarea:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: not-allowed;
}
.hermit-drop-overlay {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--dsw-radius-panel);
background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);
border: 1px dashed var(--dsw-alias-state-business-primary);
color: var(--dsw-alias-state-business-primary);
font-size: 13px;
pointer-events: none;
}
/* Settings overlay kept simple */
.settings-scrim {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: var(--dsw-alias-bg-mask-1);
}
.settings-card {
width: min(520px, calc(100% - 48px));
max-height: calc(100% - 48px);
overflow: auto;
padding: 20px;
border-radius: var(--dsw-radius-xl);
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-elevation-panel);
color: var(--dsw-alias-label-primary);
}
.settings-card h2 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
}
.settings-card label {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.settings-card input,
.settings-card select {
height: 32px;
padding: 0 10px;
border-radius: var(--dsw-radius-sm);
border: 0.5px solid var(--dsw-alias-border-l3);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: inherit;
}
.settings-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.settings-actions button {
height: 32px;
padding: 0 14px;
border: none;
border-radius: var(--dsw-radius-sm);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
cursor: pointer;
font: inherit;
}
.settings-actions .primary {
background: var(--dsw-alias-state-business-primary);
color: #fff;
}
.hermit-user-bubble {
box-sizing: border-box;
padding: 10px 14px;
border-radius: var(--dsw-radius-xl);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font-size: var(--dsh-content-font-size, 14px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
white-space: pre-wrap;
word-break: break-word;
}
.hermit-cursor {
display: inline-block;
width: 6px;
height: 14px;
margin-left: 2px;
vertical-align: text-bottom;
background: var(--dsw-alias-state-business-primary);
animation: hermit-blink 1s step-end infinite;
}
@keyframes hermit-blink {
50% { opacity: 0; }
}
/* Soften leftover Hermit theme tokens if any sheet still references them */
:root {
--hermit-accent: var(--dsw-alias-state-business-primary);
}
/* WebKitGTK paints native light buttons; kill UA chrome inside DSH shells. */
.hermit-conversation-body button,
aside button {
appearance: none;
-webkit-appearance: none;
color: inherit;
font: inherit;
outline: none;
}
aside [class*="projectRow"],
aside [class*="sessionRow"] {
background-color: transparent;
border: none;
outline: none;
box-shadow: none;
}
/* Hero mark: DSH fish slot is currentColor silhouette; keep crab readable. */
.hermit-hero-mark {
color: var(--dsw-alias-label-primary);
flex: none;
}
.hermit-hero-mark svg,
.hermit-mark svg {
display: block;
overflow: visible;
animation: none !important;
}
.hermit-mark svg *,
.hermit-hero-mark svg * {
animation: none !important;
}
/* Kill DSH hover swim / pending pulses that thrash WebKitGTK compositing. */
@media (prefers-reduced-motion: no-preference) {
[class*="fishHitbox"]:hover [class*="fish"],
[class*="pending"],
[class*="sendingSpinner"] {
animation: none !important;
}
}
/* Session row quick actions: hover-only (override DSH coarse-pointer always-on). */
.hermit-row-actions {
display: none !important;
}
@media (hover: hover) {
[class*="sessionRow"]:hover .hermit-row-actions {
display: inline-flex !important;
}
}
.hermit-row-actions button {
width: 20px;
height: 20px;
padding: 0;
}
/* Plan/Bash panels — DSH surface tokens */
.plan-panel,
.bash-panel {
margin: 16px auto;
width: min(100%, var(--dsh-chat-content-width));
padding: 16px;
border-radius: var(--dsw-radius-xl);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
border: 0.5px solid var(--dsw-alias-border-l3);
}
+117
View File
@@ -0,0 +1,117 @@
/* DSH dark surfaces + Hermit brand accents */
:root {
/* Brand from color card + agent-motion eye */
--brand: #009cc6;
--brand-bright: #70cde5;
--brand-dim: #007a9c;
--accent: var(--brand);
--accent-bright: var(--brand-bright);
--crab: #ffa939;
--crab-dim: #e8922a;
--cream: #f9f3ee;
--eye: #9957ad;
--send: var(--crab);
--send-hover: var(--crab-dim);
--ok: #22c55e;
--warn: #f59e0b;
--danger: #f25a5a;
/* DSH dark bluish neutrals (design-platform.css) */
--bg: #151517; /* bluish-950 */
--bg-elev: #1b1b1c; /* bluish-900 sidebar */
--bg-layer: #232325; /* bluish-875 */
--bg-card: #2c2c2e; /* bluish-850 input */
--bg-hover: rgba(255, 255, 255, 0.08);
--bg-active: rgba(255, 255, 255, 0.14);
--border-l1: rgba(255, 255, 255, 0.06);
--border-l2: rgba(255, 255, 255, 0.12);
--border-l3: rgba(255, 255, 255, 0.16);
--border-l4: rgba(255, 255, 255, 0.2);
--border: var(--border-l1);
--border-2: var(--border-l2);
--border-3: var(--border-l3);
--border-4: var(--border-l4);
--text: #f9fafb;
--text-dim: #ebf0f4;
--text-muted: #cfd3d6;
--text-faint: #adb2b8;
--text-caption: #81858c;
--bubble-user: #2c2c2e;
--radius-xs: 4px;
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-panel: 28px;
--rail-w: 280px;
--rail-collapsed: 56px;
--chat-w: 820px;
--composer-w: 852px; /* chat + 32 */
--composer-side: 16px;
--header-h: 52px;
--new-session-h: 38px;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Noto Sans SC", "Helvetica Neue", Helvetica, Arial, sans-serif;
--mono: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, Menlo, monospace;
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--elevation-stroke: 0 0 0 0.5px var(--border-l2);
/* DSH soft: hairline + very light lift (not heavy drop shadow) */
--elevation-soft: var(--elevation-stroke), 0 4px 16px rgba(0, 0, 0, 0.03),
0 0 24px rgba(0, 0, 0, 0.03);
--sidebar-wash: linear-gradient(
to bottom,
rgb(0 156 198 / 0.1),
rgb(112 205 229 / 0) 38%,
rgb(153 87 173 / 0) 68%,
rgb(153 87 173 / 0.07)
);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
height: 100%;
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
a {
color: var(--accent-bright);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module "*.svg?raw" {
const content: string;
export default content;
}
+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"]
}
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ApprovalCard.tsx","./src/components/BashPanel.tsx","./src/components/Composer.tsx","./src/components/Hero.tsx","./src/components/Icons.tsx","./src/components/PlanPanel.tsx","./src/components/QuestionCard.tsx","./src/components/Rail.tsx","./src/components/SessionHeader.tsx","./src/components/SettingsPanel.tsx","./src/components/Transcript.tsx","./src/lib/bridge.ts"],"version":"5.9.3"}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
// 固定 IPv4:WebKitGTK 访问 localhost 常走 ::1,Vite 默认只听 127.0.0.1 → 空白灰窗
const host = process.env.TAURI_DEV_HOST || "127.0.0.1";
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host,
hmr: { protocol: "ws", host, port: 1421 },
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,
},
});
+248
View File
@@ -0,0 +1,248 @@
cmake_minimum_required(VERSION 3.16)
project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0)
# The Qt front end (app/) embeds the C engine in-process: it links libcstcore
# and never opens a port. Two targets:
#
# cst-app-selftest pure C smoke test of the embedding path (no Qt), so the
# C side is still guarded on machines without Qt Quick
# cst-agent-app the GUI (Qt 6 Quick + QML), built when the Qt modules
# are there
#
# Standalone build (builds the engine from ../ too):
# cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug && cmake --build build-app -j
# Inside the repo build (the parent defines the cstcore target, shared with the CLI):
# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app
#
# Desktop install (the window, its launcher entry and the icon; the CLI has
# its own installer) -- default.nix installs exactly this component:
# cmake --install build --component app --prefix ~/.local
#
# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix:
# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime)
option(CST_APP_GUI "build the Qt GUI (needs Qt6 Quick, Qml, QuickControls2)" ON)
option(CST_APP_SELFTEST "build the C embedding smoke test" ON)
# --- the engine ----------------------------------------------------------
# Used as a subproject (../CMakeLists.txt already defined cstcore, engine
# compiled once for CLI and app) or built here from the sources next door.
if(NOT TARGET cstcore)
set(CST_APP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..")
include_directories("${CST_APP_ROOT}/include" "${CST_APP_ROOT}/vendor/cjson")
file(GLOB CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/*.c"
"${CST_APP_ROOT}/src/tools/*.c"
"${CST_APP_ROOT}/vendor/cjson/cJSON.c")
list(REMOVE_ITEM CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/main.c"
"${CST_APP_ROOT}/src/tui.c"
"${CST_APP_ROOT}/src/tui_stub.c"
"${CST_APP_ROOT}/src/textsel.c")
# The HTTP front end is not part of an in-process app: no server, no port.
list(REMOVE_ITEM CST_APP_CORE_SRC
"${CST_APP_ROOT}/src/net.c"
"${CST_APP_ROOT}/src/http_srv.c"
"${CST_APP_ROOT}/src/webui.c")
# web_md.c stays in: the window renders markdown with it (AgentCore::
# renderMarkdown) and the md_to_html tool needs it. Only the HTTP server
# is left out.
if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc")
include_directories("${CST_APP_ROOT}/.gen")
endif()
add_library(cstcore STATIC ${CST_APP_CORE_SRC})
target_compile_definitions(cstcore PRIVATE CST_NO_WEBUI=1)
find_package(CURL REQUIRED)
find_package(Threads REQUIRED)
target_link_libraries(cstcore PUBLIC ${CURL_LIBRARIES} Threads::Threads m)
message(STATUS "app: building the engine from ${CST_APP_ROOT}")
else()
message(STATUS "app: linking the cstcore target of the parent build")
endif()
# --- the C smoke test ----------------------------------------------------
if(CST_APP_SELFTEST)
add_executable(cst-app-selftest tools/selftest.c)
target_link_libraries(cst-app-selftest PRIVATE cstcore)
enable_testing()
add_test(NAME cst-app-selftest COMMAND cst-app-selftest)
endif()
# --- the GUI -------------------------------------------------------------
if(CST_APP_GUI)
# Android's platform file talks to the platform through QtAndroidPrivate
# (the POST_NOTIFICATIONS probe), whose include path comes with CorePrivate.
if(ANDROID)
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test
CorePrivate QUIET)
else()
find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test QUIET)
endif()
if(NOT Qt6_FOUND)
message(WARNING
"app: Qt 6 Quick/Qml not found, skipping cst-agent-app "
"(the C selftest still builds; install e.g. qt6.qtdeclarative / "
"qt6.qtquickcontrols2 and reconfigure)")
else()
qt_standard_project_setup()
# Qt 6.5+ policies: keep the module resource at :/qt/qml/<URI>/ (what
# main.cpp loads) and let CMake generate the qmldir of qml/ for us.
qt_policy(SET QTP0001 NEW)
qt_policy(SET QTP0004 NEW)
# The front end's platform tools: one file per platform, the same split
# core/ToolProvider.cpp makes. Android brings its own (JNI, owner
# "app:android"), Windows its own (PowerShell popup, owner "app:windows");
# the rest use the Linux file.
if(ANDROID)
set(CST_APP_PLATFORM_SRC src/platform/android/AndroidTools.cpp)
elseif(WIN32)
set(CST_APP_PLATFORM_SRC src/platform/windows/NotifyTool.cpp)
else()
set(CST_APP_PLATFORM_SRC src/platform/linux/NotifyTool.cpp)
endif()
qt_add_executable(cst-agent-app
src/main.cpp
src/core/AgentCore.cpp
src/core/ToolProvider.cpp
src/core/PdfTools.cpp
src/ui/UiTest.cpp
src/ui/ThemeIcons.cpp
src/ui/SessionImages.cpp
src/ui/BackKey.cpp
${CST_APP_PLATFORM_SRC})
target_include_directories(cst-agent-app PRIVATE
src "${CMAKE_CURRENT_SOURCE_DIR}/../include")
target_compile_definitions(cst-agent-app PRIVATE CST_APP_VERSION="${PROJECT_VERSION}")
if(CST_APP_QML_IMPORT)
target_compile_definitions(cst-agent-app PRIVATE
CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}")
endif()
target_link_libraries(cst-agent-app PRIVATE
Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Test cstcore)
if(ANDROID)
# app/src/platform/android/AndroidTools.cpp talks to the platform
# through QtAndroidPrivate (permissions) -- a private header, hence the
# private module for its include path.
target_link_libraries(cst-agent-app PRIVATE Qt6::CorePrivate)
endif()
if(WIN32)
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
# A desktop install. The icon goes where the theme layout says, so the
# entry's Icon= name finds it, and the entry's file name is the app_id Qt
# sets (QGuiApplication::applicationName) -- rename one and the launcher
# loses the icon. All of it in the "app" component: the CLI keeps coming
# from scripts/bootstrap_linux.sh, and two `cst-agent` in PATH is how a
# version nobody meant to run gets run.
if(NOT ANDROID)
include(GNUInstallDirs)
install(TARGETS cst-agent-app
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} COMPONENT app)
install(FILES linux/cst-agent-app.desktop
DESTINATION ${CMAKE_INSTALL_DATADIR}/applications COMPONENT app)
install(FILES linux/cst-agent-app.svg
DESTINATION ${CMAKE_INSTALL_DATADIR}/icons/hicolor/scalable/apps
COMPONENT app)
endif()
# --- Android (APK) -----------------------------------------------------
# The engine is compiled in (top-level CMakeLists: -DCST_BUILD_TUI=OFF,
# -DCST_BUILD_WEBUI=OFF, so src/webui_stub.c stands in for the server and
# nothing listens on a port). The manifest, icons and strings live in
# app/android/, which androiddeployqt copies next to its own gradle
# project. minSdk 24 is the engine's own API_LEVEL (scripts/
# build_third_party_android.sh builds curl/mbedTLS for android-24).
if(ANDROID)
file(STRINGS "${CMAKE_CURRENT_SOURCE_DIR}/../include/cst_version.h"
_cst_ver_lines REGEX "#define CST_VERSION_(MAJOR|MINOR|PATCH) ")
foreach(_line ${_cst_ver_lines})
if(_line MATCHES "CST_VERSION_MAJOR ([0-9]+)")
set(_cst_vmaj "${CMAKE_MATCH_1}")
elseif(_line MATCHES "CST_VERSION_MINOR ([0-9]+)")
set(_cst_vmin "${CMAKE_MATCH_1}")
elseif(_line MATCHES "CST_VERSION_PATCH ([0-9]+)")
set(_cst_vpat "${CMAKE_MATCH_1}")
endif()
endforeach()
set_target_properties(cst-agent-app PROPERTIES
QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android"
QT_ANDROID_MIN_SDK_VERSION 24
QT_ANDROID_TARGET_SDK_VERSION 35
QT_ANDROID_VERSION_NAME "${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}"
QT_ANDROID_VERSION_CODE 1
QT_ANDROID_ABIS "${ANDROID_ABI}")
message(STATUS "app: Android package "
"${_cst_vmaj}.${_cst_vmin}.${_cst_vpat}, ABI ${ANDROID_ABI}, "
"minSdk 24, targetSdk 35")
# Signing: androiddeployqt signs the APK itself and refuses a signed
# gradle output ("Could not copy unsigned APK"), so the keystore goes
# here, not into gradle. The path, alias and passwords arrive through
# the QT_ANDROID_KEYSTORE_* environment variables;
# scripts/build_apk_android.sh points them at the standard Android debug
# key it creates on first use, and CST_ANDROID_KEYSTORE overrides it.
if(DEFINED ENV{QT_ANDROID_KEYSTORE_PATH} AND NOT "$ENV{QT_ANDROID_KEYSTORE_PATH}" STREQUAL "")
set(QT_ANDROID_SIGN_APK TRUE)
message(STATUS "app: signing the APK with "
"$ENV{QT_ANDROID_KEYSTORE_PATH} ($ENV{QT_ANDROID_KEYSTORE_ALIAS})")
else()
message(STATUS "app: QT_ANDROID_KEYSTORE_PATH unset -- the APK stays "
"unsigned and 'adb install' will refuse it (INSTALL_PARSE_FAILED_NO_CERTIFICATES)")
endif()
endif()
# Theme is read by every component, so it is a QML singleton (one instance,
# no object needed in the tree).
set_source_files_properties(qml/Theme.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
# Keep the module flat in the resource tree (:qt/qml/CstAgent/Main.qml).
# Sources live in qml/, but a module whose files sit in a subdirectory of
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
# requires absolute URLs" on every start).
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
NoteLine TodoPanel Composer ConsentDialog AskDialog SettingsDialog TaskPanel
ThemeIcon Theme ImageStrip)
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
endforeach()
qt_add_qml_module(cst-agent-app
URI CstAgent
VERSION 1.0
QML_FILES
qml/Main.qml
qml/ChatView.qml
qml/Sidebar.qml
qml/SessionRow.qml
qml/MessageCard.qml
qml/ToolCard.qml
qml/ThinkBlock.qml
qml/NoteLine.qml
qml/TodoPanel.qml
qml/Composer.qml
qml/TaskPanel.qml
qml/ConsentDialog.qml
qml/AskDialog.qml
qml/SettingsDialog.qml
qml/ThemeIcon.qml
qml/ImageStrip.qml
qml/Theme.qml)
# The icons the window asks for, compiled in (app/icons, Breeze's scalable
# names copied at 24@3x). The desktop finds them in the platform's icon
# theme and only falls back to these (ThemeIcons.cpp); a machine without
# any theme -- Android -- has nothing else, and before this the QML glyph
# fallback was all that showed, which the phone's fonts cannot render.
set(CST_APP_ICONS
go-down mail-attachment application-menu settings-configure edit-copy
edit-delete arrow-down arrow-right list-add task-complete box
document-edit text-x-generic folder-open edit-find code-context
internet-services format-list-unordered help-hint)
set(_cst_icon_files "")
foreach(_icon ${CST_APP_ICONS})
list(APPEND _cst_icon_files "${CMAKE_CURRENT_SOURCE_DIR}/icons/${_icon}.svg")
endforeach()
qt_add_resources(cst-agent-app "cst_icons"
PREFIX "/cst-icons"
BASE "${CMAKE_CURRENT_SOURCE_DIR}/icons"
FILES ${_cst_icon_files})
endif()
endif()
+10
View File
@@ -0,0 +1,10 @@
# Deprecated: Qt front end (archived)
This directory is the **legacy Qt 6 / QML** Hermit GUI (`cst-agent-app`),
formerly at `app/`. It is **frozen** under `archive/qt-app`.
Hermit’s product surface is the CLI/TUI + `libcstcore` + `--web` (`cst-webui/1`).
Another archived desktop shell is `archive/desktop-tauri` (Tauri 2 + React).
Do not add features here. Experimental rebuild: `-DCST_BUILD_APP=ON` from the
repo root (see root `archive/README.md`).
+467
View File
@@ -0,0 +1,467 @@
# app/ — the Qt front end
CST-agent's GUI: **Qt 6 + QML + the C engine, in one process**. No HTTP
server, no port, no token, no second process — the app creates a `cst_agent_t`
directly and drives it through `cst_agent.h`.
```
app/ the GUI
src/main.cpp entry point, --workspace / --selftest
src/core/AgentCore.* QObject around cst_agent_t: turns, hooks, consent
src/core/ToolProvider.h how the front end hands tools to the engine
src/platform/<os>/ platform tools the app implements itself
qml/Main.qml the window (chat, tool runs, consent dialog)
tools/selftest.c C smoke test of the embedding path (no Qt)
```
## Build
```sh
# engine + GUI + C smoke test
cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug
cmake --build build-app -j
QT_QPA_PLATFORM=offscreen ctest --test-dir build-app # cst-app-selftest
# the app alone
./build-app/cst-agent-app --workspace ~/notes
./build-app/cst-agent-app --size 480x900 # the narrow (drawer) layout
./build-app/cst-agent-app --uitest # drive the window, exit code = failures
./build-app/cst-agent-app --selftest # same checks as the C test
```
The standalone build is the supported one: `app/CMakeLists.txt` uses the
`cstcore` target when the parent project defines one, and otherwise builds
the engine itself from `../src` (TUI, `main.c` and the HTTP front end
excluded). Wiring it into the repository's `CMakeLists.txt` as
`-DCST_APP=ON` is on the list below.
Needs Qt 6.4+ with **Quick / Qml / QuickControls2** (`qt6.qtdeclarative`,
`qt6.qtquickcontrols2` on Nix; the online installer's "Qt 6.x Desktop" on
Windows/macOS). Without them the configure step warns and builds only the C
smoke test — that is deliberate: the embedding path stays verifiable
everywhere. Kirigami is not required yet; `qml/Main.qml` uses Qt Quick
Controls and keeps the layout in Kirigami's shape (single column, cards,
navigation drawer) so swapping in `org.kde.kirigami` later is a QML-only
change. Point the app at a Kirigami outside the Qt prefix with
`-DCST_APP_QML_IMPORT=/path/to/qml`.
## Install (Linux desktop)
The window installs itself: the binary, a launcher entry and the icon the
launcher draws for it. All three are one CMake component (`app`), so the CLI
keeps coming from `scripts/bootstrap_linux.sh` and the profile never holds two
`cst-agent` of different vintages; the files live in `app/linux/`.
```sh
# NixOS / nix users: PATH + menu entry + icon. The Qt apps hook bakes the
# plugin and QML module paths into the wrapper, so the app runs outside a
# nix-shell and nix-collect-garbage cannot pull Qt out from under it.
nix-build # ./result/bin/cst-agent-app, run it in place
nix profile add --file . # or: ./result/bin/cst-agent-app
nix profile remove cst-agent # uninstall
# anywhere with Qt 6 (Quick/Qml/QuickControls2) and libcurl:
cmake -B build-app -DCST_BUILD_APP=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build-app -j
cmake --install build-app --component app --prefix ~/.local
```
Launched from a menu, the workspace is the directory the app starts in
(`$HOME` there), so sessions land in `~/.agent/sessions`; pass
`--workspace <dir>` to pin another one.
## Why in one process
The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the
`cstcore` target (CMake). The app owns one `cst_agent_t` per window, so
there is no protocol to version, no socket to authenticate and no second
process to babysit — and the CLI, TUI and WebUI keep working off the same
source.
- `AgentCore::send()` runs `cst_agent_run_turn_ex()` on a worker thread;
the `cst_agent_hooks_t` callbacks turn into Qt signals (assistant deltas,
tool events, tool results, todo, turn end).
- Consent is a blocking hook: the worker waits on a `QWaitCondition`, the
GUI answers, the turn goes on. `CST_PERM_DENY / ONCE / ALWAYS_TOOL /
ALWAYS_PREFIX` map 1:1 onto the buttons, and `ALWAYS_*` still lands in
`.agent/permissions.json`, so a desktop decision is visible to the TUI.
- `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal
result, not an exception.
## Layout and input
One shell, three sizes (everything is styled from `qml/Theme.qml`):
- **Desktop**: the sidebar is docked and its divider can be dragged (double
click restores the auto width). Hover states, tooltips, selectable text and
a wheel-scrolled transcript.
- **Narrow window** (below `Theme.compactBreakpoint`, 880 px): the sidebar
folds away, the header grows a `☰` button, and the settings slide in over
the transcript with a scrim; the sidebar itself scrolls, because a phone
window cannot show it all.
- **Touch**: controls keep a `Theme.touchTarget` (34 px) hit area, buttons do
not depend on hover, the composer asks the platform keyboard not to
autocorrect (`ImhNoPredictiveText`), and Enter/Shift+Enter still behave.
`--uitest` checks this without a human: it loads the window, clicks with
synthesized **mouse** events, taps with a synthesized **touch** device, types
into the composer, drives the session list, then narrows the window to 460 px
to assert the dock → drawer switch and opens/closes the drawer. One line per
check, non-zero exit on failure:
```sh
QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest
# ok wide window: sidebar docked
# ok mouse click on "general" reaches the engine
# ok touch tap on "coding" reaches the engine
# ...
```
## Sessions
A GUI session is a directory in the workspace's store
(`.agent/sessions/<id>/session.jsonl`, with its `title`, its `attachments/` and
its sub-agent transcripts beside it), the same one the CLI, the TUI and the
WebUI use — `AgentCore::open()` resolves the workspace's current session before
the engine starts, so nothing is written next to the process's working
directory. A store an older version wrote (flat `<id>.jsonl` files) still
reads; `cst-agent --migrate-store` moves it in.
- The sidebar lists the store (newest first): title or first prompt, message
count, "3h ago"; the open one is highlighted. `+ new` starts a session, `×`
deletes one (the engine first moves off a session it has open, so it never
sits on a file that is gone), and a click switches.
- Switching **replays** the transcript: `AgentCore::history()` walks the stored
messages and returns the same rows the live signals build — messages,
thoughts, notes, and one card per tool call, with its result paired by name
(a call with no result keeps state `unfinished`, so no spinner runs
forever). A reopened session therefore renders like the turn that wrote it.
- Session controls are disabled while a turn runs: switching would move the
engine out from under it.
`--uitest` covers the flow end to end: `writeFixtureSession()` writes a small
session through the store API (a tool call with its result), the checks click
its row, assert the five replayed rows and the paired result, start two new
sessions (the view empties), delete one by its `×` (the open one stays), and
switch back.
`--uitest` uses a scratch workspace unless `--workspace` is given, since it
creates and deletes sessions. On a machine without a screen,
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
Every line the run prints also goes to `uitest.log` in the workspace, and the
verdict is the last line of it. That is how a run *on a phone* is read back:
stdout reaches nobody there, an app started from adb cannot be handed
environment variables, and logcat does not carry this build's Qt messages --
so `--uitest --workspace /sdcard/cst-uitest` plus
`adb pull /sdcard/cst-uitest/uitest.log` is the whole round trip.
## Provider, model, key
The sidebar shows where the engine points (`provider · model`); the gear in the
header opens the dialog that changes it.
- The provider list is the engine's own preset table (`cst_provider_count()`),
with each entry's base URL and where its key would come from: "key saved"
(`.agent`-independent, `<config>/cst-agent/credentials`), "key from
OPENAI_API_KEY", or "no key". A key itself is never sent to the QML side —
the dialog only ever describes it. The window starts the engine without a
key (`AgentCore::open()` is passed an empty one), so `open()` resolves it
for the provider it starts on: saved key, then the provider's env variable,
then `CST_API_KEY` — the same order the CLI uses in `main.c`. Without that
the key sat in the file but never in the turn, and every start asked for it
again.
- "Use this" calls `AgentCore::useProvider()`, which runs
`cst_ps_switch_provider()` / `cst_ps_set_key()` on the engine's live config:
a pasted key is verified against the provider's `/models` before it is saved
(offline it is kept unverified and the header says so), the model is
remembered in `settings.json`, and the **session is not touched**. Model
candidates come from the provider entry, the fetched cache
(`cst_ps_cached`, no network) and the remembered pick; the label next to the
field is `cst_model_info` ("200K · text only").
- The dialog also reports the session's token use (`cst_agent_usage`).
`--uitest` checks it end to end: the presets are readable, an unknown provider
is refused, the gear opens the panel, the provider rows exist, a model typed
into the field reaches the engine when "Use this" is clicked, the note says so,
and Close puts the dialog away. The run points `XDG_CONFIG_HOME` at its scratch
directory, so settings and keys of the real user are never rewritten.
## Custom providers, model parameters
The dialog's **Advanced options** covers the two things the presets cannot:
a provider of your own, and what the model should do.
- A custom provider is an entry the engine keeps in
`<config>/cst-agent/models.json` (layer 1 of the model registry, see
`include/cst_models.h`) — the same file a hand-written override would go
into, so the GUI and a text editor are interchangeable:
```json
{ "providers": { "myllm": { "api_base": "https://api.example.com/v1",
"key_env": "MYLLM_API_KEY",
"default_model": "m1",
"models": ["m1", "m2"] } } }
```
`AgentCore::addProvider()` / `removeProvider()` write it
(`cst_models_user_set_provider()` / `cst_models_user_drop_provider()`) and
reload the registry, so the provider is in the list, in `models[]` on the
wire and in `/provider` of the CLI at once. A name a preset uses overrides
that preset (a self-hosted endpoint, a proxy); the other content of the file
(model rules) is kept, and a file that is not a JSON object is never
overwritten. The key is still a key: it is verified against `GET /models` of
the new base URL before it is saved, and stored per provider name as before.
- The generation parameters — **temperature**, **top-p**, **max tokens** — are
remembered per provider in `settings.json` (`"params": {...}`,
`cst_settings_remember_params()`), and applied to the engine's config by
`AgentCore::setGenParams()`, `AgentCore::useProvider()` and
`cst_agent_create()` (so a CLI or TUI run sends what the window was set to).
An **empty field is not sent at all** — that is not the same as sending 0,
which is a real temperature; `cst_llm_build_body()` only adds a field that
was set (`tests/test_llm_body.c:test_gen_params`).
- A long model list (a router lists hundreds) is filtered and capped at 40
chips with "… +N more", and the whole dialog body scrolls: the buttons stay
put and nothing can spill out of the frame.
## Attachments
The next prompt can carry files, and they arrive three ways:
- **📎 in the composer** opens a file picker (`QtQuick.Dialogs`).
- **Dropping files on the window** — a `DropArea` covers it; while something is
dragged over, the border turns accent and says "drop to attach".
- **Ctrl+V** with an image on the clipboard. Text is left to the editor's own
paste: the composer asks `AgentCore::clipboardHasImage()` first and only
intercepts the keystroke when there is an image.
`AgentCore::attachFile()` / `attachClipboard()` call `cst_agent_attach_file()` /
`cst_agent_attach_bytes()`: the engine validates the file, copies it into the
session's own directory (`.agent/sessions/<id>/attachments/`), and the next
`run_turn` carries it — the
window only shows what the engine's `pending` list says, one chip per file in
the engine's own words ("note.txt (text/plain, 21 B)"), with "clear all"
(`cst_agent_pending_clear`). A prompt consumes the list, so the chips go away
with the turn. When the model cannot be shown images
(`cst_agent_sees_images() == 0`), the composer says so and the engine sends
them as text notes instead.
`--uitest` writes a file into its scratch workspace and checks the whole path:
`attachFile` succeeds, the engine reports one pending attachment, a missing file
is refused with a reason, the composer shows one chip, "clear all" empties the
list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` and
`attachClipboard()` behave.
## Images
What a session keeps as a picture is shown, not only stored: a file
`view_image` returned, an image an MCP server sent, a screenshot the user
attached or pasted. Each one appears as a thumbnail in the transcript -- a
prompt's pictures under the message, a tool's under its card -- with the name,
size and origin in the tooltip. The height is capped so a 4K screenshot cannot
push the conversation off the screen, and a file the store no longer has says
which image it was instead of leaving a hole.
The view never touches the filesystem on its own authority: the thumbnails
come from `image://cstattach/<stored name>` (`app/src/ui/SessionImages.cpp`),
where the name is the one the engine wrote next to the session and is checked
with the engine's own `cst_attach_rel_ok()`, so only the session's attachment
directory can be read. The picture is decoded at the size of its thumbnail
rather than at its full resolution, and the provider's directory follows the
session: `AgentCore::attachmentDir()` is re-read on every switch.
A document (PDF, UTF-8 text) still arrives as its name in the message text --
those are sent to the model as text, not as a picture.
## Files the agent delivers
The opposite direction: the engine's `deliver_file` tool (registered in
`src/tools/registry.c`, run in `src/tools/fs_tools.c`) copies a finished file
out of the workspace into the session's own store, and the transcript shows it
as a **file card** -- an extension badge, the file's name, its type and size,
and two buttons.
- The copy is marked `for_user` (`cst_attachment_t.for_user`,
`cst_attach_import_artifact()`): any bytes up to `CST_ARTIFACT_MAX_BYTES`
(64 MB), the name's extension decides the mime, and **no request ever
contains it** -- `src/llm_openai.c` skips those entries when it builds the
body, which is what keeps a delivered report from being pasted back into the
context. `tests/test_artifacts.c` holds that down at the wire level.
- A card never invents a path. The row carries the stored file's name, and
`AgentCore::openArtifact()` / `shareArtifact()` accept it only when one of
the session's own messages carries that name (`artifactPathOf()`, plus the
engine's `cst_attach_rel_ok()`), so a forged name is refused with a reason.
- **Open** hands the file to the system: Android builds a `content://` URI
through the app's FileProvider, checks `resolveActivity()`, starts
`ACTION_VIEW` and grants the reader permission; a desktop calls
`QDesktopServices::openUrl`. **Share** uses the phone's share sheet; a
desktop has none, so the path goes to the clipboard.
- `CST_APP_NO_OPEN=1` makes both do nothing but report the line they would
write. `--uitest` sets it, so a test run never opens anything for real: it
replays a session with a delivered `report.md`, checks the card and its two
buttons, clicks Open (the header must say `opened report.md`), clicks Share
and checks a name the session does not own is refused.
### Making one: `md_to_pdf`
The front end registers a tool of its own for this (`app/src/core/PdfTools.cpp`,
owner `app:qt`, `platforms = NULL` so every platform the window runs on gets
it): markdown, or a workspace `.md`/`.html` file, becomes an A4 PDF with 15 mm
margins in the workspace, and goes through the same
`cst_attach_import_artifact()` path as `deliver_file` -- so it arrives as a
file card the person can open, print or share, and its bytes never enter a
request. The markdown is rendered by the engine (`cst_web_md_document()`, the
same renderer the WebUI and `md_to_html` use) and Qt lays the HTML out
(`QTextDocument` + `QPdfWriter`, both QtGui); pagination is written here,
page rect by page rect, because `QPrinter`/PrintSupport is desktop-only and a
phone has no print subsystem. The tool runs on the turn's worker thread, and
`--uitest` runs it through the registry and checks the file starts with
`%PDF-`.
The card row on the phone is the same one a picture uses: the store keeps the
PDF next to the session, the image provider refuses it (it is not an image),
and the file card carries it.
## Transcript, tool calls, streaming text
- A tool call is a card of its own, and the call **id** is what pairs it with
its result: the same tool may run twice in a turn, and pairing by name (what
the window did before) hung the first card on "running" forever while the
second one took its result. `AgentCore::toolEvent()` and `toolResult()` carry
the id (`ctl_tool_call_t.id`), `AgentCore::history()` fills it from the
session (`tool_call_id`, `tool_calls_json`), and only an id-less call from an
old text-protocol session still falls back to the name.
- Nothing may keep spinning after a turn: `closeAllRows()` settles every open
row when the turn ends, and a call with no result becomes "no result"
(`unfinished`) instead of a spinner that runs forever.
- The answer is rendered **while it streams**: a 120 ms timer re-renders the
open answer with the engine's own markdown renderer (`cst_web_md_html`, via
`AgentCore::renderMarkdown()`), so a list or a code fence fills in formatted
instead of turning into markdown when the turn is over. A replayed session
renders once, when its rows are built.
- Every signal handler is a plain function on the window
(`engineToolEvent`, `engineToolResult`, `engineTurnFinished`,
`engineAssistant`, ...) so `--uitest` can drive the same path: it opens two
calls of one tool, delivers one result, and asserts that the result landed on
its own card and that the turn end settled the other one. `rowJson(i)` exists
for that (a row as JSON — an object would not survive
`QMetaObject::invokeMethod`).
## Icons, colors
- Icons come from the desktop's **icon theme** (Breeze on KDE), through
`image://csttheme/<name>?color=%23rrggbb` (`app/src/ui/ThemeIcons.{h,cpp}`).
Qt Quick has no icon provider of its own without Kirigami, so the window used
unicode glyphs before (a clip emoji, a gear, a hamburger): those render in
whichever font covers them — colour emoji in a grey toolbar — and are a box
on a machine without that glyph. `app/qml/ThemeIcon.qml` is the one place
that asks for an icon, and every use still names the old character as
`glyph:`: a host with no icon theme degrades to what it looked like before
rather than going blank.
- The provider fills Qt's theme search paths itself (XDG data dirs, `~/.icons`
and, on NixOS, `/run/current-system/sw/share/icons`): Qt only builds them
from XDG once a theme name is set. It then tries the theme the desktop chose,
else `breeze-dark` (this window is dark), `breeze`, `hicolor`. Rasterising
Breeze needs the Qt SVG image format plugin (`qt6.qtsvg` on Nix, part of a
desktop Qt install) — without it the icons fall back to the glyphs instead of
disappearing.
- `Theme.toolIcon(name)` maps a tool onto its icon: patch/write → pencil,
read → text page, list/glob → folder, grep → magnifier, shell → braces,
web → globe, plan → list, ask → question mark. Toolbar icons are 22 px
(Breeze's toolbar size), card and row icons 14–16 px.
- `textFaint` is readable grey, not decoration (~5:1 on the card fill): it
carries hints, secondary lines and provider URLs. Anything written *on* the
accent fill (a selected provider row, the open session) uses `accentText`,
`accentTextDim` and `okOnAccent` — green or faint grey on `accentDim` is
unreadable. The names are not `onAccent…`: QML reads every `onFoo` property
as a signal handler and refuses the assignment.
- The composer's frame is one `Theme.pad` around its column on **all** sides;
with the top margin alone the clip and Send hung over the lower border.
## Tools the app provides
A tool implemented in the front end is registered with `cst_tool_register()`
and the owner `app:<name>`, exactly like a plugin or MCP server would:
```c
cst_tool_def_t def = {0};
def.name = "notification_send";
def.desc = "Send a system notification.";
def.perm = CST_TP_ASK; /* class: drives the consent dialog */
def.perm_name = "notify"; /* policy: what the user is really granting */
def.platforms = ANDROID_ONLY; /* NULL = everywhere */
def.available = probe; /* 0 / -1 with a reason; cached */
def.run_ex = my_run;
def.ud = my_state;
def.owner = "app:android";
cst_tool_register(&def);
```
- `platforms` + `available` decide *whether* the tool exists here: an
Android-only tool never reaches the prompt or `tools[]` on Linux, and
`cst_tool_run()` refuses it with the reason if a model asks anyway.
- A front-end tool may take over the **name** of a built-in that cannot run
on this host (the Android clipboard replacing the desktop `xclip` one);
it may not shadow one that works.
- `cst_tool_unregister_owner("app:android")` drops them again — do it when a
platform service goes away, then `cst_tool_meta_reload()`.
- `setenv("CST_PLATFORM", ...)` overrides the detected host name (tests, a
launcher that knows better).
Note `run_ex` runs on the worker thread while the turn is going: touch the
GUI only through queued signals.
## Platform notes
| Platform | How the engine gets built | Platform tools |
|---|---|---|
| Linux | `cmake -S app -B build-app` (libcurl via pkg-config) | clipboard (shell helpers), notification (`notify-send` / D-Bus) |
| Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=<Qt>/lib/cmake`; the engine needs libcurl for Windows (see `docs/README-windows.md`) | notification via the WinRT/tray API, clipboard through the core's Win32 path |
| Android | engine compiled with the NDK inside the APK (`scripts/build_apk_android.sh`, `-DCST_TUI=0`, `CST_NO_WEBUI=1`); no port, QML talks to `AgentCore` in-process | notification (`Notifier.java`: channel + permission probe), clipboard (`QClipboard` under the engine's own tool names), share sheet (`ACTION_SEND` + chooser), and the file card's open/share through the app's FileProvider (`AndroidTools.cpp`) |
| macOS | Qt from the installer, libcurl from the system | notification (`osascript`), clipboard (`pbpaste`/`pbcopy`) |
Android's long-term shape: the core lives in the APK (no Termux needed), the
platform tools are Kotlin/JNI behind the same `cst_tool_register()` contract.
The build flag that keeps Unix helpers out of the picture is
`CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine).
A Java class is added by dropping it in `app/android/src/<package>/`:
androiddeployqt copies `app/android/` with the rest of the package source and
Qt's Gradle template puts its `src/` on `java.srcDirs`, so no build-script
change is needed (`Notifier.java` is the first one; a Java helper is the only
way to catch a throwable where the platform throws instead of returning).
`CstService.java` (the foreground service that holds a phone awake while a
turn runs) went in the same way; it is started by `app::holdForeground()`
when a turn begins and stopped by `app::releaseForeground()` when it ends,
so nothing about it is visible when the app is idle.
## Status
- [x] engine as a library (`libcstcore.a` / `cstcore`), in-process embed API
- [x] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule
- [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test
- [x] QML shell: transcript cards, tool cards with diff coloring, thinking
blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout,
`--uitest` for mouse, touch and small-screen checks
- [x] session list in the sidebar: new / switch / delete, transcript replay
- [x] settings dialog: provider, model, key (`cst_ps_*`), token counters
- [x] attachments: picker, drop on the window, Ctrl+V image paste
- [x] icons from the desktop's icon theme (Breeze), with a glyph fallback
- [x] contrast pass: faint text and selected rows are readable
- [x] images in the transcript: tool results and attachments are drawn as
thumbnails (`image://cstattach/<stored name>`, see the Images section)
- [x] Android APK: the engine is linked in (no port), builds, installs under
`xyz.whcself.cstagent` and runs on the device (see `docs/android.md`)
- [x] the saved provider key reaches the engine on every start (`AgentCore::open()`
resolves it: saved key, provider environment, `CST_API_KEY`)
- [x] delivered files: `deliver_file` stores the copy next to the session and the
transcript draws a file card with Open and Share (see the Files section)
- [x] Android platform tools (JNI: notification, clipboard, share sheet) and the
file card's open/share through the app's FileProvider
- [x] a turn keeps running with the app off screen (Android foreground service
+ wake lock; nothing to do on a desktop)
- [x] desktop install: launcher entry + icon, `nix profile add --file .`
- [ ] a real turn driven through the window (needs a provider key)
- [ ] SAF file picker, the back gesture, the icon glyphs on the phone
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
global shortcuts
+113
View File
@@ -0,0 +1,113 @@
<?xml version="1.0"?>
<!-- The APK's manifest (QT_ANDROID_PACKAGE_SOURCE_DIR in app/CMakeLists.txt):
androiddeployqt copies this file and res/ into its gradle project and
fills in the INSERT_* placeholders below (version code and name, library
name, app arguments). An XML comment may not contain a double hyphen, so
the placeholder syntax is only spelled out in the attributes themselves.
The SDK levels are not here on purpose: they come from
QT_ANDROID_MIN_SDK_VERSION and QT_ANDROID_TARGET_SDK_VERSION in
app/CMakeLists.txt, and androiddeployqt writes them into the generated
gradle.properties. The app name and icon come from res/ next to this
file (values/strings.xml, drawable/ic_launcher.xml).
WHAT THIS APP IS: the engine runs in this process (no server, no port) and
writes everything (workspace, session store, settings, credentials) under
the app's own data directory, which main.cpp points the engine at (see
androidRoot() there).
Permissions: INTERNET for the providers the user configures,
POST_NOTIFICATIONS for the app's notification_send tool (Android 13+
asks at runtime; the tool stays hidden until it is granted), the
foreground-service permissions with WAKE_LOCK for the service that keeps
a turn going while the phone is in a pocket (CstService.java), and
MANAGE_EXTERNAL_STORAGE ("all files access", Android 11+) so the engine
can read a path in shared storage that a task names rather than picks:
Android only offers the Settings screen for it, so main.cpp opens that
once and everything still works without it: what the system picker (SAF)
hands over is copied into the app's own directory (AttachFile.java). No
cleartext traffic: every provider URL is https. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="xyz.whcself.cstagent"
android:installLocation="auto"
android:versionCode="-- %%INSERT_VERSION_CODE%% --"
android:versionName="-- %%INSERT_VERSION_NAME%% --">
<!-- %%INSERT_PERMISSIONS -->
<!-- %%INSERT_FEATURES -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- "All files access": without it the engine can only read the app's own
directory, so a task that names a file in shared storage cannot be
answered. Android 11+ has no runtime dialog for it, only the Settings
screen (AttachFile.java opens it once). Files the user picks work
either way: their content:// grant is copied into the app first. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
android:normalScreens="true"
android:smallScreens="true" />
<application
android:name="org.qtproject.qt.android.bindings.QtApplication"
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:allowBackup="true"
android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="false">
<activity
android:name="org.qtproject.qt.android.bindings.QtActivity"
android:label="@string/app_name"
android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|keyboard|keyboardHidden|navigation|density|mcc|mnc"
android:launchMode="singleTop"
android:screenOrientation="unspecified"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="-- %%INSERT_APP_LIB_NAME%% --" />
<meta-data
android:name="android.app.arguments"
android:value="-- %%INSERT_APP_ARGUMENTS%% --" />
</activity>
<!-- The share tool hands a file out of the workspace, and a file
crossing an app boundary has to travel as a content:// uri. This
is Qt's own provider (androidx.core.content.FileProvider, paths
from res/xml/qtprovider_paths.xml): its files-path is the app's
files directory, which is where the workspace lives. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.qtprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/qtprovider_paths" />
</provider>
<!-- A turn can outlive the screen: while one is running the front end
starts this service (AgentCore::send -> app::holdForeground), which
makes the process a foreground one, so Android neither freezes nor
kills it in the background, and takes a partial wake lock so the
CPU keeps going with the phone in a pocket. dataSync is the type
that fits "the app is finishing some work". -->
<service
android:name="xyz.whcself.cstagent.CstService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Launcher icon: the terminal prompt the app is about ("&gt;_"), the window's
own colors (bg #12161a, accent #5cc0f0, faint #8d959c). A vector drawable
works from API 24 up; a proper adaptive icon (mipmap-anydpi-v26 + a PNG
fallback) is polish for later. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#12161a"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:strokeColor="#5cc0f0"
android:strokeWidth="7"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M32,38 L52,54 L32,70" />
<path
android:fillColor="#00000000"
android:strokeColor="#8d959c"
android:strokeWidth="7"
android:strokeLineCap="round"
android:pathData="M58,74 L78,74" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The name under the launcher icon. -->
<resources>
<string name="app_name">CST-agent</string>
</resources>
@@ -0,0 +1,189 @@
package xyz.whcself.cstagent;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.OpenableColumns;
import android.provider.Settings;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
/* What the user picks in the system file chooser comes back as a content://
* URI, and the engine opens files by path -- so the bytes are copied into the
* app's own directory first. That copy needs no permission at all (the grant
* travels with the URI), which is what makes picking work on a phone that
* grants nothing else.
*
* The "all files access" below is a different thing: it is what lets the
* engine read /sdcard by path afterwards (a model that opens a file the user
* names rather than picks). Android 11+ has no runtime dialog for it -- only
* the Settings screen -- so it is a request, not a demand: everything works
* without it, one file at a time.
*
* Qt swallows a Java exception (it comes back as a null object), so every
* method catches its own and answers with a reason instead.
*/
public final class AttachFile {
private AttachFile() {}
private static final long MAX_BYTES = 256L * 1024 * 1024;
public static boolean isContent(String s) {
return s != null && s.startsWith("content:");
}
/* content://<uri> -> the path of a copy inside destDir; "" when the string
* is already a path; "error: ..." when the copy failed. */
public static String materialize(Context ctx, String pathOrUri, String destDir) {
if (pathOrUri == null || pathOrUri.isEmpty())
return "error: no file was picked";
if (!isContent(pathOrUri))
return "";
try {
if (ctx == null)
return "error: no Android context";
final Uri uri = Uri.parse(pathOrUri);
String name = displayName(ctx, uri);
if (name == null || name.trim().isEmpty())
name = uri.getLastPathSegment();
name = sanitize(name);
File dir = new File(destDir);
if (!dir.isDirectory() && !dir.mkdirs())
return "error: cannot use " + destDir;
File out = unique(dir, name);
final ContentResolver cr = ctx.getContentResolver();
final InputStream in = cr.openInputStream(uri);
if (in == null)
return "error: the picker gave no data for that file";
long total = 0;
try {
final OutputStream os = new FileOutputStream(out);
try {
final byte[] buf = new byte[64 * 1024];
int n;
while ((n = in.read(buf)) > 0) {
total += n;
if (total > MAX_BYTES) {
os.close();
in.close();
out.delete();
return "error: that file is larger than 256 MB";
}
os.write(buf, 0, n);
}
} finally {
os.close();
}
} finally {
in.close();
}
if (total == 0) {
out.delete();
return "error: that file is empty";
}
return out.getAbsolutePath();
} catch (Throwable t) {
return "error: cannot read the picked file: " + t;
}
}
/* Android 11+ keeps "all files access" in a special app-op; older systems
* have no such gate. */
public static boolean hasAllFilesAccess(Context ctx) {
try {
if (Build.VERSION.SDK_INT < 30)
return true;
return Environment.isExternalStorageManager();
} catch (Throwable t) {
return false;
}
}
/* The Settings screen Android requires for it. "" when it was opened. */
public static String requestAllFilesAccess(Context ctx) {
try {
if (Build.VERSION.SDK_INT < 30 || ctx == null)
return "";
if (Environment.isExternalStorageManager())
return "";
final Intent intent = new Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + ctx.getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
ctx.startActivity(intent);
} catch (ActivityNotFoundException notFound) {
final Intent all = new Intent(
Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
all.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
ctx.startActivity(all);
}
return "";
} catch (Throwable t) {
return "cannot open the storage settings: " + t;
}
}
private static String displayName(Context ctx, Uri uri) {
Cursor c = null;
try {
c = ctx.getContentResolver().query(uri,
new String[] { OpenableColumns.DISPLAY_NAME }, null, null,
null);
if (c != null && c.moveToFirst()) {
final int i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
if (i >= 0)
return c.getString(i);
}
} catch (Throwable ignored) {
/* fall back to the last path segment */
} finally {
if (c != null)
c.close();
}
return null;
}
/* A plain file name: no separators, no traversal, no control bytes. */
private static String sanitize(String name) {
if (name == null || name.isEmpty())
return "picked.bin";
final StringBuilder b = new StringBuilder();
for (int i = 0; i < name.length() && b.length() < 120; i++) {
final char ch = name.charAt(i);
if (ch == '/' || ch == '\\' || ch < 0x20)
b.append('_');
else
b.append(ch);
}
String s = b.toString();
while (s.startsWith("."))
s = s.substring(1);
if (s.isEmpty())
return "picked.bin";
return s;
}
private static File unique(File dir, String name) {
File f = new File(dir, name);
if (!f.exists())
return f;
final int dot = name.lastIndexOf('.');
final String stem = dot > 0 ? name.substring(0, dot) : name;
final String ext = dot > 0 ? name.substring(dot) : "";
for (int i = 2; i < 1000; i++) {
f = new File(dir, stem + "-" + i + ext);
if (!f.exists())
return f;
}
return f;
}
}
@@ -0,0 +1,143 @@
package xyz.whcself.cstagent;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
/**
* The service that keeps a turn running while the app is not on screen.
*
* A phone freezes an app it cannot see (and may kill it), so a turn that takes
* minutes -- a build, a long web fetch, a provider that thinks -- dies in the
* pocket. While one is going, AgentCore::send() starts this service through
* app::holdForeground(); being a foreground service makes the process one
* Android does not touch, and the partial wake lock keeps the CPU awake with
* the screen off. turnFinished() stops it again (app::releaseForeground()), so
* the permanent notification exists exactly as long as the work does.
*
* Started with an explicit component (AndroidTools.cpp builds the Intent), no
* intent filter: nothing outside the app can start it.
*/
public class CstService extends Service {
/** Same channel the notification_send tool uses: one place in settings. */
private static final String CHANNEL_ID = "cst-agent";
private static final int NOTE_ID = 2;
private static final String ACTION_STOP = "xyz.whcself.cstagent.STOP";
/** A turn that runs longer than this is stuck; the lock must not outlive it. */
private static final long WAKE_LOCK_TIMEOUT_MS = 30 * 60 * 1000L;
private PowerManager.WakeLock wake;
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (intent != null && ACTION_STOP.equals(intent.getAction())) {
stopEverything();
return START_NOT_STICKY;
}
String text = intent == null ? null : intent.getStringExtra("text");
try {
startForeground(NOTE_ID, buildNote(text));
} catch (Throwable t) {
/* Android 12+ refuses a foreground start from the background: the
* turn then simply runs without the protection rather than not at
* all. Nothing to show the user here -- the turn's own result is
* what matters. */
stopSelf();
return START_NOT_STICKY;
}
acquireWake();
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
releaseWake();
super.onDestroy();
}
private void stopEverything() {
releaseWake();
if (Build.VERSION.SDK_INT >= 24)
stopForeground(STOP_FOREGROUND_REMOVE);
else
stopForeground(true);
stopSelf();
}
private void acquireWake() {
try {
if (wake == null) {
PowerManager pm =
(PowerManager) getSystemService(Context.POWER_SERVICE);
if (pm == null)
return;
wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
"cst-agent:turn");
wake.setReferenceCounted(false);
}
wake.acquire(WAKE_LOCK_TIMEOUT_MS);
} catch (Throwable ignored) {
/* no wake lock: the turn still runs while the screen is on */
}
}
private void releaseWake() {
try {
if (wake != null && wake.isHeld())
wake.release();
} catch (Throwable ignored) {
}
}
/** "the agent is working: <first line of the prompt>", tapping opens it. */
private Notification buildNote(String text) {
Context ctx = getApplicationContext();
NotificationManager manager =
(NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager != null && Build.VERSION.SDK_INT >= 26) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID, "CST-agent", NotificationManager.IMPORTANCE_LOW);
channel.setDescription("A turn is running");
manager.createNotificationChannel(channel);
}
Notification.Builder builder = Build.VERSION.SDK_INT >= 26
? new Notification.Builder(ctx, CHANNEL_ID)
: new Notification.Builder(ctx);
builder.setContentTitle("CST-agent is working")
.setSmallIcon(smallIcon())
.setOngoing(true)
.setShowWhen(false);
if (text != null && !text.isEmpty())
builder.setContentText(text);
Intent open = ctx.getPackageManager().getLaunchIntentForPackage(ctx.getPackageName());
if (open != null) {
open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
builder.setContentIntent(PendingIntent.getActivity(ctx, 0, open,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
}
return builder.build();
}
/** A framework drawable first (the same order the tool uses), then the app's. */
private int smallIcon() {
int icon = getResources().getIdentifier("ic_dialog_info", "drawable", "android");
if (icon == 0)
icon = getResources().getIdentifier("stat_notify_chat", "drawable", "android");
if (icon == 0)
icon = getApplicationInfo().icon;
return icon;
}
}
@@ -0,0 +1,60 @@
package xyz.whcself.cstagent;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
/**
* The notification behind the engine's notification_send tool.
*
* It lives in Java because a Java exception is invisible to C++ by the time
* Qt's JNI wrapper is done with the call: Notification.Builder.build() can
* throw (a small icon Android refuses, a channel it does not know) and the
* JNI call then simply returns null -- the tool could only say "failed, no
* exception detail" on a device where the phone's own shade never saw
* anything. Catching it here turns the failure into the line the tool shows.
*
* Called from app/src/platform/android/AndroidTools.cpp through QJniObject.
* androiddeployqt copies this file with the rest of the package source
* (QT_ANDROID_PACKAGE_SOURCE_DIR, app/android/) and Qt's Gradle template adds
* "src" to java.srcDirs, so no build script change is needed for a file
* added here.
*/
public final class Notifier {
private Notifier() {}
/**
* Post one notification. An empty channelId means an API below 26, which
* has no channels. Returns "" when it went out, the Java failure
* otherwise (class name and message), so the tool result can name it.
*/
public static String post(Context ctx, String channelId, String title,
String text, int iconId, Intent tap, int id) {
try {
Notification.Builder builder = channelId == null || channelId.isEmpty()
? new Notification.Builder(ctx)
: new Notification.Builder(ctx, channelId);
builder.setContentTitle(title).setContentText(text).setSmallIcon(iconId);
if (tap != null) {
tap.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pending = PendingIntent.getActivity(
ctx, 0, tap,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
builder.setContentIntent(pending);
}
NotificationManager manager = (NotificationManager)
ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null)
return "no NotificationManager";
manager.notify(id, builder.build());
return "";
} catch (Throwable t) {
String message = t.getMessage();
return t.getClass().getSimpleName()
+ (message == null || message.isEmpty() ? "" : ": " + message);
}
}
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<defs id="defs3051">
<style type="text/css" id="current-color-scheme">.ColorScheme-Text { color: #fcfcfc; } </style>
</defs>
<g transform="translate(1,1)">
<path style="fill:currentColor;fill-opacity:1;stroke:none" d="m3 5v2h16v-2h-16m0 5v2h16v-2h-16m0 5v2h16v-2h-16" class="ColorScheme-Text"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 446 B

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

After

Width:  |  Height:  |  Size: 549 B

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

After

Width:  |  Height:  |  Size: 547 B

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

After

Width:  |  Height:  |  Size: 589 B

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 741 B

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

After

Width:  |  Height:  |  Size: 583 B

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

After

Width:  |  Height:  |  Size: 471 B

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