16 Commits
Author SHA1 Message Date
WHC 580d533656 v0.9.6: documents, downloads, PDFs and the phone's own files
ci / tcc-check (push) Successful in 1m9s
ci / linux (aarch64, 0) (push) Failing after 1h0m2s
ci / linux (x86, 1) (push) Failing after 1h0m2s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 10m4s
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