Compare commits
@@ -59,7 +59,10 @@
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(chmod -R 777 *)",
|
||||
"Bash(chmod 777 *)"
|
||||
"Bash(chmod 777 *)",
|
||||
"Bash(git add -A*)",
|
||||
"Bash(git add --all*)",
|
||||
"Bash(git add .)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,16 @@ Follow these conventions whenever you create a commit in this repository. These
|
||||
|
||||
## Message style
|
||||
|
||||
- **First line:** imperative mood, ≤ 70 characters. Examples: `add sidecar PTY scaffold`, `fix IPC reconnect after app reload`, `update CLI exit-code contract`.
|
||||
This repo uses [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) (per [D-37](../../../governance/decisions/process.md#d-37)).
|
||||
|
||||
- **First line:** `type(scope): imperative subject`, ≤ 72 characters **including** the prefix. Examples: `feat(settings): add Appearance font picker (T-460)`, `fix(ipc): reconnect after app reload`, `docs(readme): drop brittle version line`.
|
||||
- **Type:** one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`. Use `feat`/`fix` for user-visible behavior; `chore` for bookkeeping (`chore(plan)` is the convention for pql ticket housekeeping). Append `!` after the scope for a breaking change (`feat(ipc)!: …`).
|
||||
- **Scope (optional but preferred):** the subsystem the change lives in — `settings`, `vim`, `pty`, `git`, `plan`, etc. Lower-case, no spaces.
|
||||
- **Ticket ref:** keep a trailing `(T-NNN)` on the subject when the work has a ticket — `feat(settings): category rail + navigation (T-447)`.
|
||||
- **Body (optional):** wrap at ~72 chars. Explain the *why* — the reason this change exists. The diff already shows the *what*; don't restate it in prose.
|
||||
- **No emojis.** Anywhere.
|
||||
- **Don't prefix with types** like `feat:` or `fix:` — this repo isn't Conventional Commits. (The Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild at the repo root does not.)
|
||||
- **Don't reference the current task or flow** (`for the v2.0 milestone`, `used by the canvas panel`) — that context belongs in the PR description and rots as the repo evolves.
|
||||
- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the Go sidecar/CLI binary is `clide`. The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`.
|
||||
- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the `clide` CLI is a thin C client (`native/clide-cli/`). The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`.
|
||||
|
||||
## Logically-separated commits
|
||||
|
||||
|
||||
@@ -36,8 +36,15 @@ These apply across every reference and every surface:
|
||||
- Never use `Material*` or `Cupertino*` widgets or color constants — clide
|
||||
is `WidgetsApp` only (D-7).
|
||||
- Use `ClideText` for themed text; never bare `Text` in production widgets.
|
||||
- Typography: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
|
||||
- Typography sizes: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
|
||||
status/section headers, body inherits from `DefaultTextStyle`.
|
||||
- Font *family* comes from the user-selectable facade, not a const: a
|
||||
monospace surface uses `fontFamily: ClideSettings.fonts.monoOf(context)`
|
||||
(and `fontFamilyFallback: clideMonoFamilyFallback`); the UI face is inherited
|
||||
via the root `DefaultTextStyle`, or `ClideSettings.fonts.uiOf(context)` when a
|
||||
widget must set it explicitly. `clideMonoFamily` / `clideUiFamily` are the
|
||||
facade's defaults — don't read them directly in new widgets (D-101). Same
|
||||
facade exposes `ClideSettings.theme.of(context)` and `.i18n.of(context)`.
|
||||
|
||||
## Conversation-panel cards (T-305)
|
||||
|
||||
|
||||
@@ -112,9 +112,37 @@ tooltip → tooltipBackground / tooltipForeground / tooltipBorder
|
||||
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
|
||||
```
|
||||
|
||||
## Settings & grouped lists — sectioned cards
|
||||
|
||||
Settings surfaces and any long grouped list (e.g. the Claude config lists)
|
||||
read as **sectioned cards**, not bare rows floating on the panel. Each logical
|
||||
group gets its own card; the small-caps section label (+ optional count) sits
|
||||
just **above** the card.
|
||||
|
||||
```
|
||||
panel bg → panelBackground (#20202C)
|
||||
card surface → surface (#242838) fill + dividerColor/border (1px), ~6px corners
|
||||
section head → sidebarSectionHeader (small-caps), with the count muted to its right
|
||||
control inset → inputs INSIDE a card recede to panelBackground, so they still
|
||||
read as fields against the elevated card
|
||||
```
|
||||
|
||||
- **One card per group** — a settings table, each config list. The card's
|
||||
elevated fill + border do the visual separation; don't rely on spacing alone.
|
||||
- **Field row inside a card:** label (`globalForeground`) + help
|
||||
(`globalTextMuted`) + the control right-aligned, with the per-field scope tag
|
||||
in the far-right column.
|
||||
- **Scroll, don't cram:** when stacked cards exceed the modal/pane viewport, the
|
||||
pane scrolls vertically (sticky header, scrolling body) — prefer that over
|
||||
shrinking content to fit one screen.
|
||||
- Pattern reference: the settings wireframes under
|
||||
`docs/design/wireframes/settings/` (T-302).
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- `globalBackground` for panel fill → use `panelBackground`
|
||||
- Bare settings rows on the panel where a group of them should be one card →
|
||||
see "Settings & grouped lists".
|
||||
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
|
||||
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
|
||||
- Tab active border = `globalFocus` → use `panelActiveBorder`
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Gitea Actions workflow for clide.
|
||||
#
|
||||
# NOT YET ACTIVATED. Gitea Actions must be enabled in the instance
|
||||
# settings before this runs; until then the file is just a ready-made
|
||||
# pipeline Claude + the user can review.
|
||||
#
|
||||
# When the repo eventually lands on GitHub, copy this file verbatim to
|
||||
# `.github/workflows/test.yml` — Gitea Actions consumes GitHub-Actions
|
||||
# syntax, so no rewrite is needed.
|
||||
#
|
||||
# Steps go through the make targets (the repo's tooling-discipline rule:
|
||||
# the make layer sets up the environment — gen-build-info etc. — and
|
||||
# stays correct if a wrapped script moves). T-384 fixed three latent
|
||||
# breaks here: a `cd app` into the flattened-away app/ directory, a
|
||||
# coverage gate with no coverage run before it, and raw ci/ script
|
||||
# invocations that skipped build-info generation.
|
||||
|
||||
name: test
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit + widget + golden + a11y + coverage gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: flutter pub get
|
||||
# test-coverage runs the full fast suite WITH coverage (it includes
|
||||
# the a11y suite — see the push-check note in the Makefile), which
|
||||
# is what coverage-gate consumes.
|
||||
- run: make test-coverage
|
||||
- run: make coverage-gate
|
||||
|
||||
integration:
|
||||
name: integration_test (xvfb)
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
|
||||
- run: flutter pub get
|
||||
- uses: coactions/setup-xvfb@v1
|
||||
with: { run: make test-integration }
|
||||
|
||||
startup-bundle:
|
||||
name: bundle smoke (xvfb 5s)
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
|
||||
- run: flutter pub get
|
||||
- run: make smoke-bundle
|
||||
|
||||
# The web-WASM Playwright job is withheld: `flutter build web --wasm`
|
||||
# cannot compile the tree since the tree-sitter/PTY dart:ffi pivot
|
||||
# (dart:ffi is unavailable on the wasm target). Whether the web target
|
||||
# gets conditional-import fences or is dropped is an open question —
|
||||
# see Q-50 in governance/questions/architecture.md. Re-add the job
|
||||
# (steps: setup-node, npm install + playwright install in tools/ui,
|
||||
# `make test-e2e`) when Q-50 resolves toward keeping it.
|
||||
|
||||
docs:
|
||||
name: dart doc (lib API)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: flutter pub get
|
||||
- name: dart doc --validate-links (fail on warning)
|
||||
run: |
|
||||
set -o pipefail
|
||||
dart doc --validate-links 2>&1 | tee dartdoc.log
|
||||
if grep -q "^ warning:" dartdoc.log; then
|
||||
echo "::error::dartdoc emitted warnings — see log above"
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dart-api-docs
|
||||
path: doc/api/
|
||||
@@ -1,3 +1,9 @@
|
||||
#!/bin/sh
|
||||
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout)
|
||||
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout"
|
||||
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout).
|
||||
# The pql hook is untracked (a local `pql init` install), so a fresh
|
||||
# `git worktree add` has no .pql/hooks — source it only when present, and
|
||||
# always exit 0: post-checkout is best-effort and must never abort the
|
||||
# checkout / worktree creation.
|
||||
hook="$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout"
|
||||
if [ -f "$hook" ]; then . "$hook"; fi
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
name: release
|
||||
|
||||
# Build + publish versioned Windows and Linux release bundles when the version
|
||||
# in pubspec.yaml changes on main. The `version` job only proceeds when the
|
||||
# v<version> tag doesn't already exist, so an unrelated pubspec edit is a no-op.
|
||||
#
|
||||
# FIRST CUT — neither build has run in CI yet (Windows has never been built at
|
||||
# all), so expect to iterate on these from the first run's logs. The repo's own
|
||||
# `make` targets are the build contract (gen-build-info + clide-cli + flutter
|
||||
# build, all wired in `make build`).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['pubspec.yaml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write # create the tag + the release
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
fresh: ${{ steps.v.outputs.fresh }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 0 } # tags, to tell new vs. already-released
|
||||
- id: v
|
||||
shell: bash
|
||||
run: |
|
||||
version=$(awk -F': *' '/^version:/ {gsub(/[" ]/,"",$2); print $2; exit}' pubspec.yaml)
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
if git rev-parse "v$version" >/dev/null 2>&1; then
|
||||
echo "fresh=false" >> "$GITHUB_OUTPUT"
|
||||
echo "v$version already tagged — nothing to release."
|
||||
else
|
||||
echo "fresh=true" >> "$GITHUB_OUTPUT"
|
||||
echo "v$version is new — building."
|
||||
fi
|
||||
|
||||
build-linux:
|
||||
needs: version
|
||||
if: needs.version.outputs.fresh == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable }
|
||||
- run: sudo apt-get update && sudo apt-get install -y ninja-build libgtk-3-dev
|
||||
- run: make dugite-fetch
|
||||
- run: make build # gen-build-info + clide-cli + flutter build linux
|
||||
- name: package
|
||||
run: tar -C build/linux/x64/release/bundle -czf clide-linux-x64-${{ needs.version.outputs.version }}.tar.gz .
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: linux, path: clide-linux-x64-*.tar.gz }
|
||||
|
||||
build-windows:
|
||||
needs: version
|
||||
if: needs.version.outputs.fresh == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable }
|
||||
- run: choco install -y make
|
||||
- name: build
|
||||
shell: bash
|
||||
run: make dugite-fetch && make build # MSVC + bash already on windows-latest
|
||||
- name: package
|
||||
shell: pwsh
|
||||
run: Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath clide-windows-x64-${{ needs.version.outputs.version }}.zip
|
||||
- uses: actions/upload-artifact@v4
|
||||
with: { name: windows, path: clide-windows-x64-*.zip }
|
||||
|
||||
publish:
|
||||
needs: [version, build-linux, build-windows]
|
||||
if: needs.version.outputs.fresh == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: changelog notes for this version
|
||||
shell: bash
|
||||
run: |
|
||||
ver="${{ needs.version.outputs.version }}"
|
||||
# Pull the entries under `## [<version>]` — the changelog cut that the
|
||||
# version-bump commit lands per the changelog discipline — as the
|
||||
# release body; fall back to a one-liner if the section is absent.
|
||||
awk -v ver="$ver" '
|
||||
$0 ~ "^## \\[" ver "\\]" {grab=1; next}
|
||||
grab && /^## \[/ {exit}
|
||||
grab {print}
|
||||
' CHANGELOG.md > release-notes.md
|
||||
[ -s release-notes.md ] || echo "Release v$ver." > release-notes.md
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { path: dist }
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.version.outputs.version }}
|
||||
name: clide v${{ needs.version.outputs.version }}
|
||||
body_path: release-notes.md # the version's CHANGELOG section
|
||||
generate_release_notes: true # + auto commit list appended
|
||||
files: dist/**/* # the built versioned bundles
|
||||
@@ -0,0 +1,152 @@
|
||||
name: test
|
||||
|
||||
# Linux CI for clide (GitHub Actions). Moved here from .gitea/workflows/ when CI
|
||||
# consolidated onto GitHub (the primary remote); the Gitea secondary has Actions
|
||||
# disabled. Pairs with windows.yml (ConPTY suite on windows-latest) and
|
||||
# release.yml (versioned release bundles).
|
||||
#
|
||||
# Steps go through the make targets (the repo's tooling-discipline rule: the
|
||||
# make layer sets up the environment — gen-build-info etc. — and stays correct
|
||||
# if a wrapped script moves).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit + widget + golden + a11y + coverage gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: flutter pub get
|
||||
- name: install pql
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release download --repo postmeridiem/pql --pattern 'pql_*_Linux_x86_64.tar.gz' --output /tmp/pql.tgz
|
||||
tar -xzf /tmp/pql.tgz -C /tmp
|
||||
sudo install -m 0755 /tmp/pql /usr/local/bin/pql
|
||||
pql --version
|
||||
# The pql tests query the repo's vault, but .pql/pql.db is gitignored
|
||||
# (the post-checkout hook rebuilds it from the committed changelog).
|
||||
# A fresh CI checkout has the changelog but no db — materialize it,
|
||||
# and sync decision records from the governance/ DQR markdown tree
|
||||
# (tickets come from the changelog; decisions from `decisions sync`).
|
||||
pql plan import
|
||||
pql decisions sync
|
||||
# test-coverage runs the full fast suite WITH coverage (it includes the
|
||||
# a11y suite — see the push-check note in the Makefile), which is what
|
||||
# coverage-gate consumes.
|
||||
- run: make test-coverage
|
||||
- run: make coverage-gate
|
||||
|
||||
integration:
|
||||
name: integration_test (xvfb)
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
|
||||
- run: flutter pub get
|
||||
- name: install pql
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release download --repo postmeridiem/pql --pattern 'pql_*_Linux_x86_64.tar.gz' --output /tmp/pql.tgz
|
||||
tar -xzf /tmp/pql.tgz -C /tmp
|
||||
sudo install -m 0755 /tmp/pql /usr/local/bin/pql
|
||||
pql --version
|
||||
# The pql tests query the repo's vault, but .pql/pql.db is gitignored
|
||||
# (the post-checkout hook rebuilds it from the committed changelog).
|
||||
# A fresh CI checkout has the changelog but no db — materialize it,
|
||||
# and sync decision records from the governance/ DQR markdown tree
|
||||
# (tickets come from the changelog; decisions from `decisions sync`).
|
||||
pql plan import
|
||||
pql decisions sync
|
||||
- uses: coactions/setup-xvfb@v1
|
||||
with: { run: make test-integration }
|
||||
|
||||
startup-bundle:
|
||||
name: bundle smoke (xvfb 5s)
|
||||
runs-on: ubuntu-latest
|
||||
needs: unit
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
|
||||
- run: flutter pub get
|
||||
- name: install pql
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release download --repo postmeridiem/pql --pattern 'pql_*_Linux_x86_64.tar.gz' --output /tmp/pql.tgz
|
||||
tar -xzf /tmp/pql.tgz -C /tmp
|
||||
sudo install -m 0755 /tmp/pql /usr/local/bin/pql
|
||||
pql --version
|
||||
# The pql tests query the repo's vault, but .pql/pql.db is gitignored
|
||||
# (the post-checkout hook rebuilds it from the committed changelog).
|
||||
# A fresh CI checkout has the changelog but no db — materialize it,
|
||||
# and sync decision records from the governance/ DQR markdown tree
|
||||
# (tickets come from the changelog; decisions from `decisions sync`).
|
||||
pql plan import
|
||||
pql decisions sync
|
||||
# Point the real release app's crash logs at an uploadable workspace dir
|
||||
# (T-436): if the bundle wedges on boot, the watchdog heartbeat/sample +
|
||||
# FileLogSink land here and get uploaded below. CLIDE_LOG=debug so the
|
||||
# file sink captures info/debug, not just the release-default warn.
|
||||
- name: bundle smoke (logs → artifact)
|
||||
env:
|
||||
CLIDE_LOG: debug
|
||||
CLIDE_LOG_DIR: ${{ github.workspace }}/clide-logs
|
||||
run: make smoke-bundle
|
||||
- name: Upload crash logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundle-crash-logs
|
||||
path: ${{ github.workspace }}/clide-logs
|
||||
if-no-files-found: ignore
|
||||
|
||||
web-wasm:
|
||||
name: web build (wasm compile gate)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: flutter pub get
|
||||
# Compile gate for the dart:ffi web fence (D-100 / T-438, resolving Q-50):
|
||||
# every native binding lives behind a `dart.library.ffi` conditional import
|
||||
# with a web stub. If a new one lands without its stub, this fails — the
|
||||
# fence can't silently rot. Build-only for now; the full web-WASM Playwright
|
||||
# e2e (`make test-e2e`: setup-node + playwright install in tools/ui +
|
||||
# serve) is the follow-on once the harness is wired back up.
|
||||
- run: flutter build web --wasm
|
||||
|
||||
docs:
|
||||
name: dart doc (lib API)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with: { channel: stable, cache: true }
|
||||
- run: flutter pub get
|
||||
- name: dart doc --validate-links (fail on warning)
|
||||
run: |
|
||||
set -o pipefail
|
||||
dart doc --validate-links 2>&1 | tee dartdoc.log
|
||||
if grep -q "^ warning:" dartdoc.log; then
|
||||
echo "::error::dartdoc emitted warnings — see log above"
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dart-api-docs
|
||||
path: doc/api/
|
||||
@@ -0,0 +1,115 @@
|
||||
name: windows-soak
|
||||
|
||||
# ConPTY orphan-leak soak on a GitHub-hosted Windows runner — the cheap
|
||||
# alternative to a dedicated Windows VM. The freeze hypothesis (T-424) is that
|
||||
# each WindowsPty.start() leaks its conhost/OpenConsole host because the child
|
||||
# is not in a kill-on-close Job Object; across many runs those hosts pile up
|
||||
# until the box starves. tools/windows-verify/soak-conpty.ps1 reproduces that
|
||||
# WITHOUT crashing: it runs the ConPTY suite many times IN ONE job and counts
|
||||
# the hosts that survive each dart.exe exit. A throwaway runner is fine — we
|
||||
# watch the accumulation (the leading indicator), not the reboot. The repeated
|
||||
# runs happen inside this single job, so the leak can build up here even though
|
||||
# the runner is discarded afterwards (cf. the note in windows.yml, which only
|
||||
# runs the suite once).
|
||||
#
|
||||
# Diagnostic, never a gate: it always exits 0 and just publishes the verdict +
|
||||
# CSV. Runs on demand (workflow_dispatch) and when the soak kit itself changes.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
iterations:
|
||||
description: How many times to run the ConPTY suite (clean-path soak)
|
||||
default: "25"
|
||||
kill_iterations:
|
||||
description: Spawn+force-kill cycles (abrupt-death orphan probe)
|
||||
default: "15"
|
||||
ptys_per_iter:
|
||||
description: WindowsPty sessions spawned per kill cycle
|
||||
default: "2"
|
||||
push:
|
||||
branches: [windows-support]
|
||||
paths:
|
||||
- tools/windows-verify/**
|
||||
- .github/workflows/windows-soak.yml
|
||||
|
||||
jobs:
|
||||
conpty-soak:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
- run: flutter --version
|
||||
- run: flutter pub get
|
||||
- name: ConPTY orphan-leak soak
|
||||
shell: pwsh
|
||||
run: |
|
||||
$iters = "${{ github.event.inputs.iterations }}"
|
||||
if (-not $iters) { $iters = "25" }
|
||||
tools/windows-verify/soak-conpty.ps1 -Iterations ([int]$iters) -OutDir "$env:GITHUB_WORKSPACE/soak-out"
|
||||
- name: Publish verdict to job summary
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$s = Get-ChildItem "$env:GITHUB_WORKSPACE/soak-out/*.summary.txt" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($s) { Get-Content $s.FullName | Add-Content $env:GITHUB_STEP_SUMMARY }
|
||||
- name: Upload soak CSV + summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: conpty-soak
|
||||
path: soak-out
|
||||
if-no-files-found: warn
|
||||
|
||||
conpty-kill-probe:
|
||||
# Abrupt-death half: force-kill the parent dart.exe mid-life (no close(),
|
||||
# no Job Object) and count the ConPTY hosts that survive. This is the path
|
||||
# the freeze hypothesis (T-424) actually implicates — the clean-path soak
|
||||
# above never exercises it. Diagnostic only; always succeeds.
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
- run: flutter --version
|
||||
- run: flutter pub get
|
||||
- name: ConPTY abrupt-death orphan probe
|
||||
shell: pwsh
|
||||
# CLIDE_LOG_DIR makes the probe emit FFI breadcrumbs (T-436): when a
|
||||
# parent is force-killed mid-life, its reader/waiter isolates' last
|
||||
# crumb ("ReadFile enter" / "WaitForSingleObject enter") is fsynced to
|
||||
# clide-pty.crumbs.log and uploaded below — naming what the wedged
|
||||
# isolate was doing at the instant of death.
|
||||
env:
|
||||
CLIDE_LOG_DIR: ${{ github.workspace }}/kill-crumbs
|
||||
run: |
|
||||
$iters = "${{ github.event.inputs.kill_iterations }}"
|
||||
if (-not $iters) { $iters = "15" }
|
||||
$ptys = "${{ github.event.inputs.ptys_per_iter }}"
|
||||
if (-not $ptys) { $ptys = "2" }
|
||||
tools/windows-verify/soak-conpty-kill.ps1 -Iterations ([int]$iters) -PtysPerIter ([int]$ptys) -OutDir "$env:GITHUB_WORKSPACE/kill-out"
|
||||
- name: Publish verdict to job summary
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$s = Get-ChildItem "$env:GITHUB_WORKSPACE/kill-out/*.summary.txt" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($s) { Get-Content $s.FullName | Add-Content $env:GITHUB_STEP_SUMMARY }
|
||||
- name: Upload kill-probe CSV + summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: conpty-kill-probe
|
||||
path: kill-out
|
||||
if-no-files-found: warn
|
||||
- name: Upload FFI breadcrumbs (last act of each killed reader/waiter)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: conpty-kill-crumbs
|
||||
path: ${{ github.workspace }}/kill-crumbs
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,48 @@
|
||||
name: windows
|
||||
|
||||
# Windows CI on GitHub-hosted runners — the only hosted Windows available, and
|
||||
# GitHub is clide's primary remote (the Gitea secondary is self-hosted Linux and
|
||||
# keeps running the Linux suite). This is the first real execution of the ConPTY
|
||||
# backend (lib/src/pty/windows_pty.dart), so expect genuine failures until the
|
||||
# Windows fixes land (T-424). Keep this OUT of required status checks until it's
|
||||
# reliably green — it reports + uploads artifacts without blocking merges. Each
|
||||
# run is a fresh, discarded runner, so the accumulation freeze (which needs
|
||||
# repeated runs on one machine) can't build up here.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, windows-support]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
windows-pty:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
- run: flutter --version
|
||||
- run: flutter pub get
|
||||
# No `flutter analyze` here: it's platform-agnostic — the Linux job already
|
||||
# analyzes windows_pty.dart and everything else statically, and the
|
||||
# `flutter build windows` release job catches Windows-specific compile
|
||||
# errors. Skipping it also avoids needing `make gen-build-info`, since the
|
||||
# pty tests import the pty libraries directly, not the build_info-bearing
|
||||
# barrel (lib/clide.dart). This job's unique value is running real ConPTY.
|
||||
- name: ConPTY + Windows-arg unit tests
|
||||
# windows_pty_test.dart drives real ConPTY (it self-skips off-Windows);
|
||||
# the args/size suites are the pure-logic coverage. --timeout 60s so a
|
||||
# wedged reader fails fast instead of hanging the runner.
|
||||
run: dart test --concurrency=1 --timeout 60s test/pty/windows_pty_test.dart test/pty/windows_pty_args_test.dart test/pty/pty_size_test.dart
|
||||
- name: Upload test output / logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-test-output
|
||||
# T-425 (crash-survivable FileLogSink) writes under %LOCALAPPDATA%\clide\logs;
|
||||
# add that path here once it lands so a freeze leaves a downloadable log.
|
||||
path: test/.test-output
|
||||
if-no-files-found: ignore
|
||||
@@ -58,6 +58,8 @@ tools/ui/.serve.pid
|
||||
/native/linux-x64/clide
|
||||
/native/macos-arm64/clide
|
||||
/native/macos-x64/clide
|
||||
/native/windows-x64/clide.exe
|
||||
/native/windows-x64/clide.obj
|
||||
|
||||
# -- Test, coverage, profile output ------------------------------------
|
||||
*.test
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
|
||||
revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
@@ -13,11 +13,11 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||
- platform: web
|
||||
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
- platform: windows
|
||||
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -232,3 +232,71 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'T-405', '2026-06-12 03:21:31', '2026-06-12 03:21:31', NULL, 'e4e1695f838b8fbf02aae49a6f2df4fe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'T-406', '2026-06-12 03:21:49', '2026-06-12 03:21:49', NULL, '689352238d2050a3769b2a9613f0a793', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'T-407', '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '129d2b3c31022d53025a3e28169a060e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VTK2MYQQ173MSJN6E1DM', 'T-408', '2026-06-12 06:40:25', '2026-06-12 06:40:25', NULL, '0353aaab57a40900b00883fb12e135f7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VYR84023Z5XFEX9DS0S0', 'T-409', '2026-06-12 06:40:26', '2026-06-12 06:40:26', NULL, '69c280319335a8d0ef646998f4531e96', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'T-410', '2026-06-12 08:58:29', '2026-06-12 08:58:29', NULL, '4f726d1a66d38d14018a62c8d24ffe62', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'T-411', '2026-06-12 08:58:42', '2026-06-12 08:58:42', NULL, 'bdd3f8b13caf25677ac661ec29599488', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'T-412', '2026-06-12 08:58:55', '2026-06-12 08:58:55', NULL, 'e56d50bc6fc04f6d646f22c104e63183', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3KRWM65MD3DS251NN9YX0', 'T-413', '2026-06-12 08:59:08', '2026-06-12 08:59:08', NULL, 'b5247ea05c106500682be978a47e61ee', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P8YERJ5R7ENSD675BX00', 'T-414', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, '7d973f16c99441dbba0f8df89a665b32', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'T-415', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, 'a2dea9268d44b9e8a1fd746bbb947c92', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'T-416', '2026-06-12 10:25:00', '2026-06-12 10:25:00', NULL, 'f8c2a125e661607d5dd0c73cd2c3f2ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ4BYD4STCKCY8JNKF23Q4W', 'T-417', '2026-06-12 11:22:15', '2026-06-12 11:22:15', NULL, '15aa9b25417162126cbcde174d3537da', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'T-418', '2026-06-12 11:26:14', '2026-06-12 11:26:14', NULL, '000e07ae64b08273a2d2d9f8a77d193f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBTTMGKSYMTF8M1KQWTG774W', 'T-419', '2026-06-12 19:58:58', '2026-06-12 19:58:58', NULL, 'd2b01a2c3d1ce24cc863ac6d9d814d3d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FC2XY1T85A65YY9SG25VVEY4', 'T-420', '2026-06-13 14:51:51', '2026-06-13 14:51:51', NULL, '16ea4c9353a56798b894ab3d85fb7b56', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'T-421', '2026-06-14 15:29:23', '2026-06-14 15:29:23', NULL, 'a28eed4b57104034c5216344a326195c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDKX4CVHWVGDAJC6X09602M', 'T-422', '2026-06-14 15:45:57', '2026-06-14 15:45:57', NULL, '5701c634f5737a2ba1612deab8df7049', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDM61KAA3GV3CVTE8PAZ8N0', 'T-423', '2026-06-14 15:47:10', '2026-06-14 15:47:10', NULL, 'ce7dfb8b9bd088b4c2e8ddfacc8d2124', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'T-424', '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'ecbed75dcd34c39bcbd86e60d6f4a2c2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'T-425', '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'b09fc55465f7de02cf98f69c99e0e6e3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'T-426', '2026-06-14 18:15:42', '2026-06-14 18:15:42', NULL, '91f50c6e38332047f8619db428d4b376', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'T-427', '2026-06-14 18:15:43', '2026-06-14 18:15:43', NULL, '55a937def177025ef6b61a222d88b142', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'T-428', '2026-06-14 18:15:44', '2026-06-14 18:15:44', NULL, '185e7d3ce8529fbe1f4543f4c635f200', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'T-429', '2026-06-14 18:15:45', '2026-06-14 18:15:45', NULL, 'ef268614a716384a7597b3e304a6c176', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'T-430', '2026-06-14 18:15:46', '2026-06-14 18:15:46', NULL, 'd6333df4da5b7ab8958149a9f0d17974', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCGJ30V24BJB001GZCR5QKTC', 'T-431', '2026-06-14 22:37:27', '2026-06-14 22:37:27', NULL, 'b886820e87f5abd329126bf5e9f1a3da', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'T-432', '2026-06-15 07:18:58', '2026-06-15 07:18:58', NULL, '71f3e4c95f66abc5e7e5820548201e41', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9F446MZFXVHH65Q6CKTPM', 'T-433', '2026-06-15 07:19:01', '2026-06-15 07:19:01', NULL, 'b9a361f2c29b286bc808dbea57a05a7e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'T-434', '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, '403c4c8aa5659bb379cb8add9dff800b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'T-435', '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, '7c2ed604aecea99b742b341166cf2257', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'T-437', '2026-06-15 11:12:36', '2026-06-15 11:12:36', NULL, '74dd08c8c42f556959746ee1a47561e6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-440', '2026-06-15 15:53:05', '2026-06-15 15:53:05', NULL, 'f219bb1a70eb1f49e30975ce8c526051', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'T-441', '2026-06-16 09:16:07', '2026-06-16 09:16:07', NULL, 'fbabc4fc344b34476b38a186c2f44a16', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'T-442', '2026-06-16 09:27:39', '2026-06-16 09:27:39', NULL, 'c85d8fd95cd73aec1e83b477612e1131', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCRERT7X5WMZSGKQCA6T0VB4', 'T-440', '2026-06-15 17:01:25', '2026-06-15 17:01:25', NULL, 'b8e152bf87ec8d81d1bddeb023f37883', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-443', '2026-06-15 15:53:05', '2026-06-16 10:17:57', NULL, 'a4d4390596d24d7dc28d80861e36d66b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD07ECS3GK2V7Z2WYYPJHJYC', 'T-444', '2026-06-16 11:07:54', '2026-06-16 11:07:54', NULL, 'ec7a0d8d01c984d931855a6e5c4e86ec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09C4A0HPZSP8HP44F7A894', 'T-445', '2026-06-16 11:16:20', '2026-06-16 11:16:20', NULL, '418d561d50d37c3dd9b6e3abe5fc1b1f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09DH88J1659FDDYJ83JH1M', 'T-446', '2026-06-16 11:16:31', '2026-06-16 11:16:31', NULL, '7c0bc42be1a170b5bb2dd8bd7b027d69', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09SHDMGT66C4D1DRZQZ7RR', 'T-447', '2026-06-16 11:18:10', '2026-06-16 11:18:10', NULL, '203dfb0348a94392f2d087b79aee655f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'T-448', '2026-06-16 11:18:20', '2026-06-16 11:18:20', NULL, '765a91fb5b51889861a41f7eea308ea6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'T-449', '2026-06-16 11:18:32', '2026-06-16 11:18:32', NULL, '1dbd17ff345551d3f091a226bb0f7164', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'T-450', '2026-06-16 11:18:45', '2026-06-16 11:18:45', NULL, '56f97ccefa83681c387ee9cc4ca6a477', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'T-451', '2026-06-16 11:19:11', '2026-06-16 11:19:11', NULL, 'c329f6f1a5d205b7bdf5ab682812a6d1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A4MY8DF6NB0KNV96RQADR', 'T-452', '2026-06-16 11:19:41', '2026-06-16 11:19:41', NULL, '71a7729a8cf7ebc4e915a9e2736717bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A6K9B60ADTFBZA6NBVJFM', 'T-453', '2026-06-16 11:19:57', '2026-06-16 11:19:57', NULL, 'd408d563785166ee1b40e7494c7f3bbc', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0ABC7QNEC3XCTV73YPSGR4', 'T-454', '2026-06-16 11:20:36', '2026-06-16 11:20:36', NULL, '27e2ae5b0897eb0f589f4617061101a9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'T-455', '2026-06-16 13:20:25', '2026-06-16 13:20:25', NULL, '370e913c62eab739cf39bda08089e033', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15TY946689VZK3MNABSMDW', 'T-456', '2026-06-16 13:20:41', '2026-06-16 13:20:41', NULL, '6041202d66dd93ee3d22206ece3a7db7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15W7SBVAD3QR5NVE67PKWR', 'T-457', '2026-06-16 13:20:52', '2026-06-16 13:20:52', NULL, 'c9bef415fb7d9d03444fde53ecac3bb1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'T-458', '2026-06-16 14:12:04.212', '2026-06-16 14:12:04.212', NULL, '070f1def635ee013df639cb9fe81e357', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'T-459', '2026-06-16 14:15:25.011', '2026-06-16 14:15:25.011', NULL, '44d5db70ef13ead00c4dcc437c387cec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD91A7VEW3VCY7QX1END2Z3G', 'T-460', '2026-06-17 07:39:25.019', '2026-06-17 07:39:25.019', NULL, 'e7e749d35dc413a560d3347be5f4a9ee', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD91RWDWZPHFPJ72FHHHWS08', 'T-461', '2026-06-17 07:41:24.975', '2026-06-17 07:41:24.975', NULL, '4e3fba7aaad762b7b7cd854459cf664a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0TPYSWEM10RP0Q76XAP58', 'T-462', '2026-06-17 09:57:06.422', '2026-06-17 09:57:06.422', NULL, '4be5f7eb00ca81eb07015af05ed144d3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'T-463', '2026-06-17 09:57:14.458', '2026-06-17 09:57:14.458', NULL, 'd5d02453175411c13d21361b084494bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0WCPG458GF5FXTXM9VY98', 'T-464', '2026-06-17 09:57:20.180', '2026-06-17 09:57:20.180', NULL, 'c787ef61befc6698286ebdaf613e2a41', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0X5WFME83GS0DKYG3WVJ8', 'T-465', '2026-06-17 09:57:26.627', '2026-06-17 09:57:26.627', NULL, '116b1a377950dfcef4e5b6db90141faa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'T-466', '2026-06-17 09:57:30.322', '2026-06-17 09:57:30.322', NULL, 'd25bc26eb29445fc35cf53f3d54049e5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'T-467', '2026-06-17 09:57:36.624', '2026-06-17 09:57:36.624', NULL, 'a47c675ca67b86ca3c8d4c1a8acc5b80', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'T-468', '2026-06-17 09:57:41.092', '2026-06-17 09:57:41.092', NULL, 'b988d9578b1ca079c040f07265a008b4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA10DRQE3SED10JAW2CZDCR', 'T-469', '2026-06-17 09:57:53.221', '2026-06-17 09:57:53.221', NULL, '2a72e3e647afc1b5b235ab1a97d298f8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDB1VFADV4QK1YG29T658T8C', 'T-470', '2026-06-17 12:21:23.411', '2026-06-17 12:21:23.411', NULL, '0f95e5a1faa7a7e902c765f449cceb15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDJ17C3GZWNE98NRVXP189C', 'T-471', '2026-06-17 18:11:42.049', '2026-06-17 18:11:42.049', NULL, '10a9a957d647a63ce69dc4b6b564f6aa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'T-472', '2026-06-17 18:48:11.649', '2026-06-17 18:48:11.649', NULL, '097120cff851cb2dac9f54937f4c7b17', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5GVH3FTCVDEC1QAFACY4', 'T-473', '2026-06-17 19:00:20.828', '2026-06-17 19:00:20.828', NULL, '647782edc3fbb4e7285e4f82a1fb84be', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'T-474', '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '79576f18553ed885d3a2b745becdf2f5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'multi-window', '2026-06-14 15:29:27', '2026-06-14 15:29:27', NULL, 'ce03f8eb534bc45e2c1c12e9b30a2c30', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'ipc', '2026-06-14 15:29:28', '2026-06-14 15:29:28', NULL, 'e6789b66db5c1de85daf6fc8d3474449', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'regression', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, 'd1aee45df369a9afed1ecc7bc6043255', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'claude-cli', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, '2d486f878edbd7a898ece18900e0d9d6', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'web', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, '6867c9766259a7f09e81ff515973914e', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'path', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, 'e73b3b85732a2c21be23353a0f7f1493', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'web', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, '8c93337891e74d67d2465ba2c3b901d5', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'web', '2026-06-15 15:53:35', '2026-06-15 15:53:35', NULL, '4cbf5007b12087b88b8acb36f3616bf8', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||
File diff suppressed because it is too large
Load Diff
+219
@@ -16,6 +16,225 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [2.7.0] — 2026-06-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Settings panel shell.** A new `settings.open` command (⌘`,`, plus a File
|
||||
menu and command-palette entry) opens a centered Settings modal over the
|
||||
dimmed app — the foundation of the schema-driven settings UI. It frames the
|
||||
category rail and scrolling carded panel that later work fills in; closes on
|
||||
✕, Esc, or a barrier tap. (T-445)
|
||||
|
||||
- **Schema-driven settings engine.** Subsystems register a `SettingsCategory`
|
||||
(via `SettingsCategoryContribution` → the kernel `SettingsRegistry`); the
|
||||
panel renders it as carded sections of toggle/select/text/number/file rows,
|
||||
each bound to a `SettingsStore` key with help and reset-to-default.
|
||||
Registering a category surfaces a new tab. (T-448)
|
||||
|
||||
- **Settings category rail.** The modal's left rail lists the registered
|
||||
categories (icon + title, data-driven from the registry) with an accent
|
||||
left-stripe + surfaceHi selection; picking one swaps the panel. (T-447)
|
||||
|
||||
- **Per-field scope tags.** Each field shows where its value lives — folder =
|
||||
Project (`.clide`), globe = Always (`~/.clide`), circle-dashed =
|
||||
Default/unset — with a tooltip and a menu to move the value between scopes or
|
||||
reset it. Backed by scope-explicit `SettingsStore` access. (T-449)
|
||||
|
||||
- **Cross-category settings search.** A search box atop the rail filters fields
|
||||
across every category; the panel shows the matches grouped under category
|
||||
subheaders (editable inline), and each rail row shows its match count with
|
||||
zero-match categories dimmed. (T-450)
|
||||
|
||||
- **Settings → Activity category.** The first real settings tab: the
|
||||
conversation fold level (none / tools / thinking / everything) as a schema
|
||||
field; picking a level applies live to the activity stream. (T-453)
|
||||
|
||||
- **Settings → Keymap category.** A preset select (Default / Vim / VS Code /
|
||||
JetBrains); picking one switches the active keymap live via the preset
|
||||
command. (T-451)
|
||||
|
||||
- **Settings → Appearance category.** A theme picker in the panel — base-theme
|
||||
chips + a high-contrast toggle, applied live. Adds the engine's custom-control
|
||||
escape hatch (`SettingsControlContribution` / `SettingsControlRegistry`) for
|
||||
one-off controls the generic field kinds can't express. (T-452)
|
||||
|
||||
- **Settings → Extensions tab.** A "watch this space" notice — installing and
|
||||
toggling extensions arrives with third-party (Lua) support; built-ins stay
|
||||
always-on for now. (T-456)
|
||||
|
||||
- **Settings → Claude category.** New-session defaults — model, effort, and
|
||||
permission mode — seed fresh sessions (effort via `--effort` at spawn;
|
||||
model and permission applied right after start). (T-457)
|
||||
|
||||
- **Inter is the default UI font + a UI-font picker.** Bundled Inter (variable)
|
||||
as the default interface typeface — Josefin Sans stays selectable — and
|
||||
Settings → Appearance gains a UI-font picker that applies live. (T-460)
|
||||
|
||||
- **Monospace font picker.** Settings → Appearance adds a monospace font select
|
||||
(JetBrains Mono / Fira Mono) that applies live to the terminal, diffs, code,
|
||||
and IDs. Bundles Fira Mono. (T-471)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title
|
||||
changed from "Settings…" to "Theme…" so it no longer collides with the new
|
||||
Settings panel in the palette; behaviour is unchanged. (T-445)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Monospace setting now applies everywhere.** Eleven context-free render
|
||||
helpers (Claude tool bodies + results, inline markdown code/refs, search
|
||||
previews, welcome tips) hard-coded JetBrains Mono and ignored the Monospace
|
||||
font setting; they now honour it live. (T-472)
|
||||
|
||||
## [2.6.0] — 2026-06-16
|
||||
|
||||
### Added
|
||||
|
||||
- **Vim `gt` / `gT` tab motions.** Under the Vim preset, `gt`/`gT` cycle the
|
||||
workspace tab strip (the same `workspace.tab.*` commands as `ctrl+pagedown`/
|
||||
`ctrl+pageup`), resolved by the focused editor or pane and sharing the `g`
|
||||
prefix with `gg`. Completes the T-403 cross-pane vim layer. (T-405)
|
||||
|
||||
- **Vim ex command-line (`:`).** Under the Vim preset, `:` opens a transient
|
||||
one-line overlay running a fixed table — `:w` save, `:q` close the active tab
|
||||
(the split self-collapses on the last one), `:wq`/`:x` and `ZZ` save+close,
|
||||
`:e <path>` jump to quick-open seeded with the path, `:<n>` goto-line. Unknown
|
||||
commands flash and stay open; with no active buffer it no-ops. Opens from the
|
||||
editor or a focused pane; Esc dismisses. Adds the `editor.goto-line` CLI/IPC
|
||||
verb. (T-407)
|
||||
|
||||
- **Crash-survivable logging.** clide writes a durable JSON-lines log to a
|
||||
persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`,
|
||||
Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so
|
||||
a freeze leaves on-disk evidence. `CLIDE_LOG` (dart-define / env) or the
|
||||
`app.log.level` setting sets verbosity (warn in release, info in debug);
|
||||
`CLIDE_LOG_DIR` redirects where the logs land. (T-432, T-436)
|
||||
- **PTY FFI breadcrumbs.** Each PTY backend drops a breadcrumb before/after
|
||||
every risky syscall (`CreatePseudoConsole`/`CreateProcessW`/`ReadFile`,
|
||||
`posix_spawn`/`read`); the reader/waiter isolates fsync their OWN file handle
|
||||
so a wedged isolate's last crumb survives a freeze that also froze the main
|
||||
isolate — naming the wedge after the fact. Per-syscall crumbs at debug level.
|
||||
(T-434)
|
||||
- **Crash-diagnostic watchdog.** A dedicated isolate fsyncs a heartbeat every
|
||||
~500ms (bounding a freeze to ~500ms) and every ~2s samples this process's
|
||||
thread / handle / child-host / RSS counts to `clide-watchdog.log` — a climbing
|
||||
child or thread count is the leak signature. Survives a frozen main isolate;
|
||||
spawn failure is non-fatal. (T-435)
|
||||
- **Live log-verbosity toggle.** The output dock's Level chip now sets the
|
||||
running logger's level and persists `app.log.level` (not just a view filter),
|
||||
and `clide log level [<level>]` does the same from the CLI — D-6 parity. The
|
||||
choice survives restart. (T-433)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/clear` no longer kills the Claude pane.** Clearing the primary pane tore
|
||||
the session down and respawned on the same deterministic `--session-id`
|
||||
*before the old `claude` process had actually exited*, so claude 2.1.177
|
||||
rejected the id as "already in use" and the respawn exited 1. The session
|
||||
teardown now awaits the process's real death (SIGTERM, escalating to SIGKILL)
|
||||
before clearing the transcript and respawning. A dead pane also now shows the
|
||||
CLI's own reason instead of a bare "exited (code 1)". (T-437)
|
||||
- **Web/WASM build compiles again.** `flutter build web --wasm` (and the
|
||||
`test-e2e` / `ui-dev` / `ui-smoke` harness) had been broken since the
|
||||
tree-sitter/PTY `dart:ffi` pivot. Every native binding (PTY, tree-sitter, the
|
||||
Lua host, the Windows watchdog, the ABI/fd-inheritance probes) now sits behind
|
||||
a `dart.library.ffi` conditional import with a graceful web stub, and the FNV
|
||||
hash constants are dart2js-safe. A `flutter build web --wasm` compile gate was
|
||||
added to CI so the fence can't silently rot. Desktop builds are unchanged — the
|
||||
web target degrades (no terminal, no native git, no syntax highlighting), it
|
||||
does not compromise desktop fidelity. (T-438, D-100, Q-50)
|
||||
- **Desktop-launched clide finds your installed tools.** A dock/launcher start
|
||||
inherits a sparse `PATH` (no `~/.local/bin`, brew, nvm, …), so pql/git/claude
|
||||
and terminal tools could go missing. clide now resolves the real login-shell
|
||||
`PATH` once at startup (`$SHELL -l -c`, bounded + graceful fallback) and routes
|
||||
every spawn site — PTY children, git, the toolchain probe, hosted claude —
|
||||
through one shared resolver, replacing three divergent (and partly macOS-only)
|
||||
PATH expanders. (T-439, follows T-347)
|
||||
|
||||
## [2.5.0] — 2026-06-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Experimental Windows desktop support.** clide builds and runs on Windows —
|
||||
ConPTY-backed terminals, an AF_UNIX `clide` CLI client, PowerShell as the
|
||||
default shell, and a `make build-windows` target. Preview quality: ConPTY
|
||||
child-process reaping under sustained use is still being hardened. (T-424)
|
||||
- **Vim `ctrl+w` window commands.** Under the vim preset, `ctrl+w` followed by
|
||||
h/l (focus left/right panel), j (toggle dock), w / ctrl+w (cycle panels),
|
||||
shift+w (cycle back), o (focus mode), or q/c (close editor). A new global
|
||||
multi-chord matcher in the shell resolves these from any focus; bare `ctrl+w`
|
||||
still closes the editor after the ambiguity timeout. (T-404)
|
||||
- **Workspace tab cycling with ctrl+pagedown / ctrl+pageup.** New
|
||||
`workspace.tab.next` / `workspace.tab.previous` commands cycle the workspace
|
||||
tab strip with wraparound, bound across every preset. (T-405)
|
||||
- **Vim normal-mode navigation works outside the editor.** Under the vim preset,
|
||||
a focused file tree or conversation now responds to j/k, ctrl+d/ctrl+u, gg/G,
|
||||
and (tree) h/l/o — a selection cursor in the tree, scrolling in the
|
||||
conversation. Each pane runs its own sequence matcher; an `editor.focused`
|
||||
flag keeps these keys as buffer motions while the editor holds focus. (T-406)
|
||||
- **Claude Code Workflow runs surface in the conversation and sidebar.** A
|
||||
`Workflow` tool-use renders a dedicated run card — phase groups, per-agent
|
||||
rows with live spinner/check status, usage, and the script — driven by the
|
||||
harness's out-of-band progress events. The Activity tab adds a WORKFLOWS
|
||||
section showing each run's done/total agent count. (T-416)
|
||||
- **Session controls and live usage in the Claude sidebar Activity tab.** A
|
||||
SESSION strip offers clear/compact/fork/resume buttons (same code path as the
|
||||
typed commands), and a refresh control fetches `/usage` — plan usage renders
|
||||
as a USAGE block (session and weekly percentages). The runtime row now also
|
||||
shows the session's effort level. (T-415)
|
||||
- **The Claude sidebar Config tab is a live control panel.** Model, effort, and
|
||||
permission mode are popover controls showing the running session's values;
|
||||
picking an option drives the session through the same path as the typed slash
|
||||
command. The sidebar tables also got a visual pass — larger type, accent
|
||||
section headers, more breathing room. (T-414)
|
||||
- **The TUI command family opens clide surfaces.** `/permissions` sets the mode
|
||||
directly or opens a picker; `/status`, `/config`, `/mcp`, `/agents`, `/hooks`
|
||||
jump to the matching Claude sidebar tab; `/memory` opens CLAUDE.md in the
|
||||
editor; `/help` shows clide's own command summary. (T-413)
|
||||
- **`/effort` works in the Claude pane.** With a level (`/effort xhigh`) the
|
||||
session restarts in place carrying `--effort` — resume keeps the
|
||||
conversation; bare `/effort` opens a picker with the five levels and the
|
||||
current one marked. The active effort shows in the session status. (T-412)
|
||||
- **TUI-only slash commands get a helpful notice instead of failing.** A typed
|
||||
`/cost` or `/doctor` no longer errors raw from the CLI or leaks to the model
|
||||
as literal text — known TUI-only commands route to a muted notice card with
|
||||
the clide-native way. CLI-local output (like `/usage`) renders as a "clide"
|
||||
card, never fake Claude prose. (T-411)
|
||||
- **`/model` works in the Claude pane.** With a name (`/model sonnet`) it
|
||||
switches the live session's model over the control channel; bare `/model`
|
||||
opens a picker in the interaction zone with the CLI's model list and the
|
||||
current model marked. A rejected name rolls back and raises a toast. (T-408)
|
||||
|
||||
### Removed
|
||||
|
||||
- **tmux is no longer a required tool.** clide stopped spawning tmux when Claude
|
||||
session persistence moved to `--resume` (D-77); the toolchain no longer probes
|
||||
for it or warns when it's absent, on any platform.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`ClaudeConfig` no longer crashes on a project switch that races teardown.**
|
||||
`setProjectDir` / `refresh` / `ensureProbe` now skip `notifyListeners()` if the
|
||||
config was disposed during their async load (the guard `load()` already had).
|
||||
|
||||
## [2.4.1] — 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`Shift+;` types a colon again — double-Shift no longer fires on chorded
|
||||
Shift.** The double-tap detector counted any Shift press as a tap, even
|
||||
mid-chord, and never saw keys the focused editor consumed; a tap now
|
||||
requires a clean press-and-release, observed at the raw-keyboard level.
|
||||
(T-409)
|
||||
|
||||
## [2.4.0] — 2026-06-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -9,7 +9,7 @@ An IDE for Claude Code CLI. Single Flutter package at the repo root.
|
||||
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly.
|
||||
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
|
||||
|
||||
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
|
||||
Claude session persistence is `--resume <session-id>` against Claude Code's transcript files (D-77, superseding the original tmux-backed D-41) — the app re-attaches on restart, no tmux required. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
|
||||
|
||||
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`governance/`](governance/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`governance/README.md`](governance/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
|
||||
|
||||
@@ -89,6 +89,8 @@ Shell hygiene (keeps commands inside the permission allowlist, so they don't get
|
||||
|
||||
Commit and push directly to `main` for routine work — this is a solo-dev repo and does not use a branch-first / feature-branch flow. Do **not** create a working branch just to land a change. (This overrides the generic "branch before committing on the default branch" assistant default.) The usual safety rules still hold: never `--no-verify`, never force-push `main`, and let the pre-push gate run.
|
||||
|
||||
**Never `git add -A` or `git add .` — stage explicit paths every time (`git add <file> …`), no exceptions.** This worktree can host concurrent Claude sessions: a blanket add vacuums another session's in-progress files — and your own unrelated edits — into your commit, mislabeling work and entangling history (this has happened). If `git status` shows files you didn't touch this turn, they are not yours to stage. **Always create commits through the [`git-commit` skill](.claude/skills/git-commit/SKILL.md)** — it encodes the message format (Conventional Commits, per [D-37](governance/decisions/process.md#d-37)), the explicit-staging rule, changelog discipline, and the safety reminders. Don't hand-roll a commit that skips it.
|
||||
|
||||
The pre-commit hook auto-exports and stages `.pql/changelog/` (the pql ticket DB) on every commit — don't hand-stage it. A ticket change only persists if the turn makes at least one commit; with no commit the hook never fires and a later branch switch can drop it.
|
||||
|
||||
## Changelog discipline
|
||||
|
||||
+8
-4
@@ -181,14 +181,18 @@ tickets before the diff lands. Trivial typo fixes don't need one.
|
||||
See [D-37](governance/decisions/process.md#d-37) and the bundled
|
||||
[`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short:
|
||||
|
||||
- Imperative subject ≤ 70 chars, no Conventional Commits prefix
|
||||
(this isn't a Conventional Commits repo — the archived Python
|
||||
predecessor under [`legacy/`](legacy/) is, but the rebuild isn't).
|
||||
- [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/):
|
||||
`type(scope): imperative subject`, ≤ 72 chars including the prefix.
|
||||
Types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`,
|
||||
`test`, `build`, `chore`; scope is the subsystem (`settings`,
|
||||
`vim`, `pty`…); keep a trailing `(T-NNN)` ticket ref where one
|
||||
applies — e.g. `feat(settings): category rail + navigation (T-447)`.
|
||||
- One logical change per commit. If the subject needs "and", split it.
|
||||
- Every user-visible commit adds an entry to `CHANGELOG.md` under
|
||||
`[Unreleased]` in the right subsection (Added, Changed, Deprecated,
|
||||
Removed, Fixed, Security). Keep entries to one or two short
|
||||
sentences — the 60-word cap is enforced by `ci/changelog_gate.sh`.
|
||||
sentences — the 60-word cap is enforced by the pre-push gate
|
||||
(`make changelog-gate`).
|
||||
- Co-author trailer:
|
||||
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
|
||||
any of the diff.
|
||||
|
||||
@@ -148,7 +148,7 @@ changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bulle
|
||||
ci/changelog_gate.sh
|
||||
|
||||
.PHONY: smoke-bundle
|
||||
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
|
||||
smoke-bundle: gen-build-info ## Build Linux release bundle and run it under xvfb for 5s.
|
||||
ci/smoke_bundle.sh
|
||||
|
||||
# -- web UI harness ------------------------------------------------------
|
||||
@@ -182,6 +182,10 @@ build-linux: gen-build-info ## flutter build linux (desktop bundle).
|
||||
build-macos: gen-build-info ## flutter build macos (desktop bundle).
|
||||
flutter build macos
|
||||
|
||||
.PHONY: build-windows
|
||||
build-windows: gen-build-info ## flutter build windows (desktop bundle).
|
||||
flutter build windows
|
||||
|
||||
# -- install / uninstall -----------------------------------------------------
|
||||
|
||||
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
|
||||
@@ -196,6 +200,9 @@ ifeq ($(FLUTTER_OS),linux)
|
||||
else ifeq ($(FLUTTER_OS),macos)
|
||||
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
|
||||
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli
|
||||
else ifeq ($(FLUTTER_OS),windows)
|
||||
BUNDLE_DIR := build/windows/x64/runner/Release
|
||||
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/clide-cli.exe
|
||||
endif
|
||||
|
||||
ICON_SIZES := 16 32 48 128 192 256 512
|
||||
@@ -291,7 +298,11 @@ dugite-clean: ## Remove the dugite-native directory.
|
||||
# target picks up whatever `cc` is on PATH.
|
||||
|
||||
CLIDE_CLI_SRC := native/clide-cli/clide.c
|
||||
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
|
||||
ifeq ($(FLUTTER_OS),windows)
|
||||
CLIDE_CLI_BIN := native/windows-x64/clide.exe
|
||||
else
|
||||
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
|
||||
endif
|
||||
CC ?= cc
|
||||
|
||||
.PHONY: clide-cli
|
||||
@@ -299,7 +310,11 @@ clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
|
||||
|
||||
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
|
||||
@mkdir -p $(dir $(CLIDE_CLI_BIN))
|
||||
ifeq ($(FLUTTER_OS),windows)
|
||||
ci/build_cli_windows.sh
|
||||
else
|
||||
$(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC)
|
||||
endif
|
||||
@echo "==> built $(CLIDE_CLI_BIN)"
|
||||
|
||||
.PHONY: clide-cli-clean
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document governs what clide is allowed to do at runtime, what it's allowed to depend on, and how contributors — human and agent — introduce code into the project. It is binding on all contributors. When in doubt, stop and ask.
|
||||
|
||||
Rationale for specific architectural choices referenced here and in code comments (the D-### markers) lives in `decisions/`. This document sets the rules; `decisions/` records why the rules produced the code they did in a given case. If the two ever disagree, the rule in this document wins until the document itself is changed.
|
||||
Rationale for specific architectural choices referenced here and in code comments (the D-### markers) lives in `governance/decisions/`. This document sets the rules; `governance/decisions/` records why the rules produced the code they did in a given case. If the two ever disagree, the rule in this document wins until the document itself is changed.
|
||||
|
||||
## Why this document exists
|
||||
|
||||
@@ -124,9 +124,9 @@ When removing a dependency:
|
||||
|
||||
1. **Grep the entire repository** for references to the package, its exports, and any type names it contributed. `rg '<package>|<PackageType>|<prefix_>'` across the repo. Zero hits outside git history is the goal. A single lingering import will break the build; a single lingering FFI stub or type alias will compile fine and fail at runtime.
|
||||
2. **Regenerate the lockfile** as part of the same PR. A `pubspec.yaml` with the dep removed but a `pubspec.lock` that still pins it is a partial removal, and CI or a fresh clone will happily continue installing the package.
|
||||
3. **Update `app/assets/licenses.yaml`** to drop the removed package and any transitive deps it brought in that aren't pulled by anything else. If the license manifest is auto-generated on release, verify the generation script sees the change; if it's maintained by hand, edit it in the same PR.
|
||||
3. **Update `assets/licenses.yaml`** to drop the removed package and any transitive deps it brought in that aren't pulled by anything else. If the license manifest is auto-generated on release, verify the generation script sees the change; if it's maintained by hand, edit it in the same PR.
|
||||
4. **Remove any vendored artifacts** tied to the dep — binaries, prebuilt assets, generated bindings — and delete their `BUILD.md` records. An orphaned vendored binary is worse than a removed one because it looks legitimate.
|
||||
5. **Check for architectural assumptions** that the dep was carrying. If the removed package was the thing that justified a specific data flow, build step, or platform strategy, either the replacement picks up those responsibilities or the architecture has actually changed and the relevant design decision (see `decisions/`) needs updating.
|
||||
5. **Check for architectural assumptions** that the dep was carrying. If the removed package was the thing that justified a specific data flow, build step, or platform strategy, either the replacement picks up those responsibilities or the architecture has actually changed and the relevant design decision (see `governance/decisions/`) needs updating.
|
||||
|
||||
A dependency is not removed until all five are true. "I deleted the line from pubspec.yaml" is the start of the removal, not the end.
|
||||
|
||||
@@ -188,18 +188,18 @@ When in doubt about a license, the dependency does not land until the question i
|
||||
|
||||
### Attribution requirements
|
||||
|
||||
- The license manifest at `app/assets/licenses.yaml` lists every dependency with its license, copyright notice, and upstream URL.
|
||||
- The license manifest at `assets/licenses.yaml` lists every dependency with its license, copyright notice, and upstream URL.
|
||||
- Transitive dependencies are listed, not just direct ones. If `wasm_run` pulls in `wasmtime` which pulls in `cranelift`, all three appear.
|
||||
- Apache-2.0 dependencies get their `NOTICE` file content preserved verbatim, not summarized.
|
||||
- Apache-2.0-with-LLVM-exception (e.g., Cranelift, parts of LLVM) requires the LLVM exception text specifically, not just the Apache-2.0 boilerplate.
|
||||
- Fonts and icon sets get attributed even if the license doesn't strictly require it. It's the right thing to do.
|
||||
- `app/assets/licenses.yaml` is regenerated as part of the release build, not maintained by hand. A release that ships a stale manifest is a release defect.
|
||||
- `assets/licenses.yaml` is regenerated as part of the release build, not maintained by hand. A release that ships a stale manifest is a release defect.
|
||||
|
||||
Adding a dependency means updating the license manifest in the same PR. No exceptions.
|
||||
|
||||
## Changelog and commit conventions
|
||||
|
||||
clide follows [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) for `CHANGELOG.md` and [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages. Enforcement is handled by the project's git skill; this section exists so human contributors know the standard before their first PR, and so the connection between these conventions and the rest of the policy is explicit.
|
||||
clide follows [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) for `CHANGELOG.md` and [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages (see [D-37](governance/decisions/process.md#d-37)). Enforcement is handled by the project's git skill; this section exists so human contributors know the standard before their first PR, and so the connection between these conventions and the rest of the policy is explicit.
|
||||
|
||||
Security-relevant changes — CVE responses, dependency-driven vulnerability fixes, the removal of a phoning-home transitive dep, anything where the rules in this document were the reason for the change — go under the `Security` heading of the release's changelog entry, regardless of whether the code change itself looks security-shaped. That heading is the trail future-us follows to reconstruct why a dep was bumped or removed. Lumping security fixes under `Fixed` because the diff looks like a normal bug fix loses that signal and is the wrong choice even when it's technically accurate.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-po
|
||||
|
||||
## Architecture
|
||||
|
||||
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. tmux owns Claude session persistence (D-41).
|
||||
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. Claude session persistence is `--resume <session-id>` against Claude Code's transcript files (D-77, superseding the tmux-backed D-41).
|
||||
|
||||
- **`lib/`** — all Dart code. Core subsystems (`lib/src/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), the extension framework (`lib/extension/`).
|
||||
- **PTY** — `lib/src/pty/` spawns child processes via Dart FFI `posix_openpt()` + `posix_spawn()` directly; no external helper binary.
|
||||
@@ -15,7 +15,7 @@ Claude drives the UI through a `clide` CLI surface (Bash, not MCP). Every CLI su
|
||||
|
||||
## Built-in extensions
|
||||
|
||||
canvas, claude, claude_control, decisions, diff, editor, extensions_ui, files, git, graph, grammars_core, ipc_status, keybindings_ui, markdown, pql, problems, settings_ui, terminal, theme_picker, tickets, todos, welcome.
|
||||
canvas, claude, claude_control, cli_install, decisions, deeplink, default_layout, diff, editor, extensions_ui, files, git, grammars_core, graph, ipc_status, keybindings_ui, markdown, menubar, output, pql, problems, search, settings_ui, terminal, theme_picker, tickets, todos, view, vim, welcome.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -44,7 +44,7 @@ make push-check # pre-push gate: decisions + core + fast + a11y + coverage
|
||||
|
||||
## Status
|
||||
|
||||
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
||||
Active development; the interaction model, panel system, and settings engine have landed. The Python Textual predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright (c) 2012-2013, The Mozilla Corporation and Telefonica S.A.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -50,6 +50,11 @@ bindings:
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: quickOpen.open
|
||||
# Ex command-line overlay (T-407): Esc dismisses it back to normal mode (the
|
||||
# overlay's own EditableText handles Enter via onSubmitted).
|
||||
- intent: dismiss
|
||||
keys: escape
|
||||
when: exline.open
|
||||
- intent: findInFiles.open
|
||||
keys: [ctrl+shift+f, meta+shift+f]
|
||||
- intent: focus.nextPanel
|
||||
@@ -63,6 +68,96 @@ bindings:
|
||||
- intent: text.scaleReset
|
||||
keys: [ctrl+0, meta+0]
|
||||
|
||||
# ---- Pane navigation (non-editor panes) ------------------------------
|
||||
# When a non-editor pane holds focus (file tree, conversation, lists), the
|
||||
# same motion keys mean NAVIGATION, not buffer edits (T-406). The
|
||||
# `!editor.focused` guard keeps these out of the editor's way; the editor
|
||||
# publishes `editor.focused` while it has focus. These MUST precede the
|
||||
# editor motions below — the resolver takes the first matching binding in
|
||||
# file order, so with a pane focused (editor.focused false) nav wins, and
|
||||
# with the editor focused the `!editor.focused` clause fails and the buffer
|
||||
# motion below wins. Each pane runs its own SequenceMatcher (PaneKeyNav).
|
||||
- intent: nav.down
|
||||
keys: j
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.up
|
||||
keys: k
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.pageDown
|
||||
keys: ctrl+d
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.pageUp
|
||||
keys: ctrl+u
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.top
|
||||
keys: "g g" # gg
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.bottom
|
||||
keys: shift+g # G
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.expandOrRight
|
||||
keys: l
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.collapseOrLeft
|
||||
keys: h
|
||||
when: "vim.normal && !editor.focused"
|
||||
- intent: nav.activate
|
||||
keys: [o, enter]
|
||||
when: "vim.normal && !editor.focused"
|
||||
|
||||
# ---- ctrl+w window-command family (T-404) ----------------------------
|
||||
# Multi-chord sequences resolved by the GLOBAL matcher (root_shell), so they
|
||||
# work from any focus. Bare ctrl+w still closes the editor after the ambiguity
|
||||
# timeout (the editor.close binding below / contributions layer). The 3-column
|
||||
# clide layout approximates vim's window grid: h/l focus left/right panels,
|
||||
# j toggles the dock, o is "only" (focus mode), q/c close the editor.
|
||||
- intent: command:panel.focus.left
|
||||
keys: ctrl+w h
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:panel.focus.right
|
||||
keys: ctrl+w l
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:dock.toggle
|
||||
keys: ctrl+w j
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: focus.nextPanel
|
||||
keys: [ctrl+w w, ctrl+w ctrl+w]
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: focus.previousPanel
|
||||
keys: ctrl+w shift+w # ctrl+w W
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:panel.focusMode
|
||||
keys: ctrl+w o
|
||||
when: "vim.normal || vim.visual"
|
||||
- intent: command:editor.close
|
||||
keys: [ctrl+w q, ctrl+w c]
|
||||
when: "vim.normal || vim.visual"
|
||||
|
||||
# ---- Tab motions (T-405) ---------------------------------------------
|
||||
# gt / gT cycle the workspace tab strip via the preset-neutral
|
||||
# workspace.tab.* commands (also on ctrl+pagedown/up everywhere). Bare-`g`
|
||||
# sequences are editor/pane-local — the focused editor's matcher or a pane's
|
||||
# PaneKeyNav resolves them and runs the command; they share the `g` prefix
|
||||
# with `g g` (docStart / nav.top), distinguished by the final chord.
|
||||
- intent: command:workspace.tab.next
|
||||
keys: g t # gt
|
||||
when: vim.normal
|
||||
- intent: command:workspace.tab.previous
|
||||
keys: g shift+t # gT
|
||||
when: vim.normal
|
||||
|
||||
# ---- Ex command-line (T-407) -----------------------------------------
|
||||
# `:` opens the transient ex overlay (`:w` `:q` `:wq` `:x` `:e <path>` `:N`);
|
||||
# `ZZ` runs `:wq` directly. Both are typed app intents the editor's command
|
||||
# matcher and a pane's nav matcher bubble to the app-root Actions, so they
|
||||
# fire from any focus. Editor-targeted: no-op when no buffer is active.
|
||||
- intent: exline.open
|
||||
keys: shift+semicolon # :
|
||||
when: vim.normal
|
||||
- intent: exline.writeQuit
|
||||
keys: shift+z shift+z # ZZ
|
||||
when: vim.normal
|
||||
|
||||
# ---- Mode transitions ------------------------------------------------
|
||||
- intent: command:vim.mode.visual
|
||||
keys: v
|
||||
|
||||
+26
-4
@@ -39,7 +39,7 @@ self:
|
||||
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||
# pubspec instead.
|
||||
version: "2.4.0"
|
||||
version: "2.7.0"
|
||||
homepage: https://github.com/postmeridiem/clide
|
||||
license: MIT
|
||||
license_file: assets/LICENSE
|
||||
@@ -54,10 +54,32 @@ dependencies:
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/jetbrains_mono/OFL.txt
|
||||
purpose: >-
|
||||
Monospace face for terminal panes, diff views, code editors, and
|
||||
Default monospace face for terminal panes, diff views, code editors, and
|
||||
any other monospace surface.
|
||||
weights_bundled: [Regular, Italic, Bold, BoldItalic]
|
||||
|
||||
- name: Fira Mono
|
||||
kind: font
|
||||
version: "3.206"
|
||||
homepage: https://github.com/mozilla/Fira
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/fira_mono/OFL.txt
|
||||
purpose: >-
|
||||
Selectable monospace face (Settings → Appearance, T-471); JetBrains Mono
|
||||
remains the default.
|
||||
weights_bundled: [Regular, Bold]
|
||||
|
||||
- name: Inter
|
||||
kind: font
|
||||
version: "variable"
|
||||
homepage: https://github.com/rsms/inter
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/inter/OFL.txt
|
||||
purpose: >-
|
||||
Default application UI face (T-460). Variable font with optical-size
|
||||
and weight axes; Josefin Sans remains bundled as a selectable option.
|
||||
weights_bundled: [VariableFont, Italic-VariableFont]
|
||||
|
||||
- name: Josefin Sans
|
||||
kind: font
|
||||
version: "variable"
|
||||
@@ -65,8 +87,8 @@ dependencies:
|
||||
license: OFL-1.1
|
||||
license_file: assets/fonts/josefin_sans/OFL.txt
|
||||
purpose: >-
|
||||
Application UI face. Default weight Light (300); full 100-700
|
||||
range available via the variable-font weight axis.
|
||||
Selectable application UI face (was the default before T-460). Default
|
||||
weight Light (300); full 100-700 range via the variable-font weight axis.
|
||||
weights_bundled: [VariableFont, Italic-VariableFont]
|
||||
|
||||
- name: Phosphor Icons
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Build the C `clide` client with MSVC on Windows (Git Bash / MSYS).
|
||||
# Wrapped by `make clide-cli` — don't run directly (see CLAUDE.md
|
||||
# tooling discipline). Finds the VC++ toolset via vswhere, loads the
|
||||
# x64 dev environment, compiles:
|
||||
# native/clide-cli/clide.c -> native/windows-x64/clide.exe
|
||||
# ws2_32.lib supplies winsock (AF_UNIX socket support).
|
||||
set -e
|
||||
|
||||
VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
|
||||
if [ ! -x "$VSWHERE" ]; then
|
||||
echo "vswhere.exe not found — install Visual Studio (Build Tools) with the C++ workload" >&2
|
||||
exit 1
|
||||
fi
|
||||
VSROOT=$("$VSWHERE" -products '*' -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | tr -d '\r')
|
||||
if [ -z "$VSROOT" ]; then
|
||||
echo "no Visual Studio C++ x64 toolset found (vswhere returned nothing)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p native/windows-x64
|
||||
# A generated .bat sidesteps the unwinnable sh->cmd quote escaping for
|
||||
# the space-laden VS path. //c keeps MSYS from path-mangling cmd's /c
|
||||
# switch; /Fo drops the .obj next to the .exe so the repo root stays
|
||||
# clean.
|
||||
BAT=$(mktemp --suffix=.bat)
|
||||
trap 'rm -f "$BAT"' EXIT
|
||||
cat > "$BAT" <<EOF
|
||||
@call "$VSROOT\\Common7\\Tools\\VsDevCmd.bat" -arch=amd64 -no_logo
|
||||
@cl /nologo /O2 /W4 /D_CRT_SECURE_NO_WARNINGS native\\clide-cli\\clide.c /Fonative\\windows-x64\\ /Fe:native\\windows-x64\\clide.exe ws2_32.lib
|
||||
EOF
|
||||
cmd.exe //c "$(cygpath -w "$BAT")"
|
||||
rm -f native/windows-x64/clide.obj
|
||||
+6
-1
@@ -35,7 +35,12 @@ echo "==> dart test (pty — unreliable under the flutter test runner; serial)"
|
||||
# --concurrency=1: these spawn real PTYs and compete for fds when run in
|
||||
# parallel, which flaked them (registry/session). Serialize — the proper fix
|
||||
# for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193)
|
||||
dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart test/panes/registry_test.dart
|
||||
# windows_pty_test is the ConPTY sibling of session_test; each suite
|
||||
# self-skips off-platform, so the union always contributes tests.
|
||||
# --timeout 60s matches the flutter lines below: a wedged PTY test (e.g. a
|
||||
# ConPTY reader blocked forever in ReadFile) fails fast instead of hanging the
|
||||
# whole serial run.
|
||||
dart test -r "$REPORTER" --concurrency=1 --timeout 60s --tags pty test/pty/session_test.dart test/panes/registry_test.dart test/pty/windows_pty_test.dart
|
||||
|
||||
# The parallel pool excludes both pty (runs under dart test, above) and
|
||||
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
# start" regression gate. Flutter integration tests prefer one file at
|
||||
# a time on desktop; we iterate to avoid the "Unable to start the app"
|
||||
# error that hits when they run as a batch.
|
||||
#
|
||||
# -d linux pins the desktop device explicitly: the GitHub ubuntu-latest
|
||||
# runner exposes BOTH a linux desktop AND a chrome web device, so a bare
|
||||
# `flutter test integration_test/...` aborts with "More than one device
|
||||
# connected" before it ever compiles (the dev box / old Gitea runner only
|
||||
# had the one device, so this was latent until CI moved to GitHub).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
for f in integration_test/*_test.dart; do
|
||||
echo "==> integration_test: $f"
|
||||
flutter test "$f"
|
||||
flutter test -d linux "$f"
|
||||
done
|
||||
|
||||
-776
@@ -1,776 +0,0 @@
|
||||
# clide — External Consultant Review
|
||||
|
||||
**Date:** 2026-05-14
|
||||
**Scope:** Full-repository assessment of clide at `main` (commit `9030e56`).
|
||||
**Method:** Six independent specialist reviewers, each given read-only access and a
|
||||
brief covering best practice, clean code, architecture, usability, stability,
|
||||
expandability, style, consistency, and general quality. Reviewers did not see each
|
||||
other's findings; cross-cutting themes below are genuine independent agreement.
|
||||
|
||||
**Panel:**
|
||||
| Lens | Reviewer |
|
||||
|---|---|
|
||||
| Architecture | Software Architect |
|
||||
| Tests & quality gates | Test / QA Analyst |
|
||||
| UX & accessibility | UX & Accessibility Expert |
|
||||
| Code quality & craft | Senior Dart/Flutter Engineer |
|
||||
| Security & supply chain | Security Engineer |
|
||||
| Docs, governance & DX | TPM / Developer-Experience Consultant |
|
||||
|
||||
---
|
||||
|
||||
## Overall verdict
|
||||
|
||||
clide is, for a solo-dev pre-v2.0 project, **unusually disciplined** — every reviewer
|
||||
said so independently. The governance system is alive, the core subsystems are small
|
||||
and well-typed, the FFI/PTY layer shows real systems-programming care, and the quality
|
||||
gates are genuine rather than ornamental. The codebase is in good shape.
|
||||
|
||||
The weaknesses cluster into a handful of themes, and several are **load-bearing**: a
|
||||
central guardrail (CLI-first IPC) has no runtime implementation, keyboard operability —
|
||||
the core requirement of a power-user dev tool — is largely unbuilt, an untrusted
|
||||
workspace can achieve code execution, and the two primary onboarding documents describe
|
||||
an architecture that no longer exists.
|
||||
|
||||
None of these are fatal; all are fixable; most have quick-win first steps. But they
|
||||
should be addressed before a public v2.0.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting themes (independent agreement)
|
||||
|
||||
These were each flagged by **two or more** reviewers who did not coordinate:
|
||||
|
||||
1. **The IPC layer is mid-migration and contradicts itself.** The Architect found no
|
||||
Unix-socket *server* anywhere in `lib/` — D-56's "app hosts an in-process IPC server"
|
||||
and D-1's "CLI-first, not MCP" have no runtime path; three IPC clients
|
||||
(`DaemonClient`, `InProcessClient`, `IsolateClient`) coexist with two duplicated
|
||||
service-wiring sites. The Security reviewer independently noted `DaemonClient`'s
|
||||
socket code is still live as an unvalidated attack surface. **Pick one IPC model,
|
||||
implement or amend D-56, delete the other two.**
|
||||
|
||||
2. **The "no pre-existing excuse / clean board" guardrail is being violated right now.**
|
||||
`flutter analyze` reports 9 `unnecessary_import` issues in `test/`; `ci/test.sh` runs
|
||||
analyze with `--no-fatal-infos`, which silently tolerates them. Flagged by the
|
||||
Architect, Code Quality, and Test reviewers. The repo's own rules say fix-first.
|
||||
|
||||
3. **`lib/src/terminal/` is in an undeclared middle state.** ~7k LOC forked from
|
||||
xterm.dart, carrying commented-out `print`s, dangling TODOs, a 1137-line `parser.dart`,
|
||||
and the only `invalid_use_of_protected_member` suppression in the repo. MEMORY says
|
||||
"code under `lib/` is owned, not vendored" — so it must either be formally vendored
|
||||
(frozen, documented, decision-recorded) or cleaned to the project bar. Flagged by
|
||||
Code Quality; the Architect's "consistency" deduction points at the same seam.
|
||||
|
||||
4. **Documentation describes a dissolved architecture.** `README.md` and
|
||||
`docs/initial-plan.md` still describe a Go sidecar, `ptyc/` C helper, `app/`
|
||||
subdirectory, and a separate `clide --daemon` process — all removed by D-5, D-56, and
|
||||
the FFI pivot. A new contributor's first read builds a wrong mental model.
|
||||
|
||||
---
|
||||
|
||||
## Consolidated scorecard
|
||||
|
||||
Scores are each reviewer's, 1–5, on their own dimensions.
|
||||
|
||||
| Domain | Dimension | Score |
|
||||
|---|---|---|
|
||||
| **Architecture** | Layering & dependency direction | 4 |
|
||||
| | Separation of concerns | 4 |
|
||||
| | Expandability | 5 |
|
||||
| | Consistency | 4 |
|
||||
| | Guardrail adherence | 3 |
|
||||
| **Tests** | Coverage quality | 4 |
|
||||
| | Test reliability / flakiness | 3 |
|
||||
| | Gate trustworthiness | 3 |
|
||||
| | Test maintainability | 5 |
|
||||
| | Regression-catching power | 4 |
|
||||
| **UX / a11y** | Interaction model | 2 |
|
||||
| | Accessibility | 3 |
|
||||
| | Visual consistency | 4 |
|
||||
| | Discoverability | 2 |
|
||||
| | State coverage (loading/error/empty) | 3 |
|
||||
| **Code quality** | Idiomatic Dart | 4 |
|
||||
| | Error handling | 4 |
|
||||
| | Naming & readability | 4 |
|
||||
| | Consistency across subsystems | 3 |
|
||||
| | Resource / lifecycle safety | 4 |
|
||||
| **Security** | Subprocess safety | 2 |
|
||||
| | IPC input validation | 3 |
|
||||
| | Path / filesystem safety | 3 |
|
||||
| | Dependency / supply-chain hygiene | 3 |
|
||||
| | Secrets & sandboxing | 3 |
|
||||
| **Docs / governance** | Governance discipline | 4 |
|
||||
| | Documentation accuracy | 2 |
|
||||
| | Changelog hygiene | 3 |
|
||||
| | Contributor onboarding | 2 |
|
||||
| | Convention adherence | 4 |
|
||||
|
||||
**Highest marks:** expandability (5), test maintainability (5). The extension contract
|
||||
and test-helper design are genuine standouts.
|
||||
**Lowest marks:** interaction model (2), discoverability (2), subprocess safety (2),
|
||||
documentation accuracy (2), contributor onboarding (2).
|
||||
|
||||
---
|
||||
|
||||
## Prioritized action list
|
||||
|
||||
Synthesized across all six reviews. Severity is the highest any reviewer assigned.
|
||||
|
||||
### Critical — address before public v2.0
|
||||
|
||||
1. **Fix untrusted-workspace code execution.** `toolchain_paths.dart:79` resolves
|
||||
`native/dugite/bin/git` relative to the *workspace root*; a malicious repo can plant
|
||||
an executable there that clide runs on the first auto-fired `git.status`. Resolve
|
||||
`native/dugite` against `Platform.resolvedExecutable`'s directory, never the
|
||||
workspace. *(Security)*
|
||||
2. **Resolve the IPC story.** Either implement the in-process Unix-socket server per
|
||||
D-56 so the `clide` CLI / C client actually works, or amend D-56 to make in-process
|
||||
direct dispatch the design and delete `DaemonClient`'s socket code, `IsolateClient`,
|
||||
`Backend`, and `backend_entry.dart`. Today the code claims three models and runs one,
|
||||
and a load-bearing guardrail (D-1/D-6) is unmet. *(Architecture, Security)*
|
||||
3. **Make the tool keyboard-operable.** `ClideTappable` (base of nearly every
|
||||
interactive widget) is mouse-only — no `Focus`, no Enter/Space. The command palette
|
||||
has no arrow-key navigation and no Escape. For a keyboard-first dev tool this is a
|
||||
functional gap, not a polish item. *(UX)*
|
||||
4. **Fix the onboarding docs.** Rewrite `README.md`'s `ptyc/` / `make ptyc-build`
|
||||
sections, fix its dead `decisions/` link, and banner `docs/initial-plan.md` as
|
||||
historical (or split out a current `docs/architecture.md`). *(Docs)*
|
||||
|
||||
### Major — should land soon
|
||||
|
||||
5. Add symlink re-resolution + containment re-check in `files.read` / `files.ls` — a
|
||||
repo symlink `config -> /etc/shadow` currently passes path-safety. *(Security)*
|
||||
6. Add `test-integration` (and ideally `smoke-bundle`) to `make push-check` — the gate
|
||||
that catches "app won't boot" is currently omitted from the pre-push gate. *(Tests)*
|
||||
7. Schema-validate the IPC argument surface; reject `-`-prefixed `branch`/`remote`/`path`
|
||||
values; add size/count bounds. *(Security)*
|
||||
8. Establish a real focus-traversal model (`FocusTraversalGroup` per slot, a documented
|
||||
"focus next panel" keybinding) and integrate `FocusTracker` with Flutter's focus
|
||||
system instead of paralleling it. *(UX)*
|
||||
9. Fix the `SchedulerService._startTicker` isolate-spawn race — a `_stopTicker()` before
|
||||
the spawn future resolves leaks a forever-ticking isolate. Mirror `NativePty`'s
|
||||
`_readerReady` pattern. *(Code quality)*
|
||||
10. Decide the status of `lib/src/terminal/` — formally vendor (and decision-record) it,
|
||||
or do the cleanup sweep. *(Code quality)*
|
||||
11. Replace fixed wall-clock `Future.delayed` sleeps in `watcher_test.dart` /
|
||||
`session_test.dart` with event-driven waits; make swallowed `onTimeout` callbacks
|
||||
`fail()` loudly. *(Tests)*
|
||||
12. Write a human-facing `CONTRIBUTING.md`; cut an interim release to drain the ~80-commit
|
||||
`[Unreleased]` backlog; merge duplicate changelog subsection headings. *(Docs)*
|
||||
13. Single global `KeyboardListener` → scoped `Shortcuts`/`Actions`; move
|
||||
`KeybindingResolver` off layout-dependent `keyLabel`. *(UX)*
|
||||
|
||||
### Quick wins — hours each
|
||||
|
||||
- Clear the 9 `unnecessary_import` analyzer issues; drop `--no-fatal-infos` from
|
||||
`ci/test.sh`. *(Architecture, Tests, Code quality)*
|
||||
- Run the `forkpty` PTY tests with `--coverage` so `native_pty.dart` — the riskiest file
|
||||
— is honestly measured. *(Tests)*
|
||||
- Add a `Focus` + Enter/Space wrapper and a focus-ring inside `ClideTappable`; this fixes
|
||||
the keyboard gap for every button and list item at once. *(UX)*
|
||||
- Add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
||||
`_ProjectSwitcherDropdown` `onKeyEvent` pattern). *(UX)*
|
||||
- Amend D-66 to reflect the coverage floor's real location (`pubspec.yaml`), mechanism,
|
||||
and value (90%) — it currently disagrees with the changelog and the code. *(Docs)*
|
||||
- Reconcile `licenses.yaml` with `pubspec.yaml` (`test` version drift, phantom `lints`
|
||||
entry); add a `native/SHA256SUMS` manifest. *(Security, Docs)*
|
||||
- Replace silent `catch (_)` in `tree_sitter_ffi.dart` with a logged last-error.
|
||||
*(Code quality)*
|
||||
- Fix the `clide.dart` barrel leak in `file_tree_view.dart:8`; narrow the barrel (drop
|
||||
the `dispatcher.dart` export); move `test_app.dart` out of the production `main.dart`
|
||||
import graph. *(Architecture)*
|
||||
- Expand the contrast gate's `canonicalPairs` to cover `globalTextMuted`, the `status*`
|
||||
colors, and `panelActiveBorder`. *(UX)*
|
||||
- Triage stale governance Q-records (Q-1/2/3/25 overtaken by shipped Tier-1 work).
|
||||
*(Docs)*
|
||||
|
||||
---
|
||||
|
||||
# Full reviews
|
||||
|
||||
## 1. Architecture — Software Architect
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide is an unusually disciplined solo-dev codebase. The governance system (67
|
||||
D-records, tracked Q/R) is real and largely honored in code, the kernel/extension split
|
||||
is coherent, and the feature-first layout with barrel files is consistently applied. The
|
||||
single biggest strength is the **extension contract**: every built-in — including layout
|
||||
itself — passes the same `ClideExtension` + `ContributionPoint` contract, which is the
|
||||
best possible proof the contract is usable. The single biggest risk is **architectural
|
||||
drift in the IPC layer**: D-56 mandates the Flutter app host an in-process IPC server
|
||||
reachable by a thin C client over a unix socket, but no socket server exists anywhere in
|
||||
`lib/` — the "CLI-first, not MCP" guardrail (D-1) has no runtime path today. Compounding
|
||||
this, three parallel IPC client implementations (`DaemonClient` socket,
|
||||
`InProcessClient`, `IsolateClient` + `Backend`) coexist with two competing
|
||||
service-wiring sites (`main.dart` and `backend_entry.dart`), suggesting an unfinished
|
||||
migration.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Extension contract is clean and scales** — `lib/extension/src/extension.dart` +
|
||||
`contribution.dart`: sealed `ContributionPoint` hierarchy, `ClideExtensionContext`
|
||||
lists services explicitly (deliberately avoiding a `KernelServices` import cycle —
|
||||
`extension.dart:50-52`). `ExtensionManager` does dependency topo-sort,
|
||||
dependency-gated activation, and contribution apply/remove symmetrically
|
||||
(`extensions_manager.dart:164-202`). Adding a pane = new extension file + one
|
||||
`register()` line in `main.dart`.
|
||||
- **Kernel admission rule is enforced, not aspirational** — D-12's "mandatory shared
|
||||
singleton" test visibly shaped `KernelServices` (`facade.dart:38-93`); ~25 services,
|
||||
each defensibly cross-cutting. The two-tier disable model (D-14) is honored:
|
||||
`default_layout` is itself an extension.
|
||||
- **Feature-first layout with barrel discipline** (D-8) is consistent — every
|
||||
`builtin/<name>/` and `kernel/` has a barrel; builtins import
|
||||
`package:clide/kernel/kernel.dart`, not deep paths. Only one leak found.
|
||||
- **Governance-to-code traceability is genuine** — `WidgetsApp` root (D-7) at
|
||||
`app.dart:38`, `ChangeNotifier`/`ListenableBuilder` state (D-10) everywhere, git
|
||||
hardcoded in toolchain/project loader (D-13), terminal correctly tagged
|
||||
`inlined-source` in `licenses.yaml` with modifications documented.
|
||||
- **Git subsystem cohesion** — `lib/src/git/` cleanly split into `client` / `status` /
|
||||
`diff` / `operations` (~250 lines each), each a single responsibility.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] No IPC socket server exists** — D-56 specifies the app hosts an
|
||||
in-process IPC server with a C client connecting over a unix socket. `grep` for
|
||||
`ServerSocket`/unix-domain `bind` in `lib/` returns nothing. `DaemonClient._connect`
|
||||
(`client.dart:72-94`) *connects* to a socket, but nothing *serves* one. Today the only
|
||||
working path is `InProcessClient` (`in_process.dart`), which calls the dispatcher
|
||||
directly in-process. **Claude cannot drive clide via `clide ...` — the CLI-first
|
||||
guardrail (D-1, D-6) has no implementation.** This is the load-bearing contract of the
|
||||
whole project and it is absent.
|
||||
- **[Major] Three IPC clients + two wiring sites = unfinished migration** —
|
||||
`DaemonClient` (socket), `InProcessClient`, and `IsolateClient`+`Backend`/
|
||||
`backend_entry.dart` all coexist. `main.dart:76-113` wires subsystems via
|
||||
`buildDispatcher`; `backend_entry.dart:40-110` wires the *same* five subsystems again
|
||||
inside an isolate. `Backend.spawn` is referenced only by `facade.dart` but `main.dart`
|
||||
uses `autoStartDaemonClient: false` + `daemonClientFactory` (the in-process path).
|
||||
Dead-or-dormant isolate infrastructure with duplicated registration logic — pick one
|
||||
and delete the others.
|
||||
- **[Major] `main.dart` (production entry) imports `test_app.dart`** — `main.dart:2` and
|
||||
`:51-55`. The production binary carries the test harness and branches on
|
||||
`CLIDE_TESTMODE`. Test scaffolding should not be reachable from the shipping entry
|
||||
point; gate it behind a separate entrypoint or `kDebugMode`.
|
||||
- **[Minor] `flutter analyze` reports 9 issues** — all `unnecessary_import` in `test/`,
|
||||
but CLAUDE.md's "no pre-existing excuse" / "clean board" guardrail makes this a
|
||||
fix-first item.
|
||||
- **[Minor] Barrel leak** — `lib/builtin/files/src/file_tree_view.dart:8` imports
|
||||
`package:clide/src/files/listing.dart` directly instead of via
|
||||
`package:clide/clide.dart` (which already re-exports `FileEntry`).
|
||||
- **[Minor] `clide.dart` barrel exports the daemon dispatcher** — `clide.dart:15`
|
||||
exports `src/daemon/dispatcher.dart`. The barrel is described as "shared types"; the
|
||||
dispatcher is server-side machinery.
|
||||
- **[Minor] `ExtensionManager.activate` swallows exceptions** (`extensions_manager.dart:
|
||||
141-143`) — a failed `activate()` logs and continues, leaving the extension
|
||||
un-activated but `_known`, with no surfaced "degraded" state for the UI.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** clear the 9 analyzer issues; fix the `file_tree_view.dart` barrel leak
|
||||
(consider a CI grep gate for `package:clide/src/` imports outside their feature); move
|
||||
`test_app.dart` out of the production import graph; drop the `dispatcher.dart` export
|
||||
from `clide.dart`.
|
||||
|
||||
**Larger efforts:** resolve the IPC story (implement the socket server per D-56, or
|
||||
amend D-56 and delete `DaemonClient`/`IsolateClient`/`Backend`/`backend_entry.dart`);
|
||||
collapse subsystem wiring into one `registerAllSubsystems(...)` function; give
|
||||
`ExtensionManager` a surfaced failure state so the UI can show degraded built-ins.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Layering & dependency direction | 4/5 | Kernel→extension direction clean, context-vs-aggregate split avoids cycles; docked for the `src/`↔`kernel/src/` barrel leak and the dispatcher export. |
|
||||
| Separation of concerns | 4/5 | Feature-first layout, single-responsibility subsystems; duplicated subsystem registration is the blemish. |
|
||||
| Expandability | 5/5 | New pane = one extension file + one `register()` line; sealed contribution hierarchy; layout itself is data and extension-shaped. |
|
||||
| Consistency | 4/5 | Barrels, naming, D-record back-references uniform; three coexisting IPC clients and 9 analyzer issues break the bar. |
|
||||
| Guardrail adherence | 3/5 | `WidgetsApp`, single-process, no-Material, governance, zero-deps all honored — but D-1/D-6/D-56 (CLI-first via socket server) have no runtime implementation. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Tests & quality gates — Test / QA Analyst
|
||||
|
||||
### Executive summary
|
||||
|
||||
The clide test suite is, for a solo-dev pre-2.0 project, in genuinely good shape. ~104
|
||||
test files against 276 lib files, ~92.75% line coverage, and — critically — the coverage
|
||||
was *not* bought with assertion-free filler. Even the alarmingly-named files
|
||||
(`coverage_trivials_test.dart`, `zero_coverage_widgets_test.dart`,
|
||||
`services_stubs_test.dart`, `mop_up_test.dart`) contain real behavioral assertions. The
|
||||
biggest strength is a sensibly layered pyramid with a real boot-path integration gate
|
||||
and a startup smoke test that catches the "tests pass but app won't launch" class. The
|
||||
biggest risk is **flakiness from wall-clock-dependent tests** — fixed `Future.delayed`
|
||||
sleeps in file-watcher and PTY tests will eventually produce intermittent CI failures,
|
||||
and the PTY tests are run via `dart test` so they are **excluded from the coverage
|
||||
measurement entirely**.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Test pyramid is sound.** Pure-Dart unit, widget tests with a shared harness, golden
|
||||
tests (Alchemist), an a11y contract layer, and 3 real-boot `integration_test/` files —
|
||||
correctly separated by runner (`ci/test.sh` vs `ci/test_core.sh` vs
|
||||
`ci/test_integration.sh`).
|
||||
- **Helpers are well-designed.** `test/helpers/kernel_fixture.dart` boots a real
|
||||
`KernelServices` with in-memory themes/i18n and `autoStartDaemonClient: false` — no
|
||||
real socket, temp-dir scoped, proper `dispose()`. `FakeDaemonClient` is a clean stub.
|
||||
- **Error-branch discipline.** `pql_commands_errors_test.dart` /
|
||||
`git_commands_errors_test.dart` deliberately point the toolchain at a non-existent
|
||||
binary to drive catch-branches the happy path can't reach — table-driven, with
|
||||
`reason:` tags.
|
||||
- **OS-dialog avoidance is handled correctly.** `welcome/dialog_test.dart` mocks the
|
||||
`clide/window` MethodChannel to throw `MissingPluginException`, exercising the fallback
|
||||
path *without* spawning a native file picker.
|
||||
- **Startup gate.** `ci/smoke_bundle.sh` builds the real release bundle and runs it
|
||||
under xvfb for 5s, correctly interpreting `timeout` exit codes (124/143 = healthy).
|
||||
- **Coverage gate is honest.** `ci/coverage_gate.sh` is a self-contained awk parser (no
|
||||
`lcov` dependency), ratchets only upward, and `exit 2` distinguishes "missing data"
|
||||
from "below floor."
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Major] PTY tests are excluded from coverage.** `ci/test.sh:13` runs
|
||||
`flutter test --coverage --exclude-tags forkpty`; the `forkpty` tests run separately
|
||||
via `dart test` with no `--coverage`. So `lib/src/pty/native_pty.dart` — the
|
||||
highest-risk native code in the repo — is barely in the measured denominator. The
|
||||
92.75% number overstates coverage of the riskiest file.
|
||||
- **[Major] Wall-clock sleeps will flake.** `test/files/watcher_test.dart:67-82` uses
|
||||
fixed `Future.delayed`; `test/pty/session_test.dart:71` polls 50×100ms and `:65` uses a
|
||||
bare `500ms` settle. `session_test.dart`'s `timeout(5s, onTimeout: () {})` (`:48`)
|
||||
*swallows* the timeout — a never-producing PTY proceeds to a confusing assertion
|
||||
failure rather than a clear timeout.
|
||||
- **[Major] `make push-check` does not run integration tests.** `push-check:
|
||||
decisions-validate test-core test test-a11y coverage-gate` — `test-integration` and
|
||||
`smoke-bundle` are omitted. A boot-order regression sails through.
|
||||
- **[Minor] `flutter analyze --no-fatal-infos` in `ci/test.sh:9`** contradicts the
|
||||
stated "fail-on-warning, clean board" discipline.
|
||||
- **[Minor] Integration tests run one-file-at-a-time** to dodge a batch "Unable to start
|
||||
the app" error — each invocation re-boots the engine (slow), and the workaround masks
|
||||
whether the batch failure is environmental or a real teardown leak.
|
||||
- **[Minor] Golden CI config disabled.** Only platform goldens run; a Linux-only CI
|
||||
never validates the macOS goldens, and stale `test/goldens/failures/*.png` artifacts
|
||||
are committed to the repo.
|
||||
- **[Minor] `test_core.sh` timeout kill is best-effort** — the `pkill -9 -f` pattern
|
||||
match is redundant noise next to the real `setsid` + `timeout --kill-after` safety net.
|
||||
- **[Minor] `git/client_test.dart` depends on the ambient `git` binary**, not the
|
||||
vendored dugite — the suite passes/fails on the host git version.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** add `test-integration` (and `smoke-bundle`) to `push-check` — the single
|
||||
highest-value change; run the `forkpty` tests with `--coverage`; drop `--no-fatal-infos`;
|
||||
gitignore `test/goldens/failures/`; make `onTimeout` callbacks `fail()`.
|
||||
|
||||
**Larger efforts:** replace fixed sleeps with event-driven waits
|
||||
(`expectLater(stream, emits(...))`); add a macOS golden CI matrix entry or document
|
||||
goldens as advisory; consider a coverage-exclusion allowlist for genuinely-unreachable
|
||||
defensive branches rather than chasing the last lines with filler tests.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Coverage quality | 4/5 | Tests are meaningful even in "mop-up" files; docked because PTY/FFI is outside the measured number. |
|
||||
| Test reliability / flakiness | 3/5 | Fixed wall-clock sleeps and a swallowed timeout are latent intermittent failures. |
|
||||
| Gate trustworthiness | 3/5 | Coverage gate and smoke bundle are well-built, but `push-check` omits integration tests. |
|
||||
| Test maintainability | 5/5 | Shared fixtures, consistent structure, table-driven error suites, clear doc comments. |
|
||||
| Regression-catching power | 4/5 | Real boot-path integration + smoke + a11y + goldens; weakened by single-OS goldens and PTY coverage gaps. |
|
||||
|
||||
---
|
||||
|
||||
## 3. UX & accessibility — UX & Accessibility Expert
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide has an unusually disciplined *foundation* for a solo pre-v2.0 project: a coherent
|
||||
semantic design-token system, a WCAG-AA contrast gate wired into CI, and i18n/semantic
|
||||
contract tests. That foundation is the biggest strength. The biggest risk is that
|
||||
**keyboard operability is largely unimplemented below the foundation** — the project's
|
||||
own core interaction primitive (`ClideTappable`) is mouse-only, the command palette has
|
||||
no arrow-key navigation or Escape, and there is no focus-traversal wiring across panels.
|
||||
For a keyboard-first power-user dev tool, this is a critical gap that the a11y test suite
|
||||
does not catch because the tests assert *structural* presence (Semantics nodes exist)
|
||||
rather than *operability* (can you actually drive it from the keyboard).
|
||||
|
||||
### Strengths
|
||||
|
||||
- **Semantic token system is real and enforced.** `lib/kernel/src/theme/tokens.dart`
|
||||
defines ~65 named surface tokens; widgets consume `ClideTheme.of(context).surface`
|
||||
rather than raw colors. The resolver provides defaults so partial themes still produce
|
||||
a complete `SurfaceTokens`.
|
||||
- **Contrast gate is genuine WCAG math, run per-theme.** `lib/kernel/src/theme/
|
||||
contrast.dart` implements real relative-luminance ratio with alpha pre-compositing
|
||||
against neutral grey (`contrast.dart:31-37`) — semi-transparent tokens can't spuriously
|
||||
pass.
|
||||
- **Semantics are present on composed widgets.** `ClideButton` wraps
|
||||
`Semantics(button: true, enabled:, label:, hint:, onTap:)`; panels set
|
||||
`container: true, explicitChildNodes: true` with landmark labels.
|
||||
- **State coverage exists in data panels.** `git_panel_view.dart:86-104` handles error,
|
||||
loading, and empty ("working tree clean") states distinctly; `file_tree_view.dart`
|
||||
handles error + loading.
|
||||
- **Manual a11y discipline is documented.** `docs/testing/a11y-manual.md` prescribes a
|
||||
per-tier Orca/VoiceOver pass and is honest about why prose quality can't be automated.
|
||||
- **Disabled state is handled at the cursor level.** `clide_button.dart:41` switches to
|
||||
`SystemMouseCursors.forbidden` and drops the semantic `onTap` when `onPressed == null`.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] `ClideTappable` is mouse-only — no `Focus`, no keyboard activation.**
|
||||
`lib/widgets/src/clide_tappable.dart:37-54` is `MouseRegion` + `GestureDetector` only.
|
||||
It is the base for `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, and
|
||||
most builtin list items. None can receive Tab focus or be activated with Enter/Space.
|
||||
The keyboard-traversal test only passes because it manually wraps the button in an
|
||||
external `Focus` node — it tests that the widget doesn't *block* focus, not that it
|
||||
*accepts* it.
|
||||
- **[Critical] Command palette is not keyboard-navigable.** `clide_palette.dart` —
|
||||
`onSubmitted` only ever invokes `filtered.first` (`:77-80`); no up/down handling, no
|
||||
selected index, no selection highlight, no Escape handler.
|
||||
- **[Major] No focus-traversal wiring between panels.** `FocusTracker`
|
||||
(`lib/kernel/src/focus.dart`) tracks an active *contribution id* for the `clide active`
|
||||
CLI, but is not Flutter `FocusScope`/`FocusTraversalGroup` integration. Nothing
|
||||
establishes Tab order across sidebar → workspace → context.
|
||||
- **[Major] Drag-resize handles have no keyboard equivalent — parity gap.**
|
||||
`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers, with no
|
||||
Semantics node at all. Per "User/Claude parity", panel sizing should have a CLI
|
||||
affordance; none is evident.
|
||||
- **[Major] Single global `KeyboardListener` is a fragile keybinding architecture.**
|
||||
`app.dart:90-148` routes all shortcuts through one root `KeyboardListener` — no
|
||||
per-context scoping, will conflict with text-input fields.
|
||||
`KeybindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel`.
|
||||
- **[Major] Text scale is the *only* in-app a11y accommodation, and it's hidden.**
|
||||
`app.dart:122-138` implements Ctrl +/-/0 text scaling but it's undiscoverable. No
|
||||
high-contrast toggle, no reduced-motion handling, no focus-ring rendering anywhere.
|
||||
- **[Minor] Contrast gate covers only 11 token pairs** — omits `globalTextMuted` (muted
|
||||
text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and
|
||||
`panelActiveBorder`.
|
||||
- **[Minor] ~43 hardcoded-color sites bypass the token system** — some defensible (ANSI
|
||||
palette), but the modal/palette shadow and window-control colors won't adapt to the
|
||||
`paper` light theme.
|
||||
- **[Minor] Hover state is inconsistent and not paired with focus** — every interactive
|
||||
widget reimplements its own `_hover` bool; none render a focus indicator.
|
||||
- **[Minor] `_LeftHatContent` is dead code** — `app.dart:281-292` always returns
|
||||
`SizedBox.shrink()`.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** add a `Focus` + `Actions`/`Shortcuts` (Enter/Space → onTap) wrapper and
|
||||
a focus-ring inside `ClideTappable` — fixes the [Critical] for every button/list-item at
|
||||
once; add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
||||
`_ProjectSwitcherDropdown` `onKeyEvent` pattern at `app.dart:446-452`); expand
|
||||
`canonicalPairs`; surface text-zoom and theme switching in the palette; tokenize the
|
||||
modal shadow and window-control colors.
|
||||
|
||||
**Larger efforts:** establish a real focus-traversal model and integrate `FocusTracker`
|
||||
with Flutter's focus system; replace the root `KeyboardListener` with scoped
|
||||
`Shortcuts`/`Actions` and move off `keyLabel`; add keyboard operability + Semantics to
|
||||
drag-resize handles plus a `clide panel resize` CLI; add an a11y test tier that asserts
|
||||
*operability*, not just Semantics presence.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Interaction model | 2/5 | Coherent slot/panel structure and good drag-resize *with a mouse*, but keyboard operability is largely unbuilt. |
|
||||
| Accessibility | 3/5 | Genuine contrast gate, Semantics on composed widgets, i18n contract tests — but keyboard operability and focus order are not implemented. |
|
||||
| Visual consistency | 4/5 | Strong semantic token system consumed consistently; a few hardcoded-color sites are real theme-adaptation bugs. |
|
||||
| Discoverability | 2/5 | Command palette isn't keyboard-navigable; accommodations are undiscoverable; no in-app keybinding reference. |
|
||||
| State coverage | 3/5 | Data panels and dialogs handle loading/error/empty; but no focus states anywhere and no reduced-motion handling. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Code quality & craft — Senior Dart/Flutter Engineer
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide is, for a solo pre-v2.0 project, in genuinely good shape. The core subsystems (IPC
|
||||
envelope, daemon dispatch, git client, PTY) are small, single-responsibility,
|
||||
well-typed, and consistent. `flutter analyze` is clean for `lib/` — the 9 reported issues
|
||||
are all in `test/`, none are suppressions. The biggest strength is the FFI/PTY layer:
|
||||
`lib/src/pty/native_pty.dart` shows real systems-programming discipline (pre-fork
|
||||
allocation, errno captured before `free`, isolate-teardown ordering documented and
|
||||
correct). The biggest risk is concentrated in two places: a genuine isolate-leak race in
|
||||
`SchedulerService`, and the large vendored-but-owned `lib/src/terminal/` xterm.dart fork
|
||||
(~7k LOC) which carries a different style, commented-out `print`s, and dangling TODOs
|
||||
that the project's own "lib is owned, not vendored" rule says must be held to the same
|
||||
bar.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **PTY/FFI layer is excellent.** `native_pty.dart:129-145` force-resolves FFI
|
||||
trampolines and pre-allocates *all* native memory before `forkpty()`.
|
||||
`native_pty.dart:171-177` captures `errno` before `_freeAll` because `free()` can
|
||||
clobber it. `close()` (367-397) documents and implements the kill→EOF→close ordering
|
||||
to avoid fd-reuse races. The child branch touches no Dart heap.
|
||||
- **IPC envelope is clean and idiomatic** — `lib/src/ipc/envelope.dart` uses a `sealed`
|
||||
class hierarchy, named constructors, a private unifying constructor, and conditional
|
||||
map keys. Decode is total over the type discriminant.
|
||||
- **Typed, meaningful errors.** `PtyException` carries `op` + optional `errno`;
|
||||
`GitException` carries `stderr`; `errnoToIpcError` maps POSIX errno to actionable IPC
|
||||
error kinds. Errors are values, not strings.
|
||||
- **Resource lifecycle is taken seriously across most subsystems.** `FileWatcher.stop()`
|
||||
cancels the subscription *and* closes the controller; `withBuffer`/`setWinsize` in
|
||||
`libc.dart` use `try/finally` around every native allocation. 45 files define
|
||||
`dispose`/`close`.
|
||||
- **The `DaemonEventSink` interface** keeps the dependency graph pointing the right way
|
||||
(server→subsystems) and is documented as such.
|
||||
- **The one `ignore_for_file` (`libc.dart:11-27`) is exemplary** — textbook FFI case,
|
||||
multi-paragraph justification exactly as CLAUDE.md requires.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Major] Isolate-leak race in `SchedulerService._startTicker`** — `scheduler.dart:71`:
|
||||
`Isolate.spawn(...).then((iso) => _isolate = iso)`. If `_stopTicker()` runs before the
|
||||
spawn future completes, `_isolate` is still null, nothing is killed, and the
|
||||
just-spawned isolate (with its `Timer.periodic`) leaks. `native_pty.dart` solved
|
||||
exactly this with `_readerReady`.
|
||||
- **[Major] `lib/src/terminal/` held below the project's own bar.** Carries
|
||||
commented-out `print()` debugging (`custom_text_edit.dart:244-275`), dangling TODOs
|
||||
(`parser.dart:110-113`, `keytab.dart:91`), a 1137-line `parser.dart`, and the only
|
||||
`// ignore: invalid_use_of_protected_member` in the repo (`terminal_view.dart:363`).
|
||||
Either it's genuinely vendored (belongs in `native/` or documented as frozen) or it's
|
||||
owned (needs the cleanup pass).
|
||||
- **[Minor] Empty `catch (_) {}` swallows in `tree_sitter_ffi.dart:197,206`** —
|
||||
`DynamicLibrary.open` failures silently discarded; caller gets a bare `null` with no
|
||||
diagnostic about *why*. Syntax highlighting silently not working is a support
|
||||
headache.
|
||||
- **[Minor] Empty `catch (_) {}` in `test_app.dart:271,311`** — `:271` swallows a
|
||||
theme-load failure the harness exists to detect.
|
||||
- **[Minor] Dead alias in `libc.dart:201-202`** — `typedef Cmsghdr = CmsghdrLinux;`
|
||||
flagged "backward compatibility"; CLAUDE.md forbids backwards-compat hacks in a solo
|
||||
repo.
|
||||
- **[Minor] `// ignore: unused_field` in `editor_controller.dart:25`** "kept for future
|
||||
subscription changes" — speculative retention; the no-suppression rule wants it fixed,
|
||||
not silenced.
|
||||
- **[Minor] Magic numbers in hot FFI paths.** `native_pty.dart` inlines `0x0001
|
||||
// POLLIN`, `28 /* SIGWINCH */`, `4 /* EINTR */`, `9 /* EBADF */` — but `libc.dart`
|
||||
already has a constants section and `errno_mapping.dart` has `PosixErrno.ebadf`.
|
||||
- **[Minor] `git_commands.dart` has ~16 near-identical handler bodies** — a
|
||||
`_guarded(req, () async {...})` helper would remove ~60 lines of structural
|
||||
duplication. Borderline.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** fix the `SchedulerService` spawn race (track the spawn future like
|
||||
`NativePty._readerReady`); replace the three silent `catch (_)` in `tree_sitter_ffi.dart`
|
||||
with a logged last-error; delete the `Cmsghdr` alias and the `unused_field` suppression;
|
||||
have the PTY layer consume `libc.dart` constants / `PosixErrno` instead of inline hex.
|
||||
|
||||
**Larger efforts:** decide the status of `lib/src/terminal/` — formally vendor it
|
||||
(freeze, document, decision-record) or do the cleanup sweep; optionally a `_guarded`
|
||||
helper for `git_commands.dart` (check whether `files_commands` / `editor_commands` share
|
||||
the shape).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Score | Justification |
|
||||
|---|---|---|
|
||||
| Idiomatic Dart | 4/5 | Sealed classes, named ctors, records, `const`, immutability used well; the vendored terminal tree pulls the average down. |
|
||||
| Error handling | 4/5 | Typed errors with context everywhere in core; a few silent `catch (_)` in the FFI loader and test harness cost the 5th point. |
|
||||
| Naming & readability | 4/5 | Clear, intention-revealing names; comments earn their place; inline magic numbers are the main blemish. |
|
||||
| Consistency across subsystems | 3/5 | IPC/git/files/pty are uniform; `lib/src/terminal/` is a different codebase in style; PTY duplicates constants `libc.dart` owns. |
|
||||
| Resource/lifecycle safety | 4/5 | `try/finally` around native allocs, controllers closed, subscriptions cancelled; the one real defect is the `SchedulerService` race. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Security & supply chain — Security Engineer
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide's security posture is **above average for a solo pre-v2.0 project**. All
|
||||
subprocess calls use `Process.run`/`Process.start` with argument *lists* (no shell
|
||||
interpolation), the IPC transport is a per-user Unix socket (not a TCP port), and there
|
||||
is an explicit `path_safety` module with a containment check. The single biggest strength
|
||||
is the disciplined no-shell subprocess layer. The single biggest risk is
|
||||
**untrusted-workspace code execution via toolchain resolution**
|
||||
(`toolchain_paths.dart:79`): a malicious repo can ship a `native/dugite/bin/git`
|
||||
executable that clide will resolve and run. Secondary real issues: path-safety does not
|
||||
defend against symlink escape, and IPC command args are largely unvalidated/un-bounded.
|
||||
Supply-chain hygiene is mostly good but `licenses.yaml` has drifted from `pubspec.yaml`
|
||||
and native binaries are committed without SHA pinning.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **No-shell subprocess execution.** `GitClient._run` (`client.dart:210`),
|
||||
`PqlClient._run` (`client.dart:165`), and the PTY layer all pass `List<String>` args
|
||||
directly. Classic command injection is structurally prevented.
|
||||
- **Toolchain uses resolved absolute paths** — git/pql/tmux resolved once to absolute
|
||||
paths and reused.
|
||||
- **Path containment check exists and is used.** `resolveUnderRoot`
|
||||
(`path_safety.dart:21`) collapses `..`/`.` without touching the filesystem and enforces
|
||||
a prefix check with a separator guard. `files.read`/`files.ls` both call it.
|
||||
- **IPC is a per-user Unix socket, not a network listener.** No `ServerSocket` over TCP
|
||||
anywhere; the default runtime path is in-process, eliminating the socket attack
|
||||
surface in the shipped app.
|
||||
- **PTY FFI memory discipline** — all native memory allocated before `forkpty()`, `errno`
|
||||
captured before `free()`, freed on every path.
|
||||
- **`pubspec.lock` is committed**, deps use exact pins (no carets), `licenses.yaml`
|
||||
exists with per-dep purpose/license.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] Malicious workspace can plant a git binary that clide executes.**
|
||||
`toolchain_paths.dart:79-84` builds `'$workspaceRoot/native/dugite/bin'` and runs
|
||||
`_firstExisting(['$dugite/git'])`; if that file exists it becomes the git binary for
|
||||
all `GitClient` calls, **before** falling back to PATH. An attacker commits an
|
||||
executable at `native/dugite/bin/git`; clide runs it on the first `git.status` (which
|
||||
fires automatically on workspace open). Arbitrary code execution from merely opening a
|
||||
repo. The `native/dugite` convention should resolve relative to the *clide install
|
||||
dir*, never the workspace root.
|
||||
- **[Major] Path-safety does not defend against symlink escape.** `path_safety.dart:
|
||||
35-51` explicitly does not resolve symlinks, and the filesystem layer
|
||||
(`files_commands.dart:81-85`) never does either. A repo symlink `config -> /etc/shadow`
|
||||
passes the containment check (the *link path* is under root) and clide reads the
|
||||
target. Fix: after `resolveUnderRoot`, `resolveSymbolicLinksSync()` and re-verify
|
||||
containment.
|
||||
- **[Major] IPC command arguments are unvalidated and unbounded.**
|
||||
`DaemonDispatcher.dispatch` (`dispatcher.dart:26`) and `IpcRequest.fromJson`
|
||||
(`envelope.dart:49`) do no schema validation. No size limit on `files.read`, no count
|
||||
cap on `git.log`, no check that `git.checkout`'s `branch` (`git_commands.dart:240`)
|
||||
isn't a `-`-prefixed flag. `git diff`/`stage` use `--` separators (good), but
|
||||
`checkout(branch)` and `push(remote, branch)` do not — argument injection
|
||||
(`git checkout --upload-pack=...`) is possible.
|
||||
- **[Minor] macOS entitlements disable library validation.**
|
||||
`macos/Runner/Release.entitlements` sets `disable-library-validation` = true with no
|
||||
App Sandbox entitlement. Arguably needed for the `dlopen` of `libtree-sitter.so`, but
|
||||
combined with no sandbox a compromised process has full user-level filesystem access.
|
||||
- **[Minor] `licenses.yaml` has drifted from `pubspec.yaml`.** Lists dev-dep `test` at
|
||||
`1.25.8` but `pubspec.yaml:60` pins `1.30.0`; lists a `lints 5.0.0` not in
|
||||
`pubspec.yaml` at all. The two-step-commit guardrail is being violated.
|
||||
- **[Minor] Native binaries committed without SHA pinning.** `native/linux-x64/` has
|
||||
`libtree-sitter.so` (24 MB) and `ptyc` (22 KB) committed with no `SHA256SUMS` manifest.
|
||||
CLAUDE.md says native deps are "pinned by SHA"; that pinning is not evidenced.
|
||||
- **[Informational] No secrets service** — clide stores no tokens; git auth is delegated
|
||||
to the system credential helper. The right call; noted so the absence reads as
|
||||
deliberate.
|
||||
- **[Informational] Lua runtime is a stub** — `lib/lua/src/host.dart` is Tier-0. Design
|
||||
intent (strip `io`/`os.execute`/`package.loadlib`/`debug`) is sound; re-assess at Tier
|
||||
6 — sandbox-escape via FFI re-entry will be the concern.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** fix toolchain resolution to resolve `native/dugite` against
|
||||
`Platform.resolvedExecutable`'s directory, never `workspaceRoot` (closes the Critical);
|
||||
add symlink re-check in `files.read`/`files.ls`; reconcile `licenses.yaml` with
|
||||
`pubspec.yaml`; reject `-`-prefixed values for `branch`/`remote`/`path` args (or use
|
||||
`--` everywhere, including `checkout`).
|
||||
|
||||
**Larger efforts:** schema-validate the IPC surface with typed arg schemas + size/count
|
||||
bounds; add a committed `native/SHA256SUMS` verified by `make` and CI; revisit macOS
|
||||
sandboxing (App Sandbox with explicit exceptions); security-review the Lua FFI boundary
|
||||
and capability table before Tier 6 ships.
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Area | Rating | Justification |
|
||||
|---|---|---|
|
||||
| Subprocess safety | 2/5 | No-shell arg lists are excellent, but the workspace-relative dugite path is a real RCE; argument-injection on `checkout`/`push` unmitigated. |
|
||||
| IPC input validation | 3/5 | Per-user Unix socket + in-process default sharply limits exposure, but zero arg-schema validation and no size/count bounds. |
|
||||
| Path/filesystem safety | 3/5 | Real containment check that's actually wired in, undermined by the unhandled symlink-escape gap. |
|
||||
| Dependency/supply-chain hygiene | 3/5 | Exact pins, committed lockfile, documented deps — but `licenses.yaml` drift and missing SHA manifest for committed native binaries. |
|
||||
| Secrets & sandboxing | 3/5 | Correctly delegates secrets; Lua sandbox is only a stub; macOS runs with library validation off and no App Sandbox. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Docs, governance & DX — TPM / Developer-Experience Consultant
|
||||
|
||||
### Executive summary
|
||||
|
||||
clide runs an unusually disciplined governance system for a solo-dev pre-v2.0 project:
|
||||
67 decision records across six domains, with a parser-validated DQR structure, anchored
|
||||
cross-references, and a `make decisions-validate` gate wired into pre-push. The biggest
|
||||
strength is that the DQR system is genuinely *alive* — questions get resolved with dated
|
||||
amendments, superseded decisions are marked, and decisions cite the commits that
|
||||
implement them. The biggest risk is **documentation drift in the narrative docs**:
|
||||
`README.md` and `docs/initial-plan.md` describe an architecture (Go sidecar, `ptyc/` C
|
||||
helper, `app/` subdirectory, separate daemon) that three major decisions (D-5, D-56, the
|
||||
FFI pivot) have since dissolved. A new contributor reading the README first would build
|
||||
a wrong mental model.
|
||||
|
||||
### Strengths
|
||||
|
||||
- **DQR system is maintained, not ornamental.** Resolved questions carry dated
|
||||
resolution lines pointing to the deciding D-record (`questions/architecture.md:39`
|
||||
Q-6→D-57). D-40 carries a `[SUPERSEDED]` tag and an amendment line.
|
||||
- **Decisions are linked to code and commits.** D-67 (`decisions/process.md:61`) cites
|
||||
implementing commits `01a99ed`, `d162ba2`. D-66 references `ci/test.sh` by path.
|
||||
- **Governance migration was done cleanly** — the `decisions/` → `governance/`
|
||||
restructure updated cross-references and the auto-generated index.
|
||||
- **Commit discipline is real.** `git log` shows imperative subjects, no Conventional
|
||||
Commits prefixes, ticket refs, logical scoping — exactly what `git-commit/SKILL.md`
|
||||
prescribes.
|
||||
- **`licenses.yaml` is thorough** — all six runtime Dart deps present, plus fonts/native
|
||||
libs, with purpose justifications. *(Note: the Security reviewer found version drift
|
||||
in this file — see Finding above; the two reviewers examined different rows.)*
|
||||
- **Makefile is self-documenting** (`##` help annotations) and matches `CLAUDE.md`.
|
||||
|
||||
### Findings
|
||||
|
||||
- **[Critical] `docs/initial-plan.md` is badly stale.** The "north-star" doc (linked
|
||||
from `CLAUDE.md:14` and `README.md:44`) still describes a Go sidecar
|
||||
(`initial-plan.md:4,55,189`), `clide --daemon` long-running process (`:162-164`),
|
||||
`app/` subdirectory layout (`:184-204`), and `project.yaml` (`:172`) — all contradicted
|
||||
by D-5, D-56, and the single-package-at-root reality. Nothing flags it as historical.
|
||||
- **[Critical] `README.md` describes a dissolved architecture.** `README.md:10`
|
||||
documents `ptyc/` as a live component; `README.md:36` lists `make ptyc-build`. The
|
||||
`ptyc/` directory does not exist, the Makefile has no such target, and the CHANGELOG's
|
||||
own Unreleased section records ptyc's removal.
|
||||
- **[Major] `README.md:44` links to `decisions/`** — a directory that no longer exists
|
||||
(migrated to `governance/`). Dead link in the primary onboarding doc.
|
||||
- **[Major] CHANGELOG has duplicate subsection headings in `[Unreleased]`.** Three
|
||||
`### Changed` blocks (`CHANGELOG.md:100, 114, 168`), two `### Fixed`, two `### Removed`
|
||||
in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release.
|
||||
- **[Major] No `CONTRIBUTING.md` or onboarding doc.** For a project "intended to ship
|
||||
publicly to other developers," there is no contributor guide; the build/test story is
|
||||
scattered across `CLAUDE.md` (Claude-oriented), `README.md` (partly wrong), and
|
||||
Makefile help.
|
||||
- **[Major] Coverage-floor governance contradicts itself.** D-66 (`testing.md:65`) says
|
||||
the floor lives at `coverage/floor.txt` starting "≈35%"; `CHANGELOG.md:44-46` says it's
|
||||
in `pubspec.yaml` `coverage_floor:` starting at 34%; the latest commit is `9030e56
|
||||
hold coverage_floor fixed at 90`. Three sources, three mechanisms/values. D-66 was
|
||||
never amended.
|
||||
- **[Minor] `ci/release.sh` is a stub that still references goreleaser/sidecar** — Go
|
||||
tooling for a project with no Go.
|
||||
- **[Minor] CHANGELOG `[2.0.0] — 2026-05-03` dating** — the v2.0.0 tag is dated
|
||||
2026-05-03 but the enormous Unreleased section represents ~80 commits of post-tag work
|
||||
with no interim version.
|
||||
- **[Minor] Stale-ish open questions** — Q-25 (body text face) is de facto resolved by
|
||||
D-43/D-44 and the shipped impl; Q-1/Q-2/Q-3 ("defer until Tier 1 is in real use") are
|
||||
due for triage now that Tier 1 has shipped.
|
||||
- **[Minor] Skills system is coherent but undocumented as a set** — eight skills under
|
||||
`.claude/skills/`, no index.
|
||||
|
||||
### Recommendations
|
||||
|
||||
**Quick wins:** rewrite `README.md`'s `ptyc/` sections and fix the `decisions/` link;
|
||||
banner `docs/initial-plan.md` as historical (or split out a current
|
||||
`docs/architecture.md`); merge the duplicate changelog subsection headings; amend D-66 to
|
||||
reflect the floor's actual location/mechanism/value with a dated amendment line; triage
|
||||
Q-1/2/3/25.
|
||||
|
||||
**Larger efforts:** write a human-facing `CONTRIBUTING.md` (clone →
|
||||
`make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions); cut
|
||||
an interim release to drain the ~80-commit Unreleased backlog; add a
|
||||
`.claude/skills/README.md` inventory; establish a periodic governance sweep (the repo
|
||||
even has a `clean-house` skill for exactly this).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Area | Score | Justification |
|
||||
|---|---|---|
|
||||
| Governance discipline | 4/5 | DQR system genuinely maintained — but D-66 drift and untriaged Tier-1-era questions show the sweep cadence lags the code. |
|
||||
| Documentation accuracy | 2/5 | Both primary onboarding docs describe a dissolved Go-sidecar/ptyc/daemon architecture; `CLAUDE.md` is accurate by contrast. |
|
||||
| Changelog hygiene | 3/5 | Per-commit discipline is followed, but duplicate subsection headings violate the standard and an 80-commit Unreleased backlog undermines the format. |
|
||||
| Contributor onboarding | 2/5 | No `CONTRIBUTING.md`; build story split across three docs, one wrong; `CLAUDE.md` is Claude-addressed, not human-addressed. |
|
||||
| Convention adherence | 4/5 | Commit style, DQR claiming, `licenses.yaml` two-step rule demonstrably followed; docked for the changelog defects and the README gap. |
|
||||
|
||||
---
|
||||
|
||||
## Closing note
|
||||
|
||||
The recurring pattern across all six reviews: **clide's foundations are excellent and
|
||||
its finishing is incomplete.** The extension contract, test helpers, FFI discipline,
|
||||
governance system, and token system are all things most projects never get right. The
|
||||
gaps — IPC not wired, keyboard not operable, docs describing a dead architecture, a
|
||||
workspace-relative binary path — are all the kind of thing that happens when a fast-moving
|
||||
solo project's implementation outruns its connective tissue. They are concentrated, not
|
||||
diffuse, and the quick-win column above would close most of the critical ones in a few
|
||||
focused days.
|
||||
@@ -0,0 +1,17 @@
|
||||
# dartdoc configuration.
|
||||
#
|
||||
# clide is an application, not a published library — its lib/ API docs are not
|
||||
# a consumed surface. The dart-doc CI gate (.github/workflows/test.yml) exists
|
||||
# to catch real doc-comment defects: unresolved [symbol] references. That check
|
||||
# stays strict (unresolved-doc-reference is NOT ignored).
|
||||
#
|
||||
# The "broken-link" category, by contrast, is pure rendering noise here:
|
||||
# - The README is dartdoc's landing page; its repo-relative links
|
||||
# ([CLAUDE.md], [docs/…], [LICENSE]) are correct on GitHub but don't resolve
|
||||
# once copied into the generated API site.
|
||||
# - The generated Phosphor icon font (lib/widgets/src/icons/phosphor_glyphs.g.dart,
|
||||
# 1512 glyphs) produces broken cross-links to its own library page.
|
||||
# Neither is a fixable doc-quality problem, so the category is ignored.
|
||||
dartdoc:
|
||||
ignore:
|
||||
- broken-link
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"name": "Settings — Appearance",
|
||||
"shapes": {
|
||||
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
|
||||
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||
|
||||
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
|
||||
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
|
||||
|
||||
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
|
||||
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
|
||||
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
|
||||
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
|
||||
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
|
||||
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
|
||||
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
|
||||
|
||||
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-appearance-sel": { "type": "Rectangle", "left": 130, "top": 228, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
|
||||
"cat-appearance-stripe": { "type": "Rectangle", "left": 130, "top": 228, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
|
||||
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Appearance", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Theme, contrast, and text size.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
|
||||
"grp-theme": { "type": "Text", "left": 382, "top": 186, "text": "THEME", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"theme-scope-body": { "type": "Rectangle", "left": 1004, "top": 188, "width": 15, "height": 10, "fillColor": "#20202C", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||
"theme-scope-tab": { "type": "Rectangle", "left": 1004, "top": 185, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||
|
||||
"s1-card": { "type": "Rectangle", "left": 382, "top": 210, "width": 200, "height": 92, "fillColor": "#20202C", "strokeColor": "#78A0F8", "corners": [6, 6, 6, 6] },
|
||||
"s1-side": { "type": "Rectangle", "left": 390, "top": 218, "width": 22, "height": 76, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [3, 3, 3, 3] },
|
||||
"s1-bar": { "type": "Rectangle", "left": 418, "top": 218, "width": 156, "height": 14, "fillColor": "#242838", "strokeColor": "#242838", "corners": [3, 3, 3, 3] },
|
||||
"s1-accent": { "type": "Rectangle", "left": 418, "top": 240, "width": 40, "height": 4, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||
"s1-l1": { "type": "Rectangle", "left": 418, "top": 254, "width": 120, "height": 4, "fillColor": "#E6E8F2", "strokeColor": "#E6E8F2" },
|
||||
"s1-l2": { "type": "Rectangle", "left": 418, "top": 266, "width": 90, "height": 4, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
|
||||
"s1-l3": { "type": "Rectangle", "left": 418, "top": 278, "width": 110, "height": 4, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
|
||||
"s1-check-bg": { "type": "Ellipse", "left": 558, "top": 218, "width": 16, "height": 16, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||
"s1-check": { "type": "Text", "left": 561, "top": 219, "text": "✓", "fontColor": "#20202C", "fontSize": 11 },
|
||||
"s1-name": { "type": "Text", "left": 390, "top": 306, "text": "clide", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
|
||||
"s2-card": { "type": "Rectangle", "left": 598, "top": 210, "width": 200, "height": 92, "fillColor": "#1E1E1E", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"s2-side": { "type": "Rectangle", "left": 606, "top": 218, "width": 22, "height": 76, "fillColor": "#181818", "strokeColor": "#181818", "corners": [3, 3, 3, 3] },
|
||||
"s2-bar": { "type": "Rectangle", "left": 634, "top": 218, "width": 156, "height": 14, "fillColor": "#252526", "strokeColor": "#252526", "corners": [3, 3, 3, 3] },
|
||||
"s2-accent": { "type": "Rectangle", "left": 634, "top": 240, "width": 40, "height": 4, "fillColor": "#569CD6", "strokeColor": "#569CD6" },
|
||||
"s2-l1": { "type": "Rectangle", "left": 634, "top": 254, "width": 120, "height": 4, "fillColor": "#D4D4D4", "strokeColor": "#D4D4D4" },
|
||||
"s2-l2": { "type": "Rectangle", "left": 634, "top": 266, "width": 90, "height": 4, "fillColor": "#6A6A6A", "strokeColor": "#6A6A6A" },
|
||||
"s2-l3": { "type": "Rectangle", "left": 634, "top": 278, "width": 110, "height": 4, "fillColor": "#6A6A6A", "strokeColor": "#6A6A6A" },
|
||||
"s2-name": { "type": "Text", "left": 606, "top": 306, "text": "midnight", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||
|
||||
"s3-card": { "type": "Rectangle", "left": 814, "top": 210, "width": 200, "height": 92, "fillColor": "#F4F1EA", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"s3-side": { "type": "Rectangle", "left": 822, "top": 218, "width": 22, "height": 76, "fillColor": "#ECE7DB", "strokeColor": "#ECE7DB", "corners": [3, 3, 3, 3] },
|
||||
"s3-bar": { "type": "Rectangle", "left": 850, "top": 218, "width": 156, "height": 14, "fillColor": "#FBF8F1", "strokeColor": "#FBF8F1", "corners": [3, 3, 3, 3] },
|
||||
"s3-accent": { "type": "Rectangle", "left": 850, "top": 240, "width": 40, "height": 4, "fillColor": "#C14B2A", "strokeColor": "#C14B2A" },
|
||||
"s3-l1": { "type": "Rectangle", "left": 850, "top": 254, "width": 120, "height": 4, "fillColor": "#1A1A1A", "strokeColor": "#1A1A1A" },
|
||||
"s3-l2": { "type": "Rectangle", "left": 850, "top": 266, "width": 90, "height": 4, "fillColor": "#7A7468", "strokeColor": "#7A7468" },
|
||||
"s3-l3": { "type": "Rectangle", "left": 850, "top": 278, "width": 110, "height": 4, "fillColor": "#7A7468", "strokeColor": "#7A7468" },
|
||||
"s3-name": { "type": "Text", "left": 822, "top": 306, "text": "paper", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||
|
||||
"s4-card": { "type": "Rectangle", "left": 382, "top": 330, "width": 200, "height": 92, "fillColor": "#0C0C0C", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"s4-side": { "type": "Rectangle", "left": 390, "top": 338, "width": 22, "height": 76, "fillColor": "#000000", "strokeColor": "#000000", "corners": [3, 3, 3, 3] },
|
||||
"s4-bar": { "type": "Rectangle", "left": 418, "top": 338, "width": 156, "height": 14, "fillColor": "#141414", "strokeColor": "#141414", "corners": [3, 3, 3, 3] },
|
||||
"s4-accent": { "type": "Rectangle", "left": 418, "top": 360, "width": 40, "height": 4, "fillColor": "#FFC868", "strokeColor": "#FFC868" },
|
||||
"s4-l1": { "type": "Rectangle", "left": 418, "top": 374, "width": 120, "height": 4, "fillColor": "#C8C8C8", "strokeColor": "#C8C8C8" },
|
||||
"s4-l2": { "type": "Rectangle", "left": 418, "top": 386, "width": 90, "height": 4, "fillColor": "#5A5A5A", "strokeColor": "#5A5A5A" },
|
||||
"s4-l3": { "type": "Rectangle", "left": 418, "top": 398, "width": 110, "height": 4, "fillColor": "#5A5A5A", "strokeColor": "#5A5A5A" },
|
||||
"s4-name": { "type": "Text", "left": 390, "top": 426, "text": "terminal", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||
|
||||
"s5-card": { "type": "Rectangle", "left": 598, "top": 330, "width": 200, "height": 92, "fillColor": "#1E1E2E", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"s5-side": { "type": "Rectangle", "left": 606, "top": 338, "width": 22, "height": 76, "fillColor": "#181825", "strokeColor": "#181825", "corners": [3, 3, 3, 3] },
|
||||
"s5-bar": { "type": "Rectangle", "left": 634, "top": 338, "width": 156, "height": 14, "fillColor": "#313244", "strokeColor": "#313244", "corners": [3, 3, 3, 3] },
|
||||
"s5-accent": { "type": "Rectangle", "left": 634, "top": 360, "width": 40, "height": 4, "fillColor": "#CBA6F7", "strokeColor": "#CBA6F7" },
|
||||
"s5-l1": { "type": "Rectangle", "left": 634, "top": 374, "width": 120, "height": 4, "fillColor": "#CDD6F4", "strokeColor": "#CDD6F4" },
|
||||
"s5-l2": { "type": "Rectangle", "left": 634, "top": 386, "width": 90, "height": 4, "fillColor": "#7F849C", "strokeColor": "#7F849C" },
|
||||
"s5-l3": { "type": "Rectangle", "left": 634, "top": 398, "width": 110, "height": 4, "fillColor": "#7F849C", "strokeColor": "#7F849C" },
|
||||
"s5-name": { "type": "Text", "left": 606, "top": 426, "text": "catppuccin-mocha", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||
|
||||
"s6-card": { "type": "Rectangle", "left": 814, "top": 330, "width": 200, "height": 92, "fillColor": "#21262F", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"s6-side": { "type": "Rectangle", "left": 822, "top": 338, "width": 22, "height": 76, "fillColor": "#292E38", "strokeColor": "#292E38", "corners": [3, 3, 3, 3] },
|
||||
"s6-bar": { "type": "Rectangle", "left": 850, "top": 338, "width": 156, "height": 14, "fillColor": "#393E48", "strokeColor": "#393E48", "corners": [3, 3, 3, 3] },
|
||||
"s6-accent": { "type": "Rectangle", "left": 850, "top": 360, "width": 40, "height": 4, "fillColor": "#FA5F8B", "strokeColor": "#FA5F8B" },
|
||||
"s6-l1": { "type": "Rectangle", "left": 850, "top": 374, "width": 120, "height": 4, "fillColor": "#E2E8F5", "strokeColor": "#E2E8F5" },
|
||||
"s6-l2": { "type": "Rectangle", "left": 850, "top": 386, "width": 90, "height": 4, "fillColor": "#7A8296", "strokeColor": "#7A8296" },
|
||||
"s6-l3": { "type": "Rectangle", "left": 850, "top": 398, "width": 110, "height": 4, "fillColor": "#7A8296", "strokeColor": "#7A8296" },
|
||||
"s6-name": { "type": "Text", "left": 822, "top": 426, "text": "summer-night", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||
|
||||
"grp-options": { "type": "Text", "left": 382, "top": 462, "text": "OPTIONS", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"card-options": { "type": "Rectangle", "left": 382, "top": 482, "width": 648, "height": 150, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
|
||||
"hc-label": { "type": "Text", "left": 398, "top": 500, "text": "High contrast", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"hc-track": { "type": "Rectangle", "left": 950, "top": 498, "width": 40, "height": 22, "fillColor": "#343850", "strokeColor": "#343850", "corners": [11, 11, 11, 11] },
|
||||
"hc-knob": { "type": "Ellipse", "left": 952, "top": 500, "width": 18, "height": 18, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
|
||||
"hc-scope-circle": { "type": "Ellipse", "left": 1004, "top": 502, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"hc-scope-eq": { "type": "Rectangle", "left": 1004, "top": 508, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"hc-scope-mer": { "type": "Rectangle", "left": 1010, "top": 502, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
|
||||
"ts-label": { "type": "Text", "left": 398, "top": 544, "text": "Text size", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"ts-box": { "type": "Rectangle", "left": 926, "top": 542, "width": 64, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"ts-text": { "type": "Text", "left": 940, "top": 547, "text": "100%", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"ts-scope-circle": { "type": "Ellipse", "left": 1004, "top": 546, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"ts-scope-eq": { "type": "Rectangle", "left": 1004, "top": 552, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"ts-scope-mer": { "type": "Rectangle", "left": 1010, "top": 546, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
|
||||
"ct-label": { "type": "Text", "left": 398, "top": 588, "text": "Custom theme", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"ct-button": { "type": "Rectangle", "left": 846, "top": 588, "width": 144, "height": 28, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [4, 4, 4, 4] },
|
||||
"ct-button-text": { "type": "Text", "left": 858, "top": 595, "text": "Import theme YAML ↗", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||
"ct-scope-circle": { "type": "Ellipse", "left": 1004, "top": 592, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"ct-scope-eq": { "type": "Rectangle", "left": 1004, "top": 598, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"ct-scope-mer": { "type": "Rectangle", "left": 1010, "top": 592, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"name": "Settings — Claude",
|
||||
"shapes": {
|
||||
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 1250, "fillColor": "#17171E", "strokeColor": "#17171E" },
|
||||
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 1222, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||
|
||||
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 1160, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
|
||||
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 1160, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
|
||||
|
||||
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
|
||||
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
|
||||
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 1114, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
|
||||
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 1114, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
|
||||
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
|
||||
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
|
||||
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
|
||||
|
||||
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-claude-sel": { "type": "Rectangle", "left": 130, "top": 264, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
|
||||
"cat-claude-stripe": { "type": "Rectangle", "left": 130, "top": 264, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
|
||||
"scrollbar-track": { "type": "Rectangle", "left": 1038, "top": 110, "width": 4, "height": 1104, "fillColor": "#242838", "strokeColor": "#242838", "corners": [2, 2, 2, 2] },
|
||||
"scrollbar-thumb": { "type": "Rectangle", "left": 1038, "top": 110, "width": 4, "height": 250, "fillColor": "#3C445C", "strokeColor": "#3C445C", "corners": [2, 2, 2, 2] },
|
||||
|
||||
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Claude", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Model, effort, permissions, and the workspace's loaded skills, agents & MCP.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
|
||||
"grp-settings": { "type": "Text", "left": 382, "top": 186, "text": "SETTINGS", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"card-settings": { "type": "Rectangle", "left": 382, "top": 206, "width": 648, "height": 166, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"model-label": { "type": "Text", "left": 398, "top": 222, "text": "Model", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"model-select": { "type": "Rectangle", "left": 870, "top": 218, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"model-text": { "type": "Text", "left": 882, "top": 223, "text": "opus-4.8", "fontColor": "#78A0F8", "fontSize": 14 },
|
||||
"model-chevron": { "type": "Text", "left": 974, "top": 223, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"model-scope-circle": { "type": "Ellipse", "left": 1004, "top": 224, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"model-scope-eq": { "type": "Rectangle", "left": 1004, "top": 230, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"model-scope-mer": { "type": "Rectangle", "left": 1010, "top": 224, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
|
||||
"effort-label": { "type": "Text", "left": 398, "top": 254, "text": "Effort", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"effort-select": { "type": "Rectangle", "left": 870, "top": 250, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"effort-text": { "type": "Text", "left": 882, "top": 255, "text": "high", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"effort-chevron": { "type": "Text", "left": 974, "top": 255, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"effort-scope-circle": { "type": "Ellipse", "left": 1004, "top": 256, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"effort-scope-eq": { "type": "Rectangle", "left": 1004, "top": 262, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"effort-scope-mer": { "type": "Rectangle", "left": 1010, "top": 256, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
|
||||
"perm-label": { "type": "Text", "left": 398, "top": 286, "text": "Permission mode", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"perm-select": { "type": "Rectangle", "left": 870, "top": 282, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"perm-text": { "type": "Text", "left": 882, "top": 287, "text": "default", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"perm-chevron": { "type": "Text", "left": 974, "top": 287, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"perm-scope-body": { "type": "Rectangle", "left": 1004, "top": 290, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||
"perm-scope-tab": { "type": "Rectangle", "left": 1004, "top": 287, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||
|
||||
"style-label": { "type": "Text", "left": 398, "top": 318, "text": "Output style", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"style-select": { "type": "Rectangle", "left": 870, "top": 314, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"style-text": { "type": "Text", "left": 882, "top": 319, "text": "default", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"style-chevron": { "type": "Text", "left": 974, "top": 319, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"style-scope-circle": { "type": "Ellipse", "left": 1004, "top": 320, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"style-scope-eq": { "type": "Rectangle", "left": 1004, "top": 326, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"style-scope-mer": { "type": "Rectangle", "left": 1010, "top": 320, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
|
||||
"source-label": { "type": "Text", "left": 398, "top": 350, "text": "Source", "fontColor": "#8890AC", "fontSize": 15 },
|
||||
"source-value": { "type": "Text", "left": 870, "top": 350, "text": "~/.claude · .clide", "fontColor": "#545C84", "fontSize": 13 },
|
||||
|
||||
"grp-skills": { "type": "Text", "left": 382, "top": 392, "text": "SKILLS", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"grp-skills-n": { "type": "Text", "left": 1014, "top": 392, "text": "6", "fontColor": "#545C84", "fontSize": 12 },
|
||||
"card-skills": { "type": "Rectangle", "left": 382, "top": 410, "width": 648, "height": 150, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"sk1": { "type": "Text", "left": 398, "top": 426, "text": "ui-design", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"sk2": { "type": "Text", "left": 398, "top": 448, "text": "frame0-wireframe", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"sk3": { "type": "Text", "left": 398, "top": 470, "text": "pql", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"sk4": { "type": "Text", "left": 398, "top": 492, "text": "clide", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"sk5": { "type": "Text", "left": 398, "top": 514, "text": "git-commit", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"sk6": { "type": "Text", "left": 398, "top": 536, "text": "testmode", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
|
||||
"grp-agents": { "type": "Text", "left": 382, "top": 580, "text": "AGENTS", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"grp-agents-n": { "type": "Text", "left": 1020, "top": 580, "text": "3", "fontColor": "#545C84", "fontSize": 12 },
|
||||
"card-agents": { "type": "Rectangle", "left": 382, "top": 598, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"ag1": { "type": "Text", "left": 398, "top": 614, "text": "Explore", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"ag2": { "type": "Text", "left": 398, "top": 636, "text": "Plan", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"ag3": { "type": "Text", "left": 398, "top": 658, "text": "general-purpose", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
|
||||
"grp-commands": { "type": "Text", "left": 382, "top": 702, "text": "COMMANDS", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"grp-commands-n": { "type": "Text", "left": 1014, "top": 702, "text": "5", "fontColor": "#545C84", "fontSize": 12 },
|
||||
"card-commands": { "type": "Rectangle", "left": 382, "top": 720, "width": 648, "height": 128, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"cm1": { "type": "Text", "left": 398, "top": 736, "text": "whats-next", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"cm2": { "type": "Text", "left": 398, "top": 758, "text": "code-review", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"cm3": { "type": "Text", "left": 398, "top": 780, "text": "simplify", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"cm4": { "type": "Text", "left": 398, "top": 802, "text": "verify", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
"cm5": { "type": "Text", "left": 398, "top": 824, "text": "run", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||
|
||||
"grp-hooks": { "type": "Text", "left": 382, "top": 868, "text": "HOOKS", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"grp-hooks-n": { "type": "Text", "left": 1020, "top": 868, "text": "3", "fontColor": "#545C84", "fontSize": 12 },
|
||||
"card-hooks": { "type": "Rectangle", "left": 382, "top": 886, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"hk1-ev": { "type": "Text", "left": 398, "top": 902, "text": "SessionStart", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||
"hk1-cmd": { "type": "Text", "left": 540, "top": 902, "text": "peon-ping ready", "fontColor": "#8890AC", "fontSize": 12 },
|
||||
"hk2-ev": { "type": "Text", "left": 398, "top": 924, "text": "UserPromptSubmit", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||
"hk2-cmd": { "type": "Text", "left": 540, "top": 924, "text": "peon-ping working", "fontColor": "#8890AC", "fontSize": 12 },
|
||||
"hk3-ev": { "type": "Text", "left": 398, "top": 946, "text": "Stop", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||
"hk3-cmd": { "type": "Text", "left": 540, "top": 946, "text": "peon-ping done", "fontColor": "#8890AC", "fontSize": 12 },
|
||||
|
||||
"grp-perms": { "type": "Text", "left": 382, "top": 990, "text": "PERMISSIONS", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"grp-perms-n": { "type": "Text", "left": 1020, "top": 990, "text": "6", "fontColor": "#545C84", "fontSize": 12 },
|
||||
"card-perms": { "type": "Rectangle", "left": 382, "top": 1008, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"pm-allow-k": { "type": "Text", "left": 398, "top": 1024, "text": "allow", "fontColor": "#7DD3A8", "fontSize": 12 },
|
||||
"pm-allow-v": { "type": "Text", "left": 452, "top": 1024, "text": "Bash(git *) · Read · Edit · Write", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||
"pm-ask-k": { "type": "Text", "left": 398, "top": 1046, "text": "ask", "fontColor": "#E6C370", "fontSize": 12 },
|
||||
"pm-ask-v": { "type": "Text", "left": 452, "top": 1046, "text": "Bash(rm *)", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||
"pm-deny-k": { "type": "Text", "left": 398, "top": 1068, "text": "deny", "fontColor": "#E87D7D", "fontSize": 12 },
|
||||
"pm-deny-v": { "type": "Text", "left": 452, "top": 1068, "text": "Bash(curl *)", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||
|
||||
"grp-mcp": { "type": "Text", "left": 382, "top": 1112, "text": "MCP SERVERS", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"grp-mcp-n": { "type": "Text", "left": 1020, "top": 1112, "text": "2", "fontColor": "#545C84", "fontSize": 12 },
|
||||
"card-mcp": { "type": "Rectangle", "left": 382, "top": 1130, "width": 648, "height": 62, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"mcp1": { "type": "Text", "left": 398, "top": 1146, "text": "claude.ai", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||
"mcp2": { "type": "Text", "left": 398, "top": 1168, "text": "context7", "fontColor": "#E6E8F2", "fontSize": 13 }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "Settings — Editor",
|
||||
"shapes": {
|
||||
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
|
||||
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||
|
||||
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
|
||||
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
|
||||
|
||||
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
|
||||
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
|
||||
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
|
||||
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
|
||||
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
|
||||
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
|
||||
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
|
||||
|
||||
"cat-editor-sel": { "type": "Rectangle", "left": 130, "top": 156, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
|
||||
"cat-editor-stripe": { "type": "Rectangle", "left": 130, "top": 156, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
|
||||
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Editor", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Buffer formatting, indentation, and per-folder overrides.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
|
||||
"grp-fmt": { "type": "Text", "left": 382, "top": 186, "text": "FORMATTING", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"card-fmt": { "type": "Rectangle", "left": 382, "top": 206, "width": 648, "height": 268, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
|
||||
"r1-label": { "type": "Text", "left": 398, "top": 222, "text": "Format on save", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"r1-help": { "type": "Text", "left": 398, "top": 246, "text": "Run the formatter on every buffer save.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"r1-toggle": { "type": "Rectangle", "left": 950, "top": 224, "width": 40, "height": 22, "fillColor": "#78A0F8", "strokeColor": "#78A0F8", "corners": [11, 11, 11, 11] },
|
||||
"r1-knob": { "type": "Ellipse", "left": 970, "top": 226, "width": 18, "height": 18, "fillColor": "#E6E8F2", "strokeColor": "#E6E8F2" },
|
||||
"r1-scope-body": { "type": "Rectangle", "left": 1004, "top": 228, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||
"r1-scope-tab": { "type": "Rectangle", "left": 1004, "top": 225, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||
|
||||
"r2-label": { "type": "Text", "left": 398, "top": 288, "text": "Indent style", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"r2-help": { "type": "Text", "left": 398, "top": 312, "text": "Spaces or tabs for new indentation.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"r2-select": { "type": "Rectangle", "left": 870, "top": 288, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"r2-select-text": { "type": "Text", "left": 882, "top": 293, "text": "Spaces", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"r2-select-chevron": { "type": "Text", "left": 974, "top": 293, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"r2-scope-circle": { "type": "Ellipse", "left": 1004, "top": 292, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"r2-scope-eq": { "type": "Rectangle", "left": 1004, "top": 298, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"r2-scope-mer": { "type": "Rectangle", "left": 1010, "top": 292, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
|
||||
"r3-label": { "type": "Text", "left": 398, "top": 354, "text": "Tab width", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"r3-help": { "type": "Text", "left": 398, "top": 378, "text": "Columns per indent level.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"r3-number": { "type": "Rectangle", "left": 934, "top": 354, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"r3-number-text": { "type": "Text", "left": 956, "top": 359, "text": "2", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"r3-scope-body": { "type": "Rectangle", "left": 1004, "top": 358, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||
"r3-scope-tab": { "type": "Rectangle", "left": 1004, "top": 355, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||
|
||||
"r4-label": { "type": "Text", "left": 398, "top": 420, "text": "Ruler column", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"r4-help": { "type": "Text", "left": 398, "top": 444, "text": "Wrap-guide position. Unset — inherits the default (120).", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"r4-number": { "type": "Rectangle", "left": 934, "top": 420, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"r4-number-text": { "type": "Text", "left": 950, "top": 425, "text": "120", "fontColor": "#545C84", "fontSize": 14 },
|
||||
"r4-scope-circle": { "type": "Ellipse", "left": 1004, "top": 424, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#78809C" },
|
||||
|
||||
"grp-files": { "type": "Text", "left": 382, "top": 494, "text": "FILE OVERRIDES", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"card-files": { "type": "Rectangle", "left": 382, "top": 514, "width": 648, "height": 66, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"r5-label": { "type": "Text", "left": 398, "top": 530, "text": "Project .editorconfig", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"r5-help": { "type": "Text", "left": 398, "top": 554, "text": "Per-folder overrides. Opens the file in the editor.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"r5-button": { "type": "Rectangle", "left": 846, "top": 530, "width": 144, "height": 28, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [4, 4, 4, 4] },
|
||||
"r5-button-text": { "type": "Text", "left": 858, "top": 537, "text": "Open .editorconfig ↗", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||
"r5-scope-body": { "type": "Rectangle", "left": 1004, "top": 534, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||
"r5-scope-tab": { "type": "Rectangle", "left": 1004, "top": 531, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||
|
||||
"legend-rule": { "type": "Rectangle", "left": 382, "top": 600, "width": 648, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
"legend-title": { "type": "Text", "left": 382, "top": 616, "text": "Scope", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"lg-proj-body": { "type": "Rectangle", "left": 438, "top": 618, "width": 15, "height": 10, "fillColor": "#20202C", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||
"lg-proj-tab": { "type": "Rectangle", "left": 438, "top": 615, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||
"lg-proj-text": { "type": "Text", "left": 462, "top": 614, "text": "folder · Project", "fontColor": "#8890AC", "fontSize": 12 },
|
||||
"lg-always-circle": { "type": "Ellipse", "left": 600, "top": 615, "width": 14, "height": 14, "fillColor": "#20202C", "strokeColor": "#E6C370" },
|
||||
"lg-always-eq": { "type": "Rectangle", "left": 600, "top": 621, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"lg-always-mer": { "type": "Rectangle", "left": 606, "top": 615, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"lg-always-text": { "type": "Text", "left": 622, "top": 614, "text": "globe · Always", "fontColor": "#8890AC", "fontSize": 12 },
|
||||
"lg-default-circle": { "type": "Ellipse", "left": 752, "top": 615, "width": 14, "height": 14, "fillColor": "#20202C", "strokeColor": "#78809C" },
|
||||
"lg-default-text": { "type": "Text", "left": 774, "top": 614, "text": "circle-dashed · Default", "fontColor": "#8890AC", "fontSize": 12 }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "Settings — Search",
|
||||
"shapes": {
|
||||
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
|
||||
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||
|
||||
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
|
||||
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
|
||||
|
||||
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
|
||||
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
|
||||
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
|
||||
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||
|
||||
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#78A0F8", "corners": [4, 4, 4, 4] },
|
||||
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#B1BBE3" },
|
||||
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#B1BBE3", "strokeColor": "#B1BBE3" },
|
||||
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "tab", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||
"search-caret": { "type": "Rectangle", "left": 198, "top": 123, "width": 1, "height": 16, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||
"search-clear": { "type": "Text", "left": 328, "top": 124, "text": "✕", "fontColor": "#78809C", "fontSize": 12 },
|
||||
|
||||
"rail-hint": { "type": "Text", "left": 152, "top": 164, "text": "Searching all categories", "fontColor": "#545C84", "fontSize": 12 },
|
||||
"cat-editor": { "type": "Text", "left": 152, "top": 196, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-editor-n": { "type": "Text", "left": 326, "top": 196, "text": "2", "fontColor": "#545C84", "fontSize": 13 },
|
||||
"cat-keymap": { "type": "Text", "left": 152, "top": 228, "text": "Keymap", "fontColor": "#545C84", "fontSize": 14 },
|
||||
"cat-appearance": { "type": "Text", "left": 152, "top": 260, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"cat-appearance-n": { "type": "Text", "left": 326, "top": 260, "text": "1", "fontColor": "#545C84", "fontSize": 13 },
|
||||
"cat-claude": { "type": "Text", "left": 152, "top": 292, "text": "Claude", "fontColor": "#545C84", "fontSize": 14 },
|
||||
"cat-activity": { "type": "Text", "left": 152, "top": 324, "text": "Activity", "fontColor": "#545C84", "fontSize": 14 },
|
||||
"cat-terminal": { "type": "Text", "left": 152, "top": 356, "text": "Terminal", "fontColor": "#545C84", "fontSize": 14 },
|
||||
"cat-extensions": { "type": "Text", "left": 152, "top": 388, "text": "Extensions", "fontColor": "#545C84", "fontSize": 14 },
|
||||
|
||||
"results-head": { "type": "Text", "left": 382, "top": 120, "text": "3 settings match “tab”", "fontColor": "#B1BBE3", "fontSize": 14 },
|
||||
|
||||
"grp-editor": { "type": "Text", "left": 382, "top": 160, "text": "EDITOR", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"card-editor": { "type": "Rectangle", "left": 382, "top": 178, "width": 648, "height": 132, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
|
||||
"r1-label": { "type": "Text", "left": 398, "top": 194, "text": "Tab width", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"r1-help": { "type": "Text", "left": 398, "top": 218, "text": "Columns per indent level.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"r1-number": { "type": "Rectangle", "left": 934, "top": 194, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"r1-number-text": { "type": "Text", "left": 956, "top": 199, "text": "2", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"r1-scope-body": { "type": "Rectangle", "left": 1004, "top": 198, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||
"r1-scope-tab": { "type": "Rectangle", "left": 1004, "top": 195, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||
|
||||
"r2-label": { "type": "Text", "left": 398, "top": 260, "text": "Indent style", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"r2-help": { "type": "Text", "left": 398, "top": 284, "text": "Spaces or tabs for new indentation.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"r2-select": { "type": "Rectangle", "left": 870, "top": 260, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"r2-select-text": { "type": "Text", "left": 882, "top": 265, "text": "Spaces", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"r2-select-chevron": { "type": "Text", "left": 974, "top": 265, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"r2-scope-circle": { "type": "Ellipse", "left": 1004, "top": 264, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"r2-scope-eq": { "type": "Rectangle", "left": 1004, "top": 270, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"r2-scope-mer": { "type": "Rectangle", "left": 1010, "top": 264, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
|
||||
"grp-appearance": { "type": "Text", "left": 382, "top": 330, "text": "APPEARANCE", "fontColor": "#78809C", "fontSize": 12 },
|
||||
"card-appearance": { "type": "Rectangle", "left": 382, "top": 348, "width": 648, "height": 66, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||
"r3-label": { "type": "Text", "left": 398, "top": 364, "text": "Workspace tab density", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||
"r3-help": { "type": "Text", "left": 398, "top": 388, "text": "Spacing of the workspace tab strip.", "fontColor": "#78809C", "fontSize": 13 },
|
||||
"r3-select": { "type": "Rectangle", "left": 870, "top": 364, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||
"r3-select-text": { "type": "Text", "left": 882, "top": 369, "text": "Compact", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||
"r3-select-chevron": { "type": "Text", "left": 974, "top": 369, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||
"r3-scope-circle": { "type": "Ellipse", "left": 1004, "top": 368, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||
"r3-scope-eq": { "type": "Rectangle", "left": 1004, "top": 374, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
"r3-scope-mer": { "type": "Rectangle", "left": 1010, "top": 368, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||
|
||||
"footer-note": { "type": "Text", "left": 382, "top": 444, "text": "Results span every category — edit a field inline, or open its category for the full list.", "fontColor": "#545C84", "fontSize": 12 }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -73,7 +73,7 @@ You might also want, project-permitting:
|
||||
- [D-29: Pre-push gate — fast layer only](decisions/testing.md#d-29-pre-push-gate--fast-layer-only) — _testing_
|
||||
- [D-30: Tests are client-side only](decisions/testing.md#d-30-tests-are-client-side-only) — _testing_
|
||||
- [D-31: Prefer-zero-deps, exact-pin](decisions/tooling.md#d-31-prefer-zero-deps-exact-pin) — _tooling_
|
||||
- [D-32: CI — Gitea primary, Linux-only runners, not yet activated](decisions/tooling.md#d-32-ci--gitea-primary-linux-only-runners-not-yet-activated) — _tooling_
|
||||
- [D-32: CI — GitHub Actions, Linux + Windows runners, active](decisions/tooling.md#d-32-ci--github-actions-linux--windows-runners-active) — _tooling_
|
||||
- [D-33: Golden-output ignore pattern — `coverage.*` excludes output, not scripts](decisions/tooling.md#d-33-golden-output-ignore-pattern--coverage-excludes-output-not-scripts) — _tooling_
|
||||
- [D-34: Q&D record system](decisions/process.md#d-34-qd-record-system) — _process_
|
||||
- [D-35: Kanban / waterfall, not Scrum](decisions/process.md#d-35-kanban--waterfall-not-scrum) — _process_
|
||||
@@ -141,6 +141,8 @@ You might also want, project-permitting:
|
||||
- [D-97: ssh:// workspace URI + system-ssh auth](decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth) — _architecture_
|
||||
- [D-98: Remote-tool contract + connect preflight](decisions/architecture.md#d-98-remote-tool-contract--connect-preflight) — _architecture_
|
||||
- [D-99: Remote session identity keyed on (host, workspace)](decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace) — _architecture_
|
||||
- [D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling](decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling) — _tooling_
|
||||
- [D-101: ClideSettings — one live-preferences access facade](decisions/architecture.md#d-101-clidesettings--one-live-preferences-access-facade) — _architecture_
|
||||
|
||||
## Open questions
|
||||
|
||||
@@ -182,7 +184,7 @@ You might also want, project-permitting:
|
||||
- [Q-47: Live mixed documents — implement?](questions/design.md#q-47-live-mixed-documents--implement) — _design_
|
||||
- [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_
|
||||
- [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
|
||||
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
|
||||
- [Q-51: Unify workspace lifecycle on a single fenced open primitive](questions/architecture.md#q-51-unify-workspace-lifecycle-on-a-single-fenced-open-primitive) — _architecture_
|
||||
|
||||
## Resolved questions
|
||||
|
||||
@@ -196,6 +198,7 @@ You might also want, project-permitting:
|
||||
- [Q-28: Terminal strip scope — shell only or logs/errors/tests](questions/architecture.md#q-28-terminal-strip-scope--shell-only-or-logserrorstests) — _architecture_
|
||||
- [Q-32: MCP tool surface — minimum slash-ide or extended clide tools?](questions/architecture.md#q-32-mcp-tool-surface--minimum-slash-ide-or-extended-clide-tools) — _architecture_
|
||||
- [Q-33: MCP transport — SSE, WebSocket, stdio, or all?](questions/architecture.md#q-33-mcp-transport--sse-websocket-stdio-or-all) — _architecture_
|
||||
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
|
||||
|
||||
## Rejected
|
||||
|
||||
|
||||
@@ -534,4 +534,12 @@ Core, rendering, IPC, kernel, panel manager.
|
||||
- **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer), [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec), [D-97](#d-97-ssh-workspace-uri--system-ssh-auth). Implemented across T-332 (identity carrier) and T-333 (session re-key).
|
||||
- **Raised by:** 2026-06-12 — T-330 spike artifacts ("session identity keyed on (host, repo) amending D-41/D-77").
|
||||
|
||||
### D-101: ClideSettings — one live-preferences access facade
|
||||
- **Date:** 2026-06-17
|
||||
- **Decision:** Live, user-selectable preferences are read through a single widget-facing facade, `ClideSettings`, namespaced by concern: `ClideSettings.fonts.monoOf(context)` / `.fonts.uiOf(context)`, `ClideSettings.theme.of(context)`, `ClideSettings.i18n.of(context)`. Values originate in the kernel `SettingsStore`; the app root (`root_shell`) resolves them and provides a `ClideSettingsScope` InheritedWidget (carrying the font families), rebuilding it on a settings change so dependents re-read live. Theme and i18n **delegate** to their existing live providers (`ClideTheme` / the `I18n` service) rather than being duplicated — one source of truth. Reads outside a scope fall back to the bundled font defaults, so a widget renders without a provider (isolated tests). "Plumb once, use many."
|
||||
- **Rationale:** Before this, each live setting had its own ad-hoc read path — theme via `ClideTheme.of`, i18n via `ClideKernel.of(context).i18n`, fonts as a compile-time `const` that couldn't change at runtime at all. A new preference meant inventing another path. One facade gives every current and future preference a uniform, discoverable read site and a single root resolution point — without a mutable global (the shortcut rejected during the T-471 font-flow design) and without a big-bang rewrite of the established theme/i18n providers (they delegate, so their consumers migrate incrementally).
|
||||
- **Cost:** Two read paths coexist during migration — `ClideTheme.of` / `i18n.string` still work (the facade delegates to them), so their hundreds of call sites move to `ClideSettings.theme` / `.i18n` incrementally rather than at once. The font consts (`clideMonoFamily` / `clideUiFamily`) remain as the facade's defaults; ~11 context-less helper sites still read the const directly, migrated in a follow-up (T-472). The facade lives in `widgets` and reaches into `kernel` for the theme/i18n delegates (a dependency already present).
|
||||
- **Cross-reference:** Fonts landed it: T-460 (Inter default + UI picker) and T-471 (mono picker) migrated ~93 sites onto `ClideSettings.fonts`. Consumer migration of theme + i18n onto the facade, and the context-less font stragglers (T-472), are staged follow-ups. Values live in the kernel `SettingsStore`.
|
||||
- **Raised by:** 2026-06-17 — user, during T-471 font-flow design: "I do see reason in nesting them all in one settings object that dynamically loads so we can extend it in the future… plumb once, use many."
|
||||
|
||||
---
|
||||
|
||||
@@ -31,6 +31,7 @@ Q&D record system itself, kanban, commit conventions, changelog.
|
||||
- **Rationale:** Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild does not. Imperative mood reads better for a project-governance log; types are noise when every commit is scoped to a subsystem already.
|
||||
- **Cost:** Contributors with Conventional Commits muscle memory adjust.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
- **Amendment (2026-06-17):** Reversed — the rebuild **does** use [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) after all. Format is `type(scope): imperative subject`, with the standard type set (`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`); `scope` is the subsystem (`settings`, `vim`, `pty`, `plan`, …); append `!` after the scope for a breaking change; keep a trailing `(T-NNN)` ticket ref where one applies. Subject (prefix included) stays ≤ 72 chars; the no-emoji, body, HEREDOC, and attribution-trailer rules from the original decision are unchanged. Practice had already drifted to this form (`feat(settings): category rail + navigation (T-447)`); the decision now matches it. `.claude/skills/git-commit/SKILL.md`, `CONTRIBUTING.md`, and `POLICY.md` updated to suit. **Why the reversal:** the original "types are noise" call didn't hold up — scoped types make `git log` skimmable and the changelog subsection (Added/Fixed/…) maps cleanly onto the commit type.
|
||||
|
||||
### D-38: Changelog discipline — Keep a Changelog 1.1.0
|
||||
- **Date:** 2026-04-21
|
||||
|
||||
@@ -11,11 +11,12 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
|
||||
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
|
||||
|
||||
### D-32: CI — Gitea primary, Linux-only runners, not yet activated
|
||||
- **Date:** 2026-04-21
|
||||
- **Decision:** CI config lives at `.gitea/workflows/test.yml` (Gitea Actions consumes GitHub-Actions syntax). Runners are Linux only; macOS is tested locally. The workflow is ready but Gitea Actions is not yet activated on the instance — the file is a staged pipeline for review. If the repo moves to GitHub, the file copies to `.github/workflows/test.yml` verbatim.
|
||||
- **Rationale:** We want the CI story defined before we turn CI on — lower blast radius on early red builds. GitHub portability is free because the syntax is shared.
|
||||
- **Cost:** PRs don't run CI yet; `make push-check` is the gate until activation.
|
||||
### D-32: CI — GitHub Actions, Linux + Windows runners, active
|
||||
- **Date:** 2026-04-21 (amended 2026-06-15)
|
||||
- **Decision:** CI runs on **GitHub Actions** under `.github/workflows/`: `test.yml` (Linux — analyze + format + unit/widget/golden + coverage gate, with `pql` installed and `pql.db` rebuilt from the changelog), `windows.yml` + `windows-soak.yml` (ConPTY tests + the orphan-leak soak), and `release.yml` (version-tagged builds, the CHANGELOG section as release notes). macOS is tested locally (no macOS runner). The web-WASM Playwright e2e job is withheld pending the `dart:ffi` fence (D-100 / Q-50). Every job goes through `make` targets.
|
||||
- **Rationale:** The repo moved to GitHub (origin `postmeridiem/clide`); GitHub Actions consumes the same workflow syntax the staged Gitea pipeline used, so the move was near-verbatim. Defining the CI story before flipping it on kept early red builds low-blast-radius.
|
||||
- **Cost:** macOS coverage is local-only; the browser/e2e surface stays dark until D-100's fence lands.
|
||||
- **Amended (2026-06-15):** Superseded the original "Gitea primary, not yet activated" posture — the Gitea staging pipeline was never activated and is gone (only `legacy/.gitea/`, the frozen Python tree, remains); CI is live on GitHub Actions (commits `8e0f33b` Linux, `45aa2d9` Windows/release). Reconciled while closing out T-384.
|
||||
- **Raised by:** 2026-04-21 planning.
|
||||
|
||||
### D-42: Dependencies documented in `licenses.yaml`
|
||||
@@ -101,3 +102,11 @@ Toolchain, supply chain, CI, ignore strategy.
|
||||
- **Raised by:** 2026-06-11 — user: "pql updates live outside this repo and people have to first update the pql binaries and install them."
|
||||
|
||||
---
|
||||
|
||||
### D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling
|
||||
- **Date:** 2026-06-15
|
||||
- **Decision:** Resolve [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) by **fencing** (option a): every module that imports `dart:ffi` — the native PTY (`lib/src/pty/`), tree-sitter (`lib/kernel/src/syntax/`), the Lua host (`lib/lua/`), the Windows watchdog, and the FFI `libc` shim — moves behind a conditional-import facade: `import 'x_native.dart' if (dart.library.ffi) 'x_native.dart' ... else 'x_stub.dart'` (io/ffi → native impl; web → a stub). Web stubs degrade gracefully (no PTY/terminal, no native git, no tree-sitter highlighting — the web target is a UI/e2e surface, not a functional desktop replacement) and never throw at import time. A `flutter build web --wasm` compile step is added to CI so the fence can't silently rot.
|
||||
- **Rationale:** Keep the web/WASM build a live "happy accident" — a hopeful future target the maintainer wants to reach eventually — rather than letting the dart:ffi pivot quietly amputate it (option c, drop) or leaving it dark and rotting (option b, park). Crucially this does **not** compromise desktop fidelity (the CLAUDE.md guardrail): stubs exist only on the web target; the desktop build keeps the real FFI impls unchanged. Fencing is reversible and additive; dropping the target is effectively one-way.
|
||||
- **Cost:** An ongoing tax — every new native binding needs a web stub + conditional import, and the wasm compile gate must stay green. Accepted deliberately: the maintainer values keeping the door open over avoiding that tax. Functional web parity is explicitly **not** promised — only that the tree compiles to wasm and the Playwright/e2e harness ([D-26](process.md)) can run again.
|
||||
- **Cross-reference:** [Q-50](../questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop), [D-32](#d-32-ci--github-actions-linux--windows-runners-active) (the withheld web-WASM e2e job lands once this fence is implemented), the tree-sitter FFI pivot.
|
||||
- **Raised by:** 2026-06-15 — user, reconciling T-384: "a happy accident for the web-based UI lives a bit more hopeful for me than it does in CLAUDE.md … let's fence dart:ffi with web stubs."
|
||||
|
||||
@@ -160,9 +160,16 @@ ticket persistence.
|
||||
- **Source:** 2026-06-09 — split out of T-132 / T-158 (was "blocked on upstream"); project memory `claude-usage-budget-not-exposed`, GitHub anthropics/claude-code#44328.
|
||||
|
||||
### Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?
|
||||
- **Status:** Open
|
||||
- **Status:** Resolved → [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling)
|
||||
- **Resolved (2026-06-15):** **Fence** (option a). Every `dart:ffi` importer moves behind a conditional-import facade with a web stub, and a `flutter build web --wasm` compile gate is added so it can't rot. The maintainer wants the web/WASM build kept alive as a hopeful future target; desktop fidelity is untouched (stubs exist only on the web target). Functional web parity is explicitly not promised — only that the tree compiles to wasm and the e2e/Playwright harness can run again. Implementation tracked in T-438. See [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling).
|
||||
- **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish.
|
||||
- **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked.
|
||||
- **Source:** T-384 / 2026-06-11 Fable review (epic T-359).
|
||||
|
||||
### Q-51: Unify workspace lifecycle on a single fenced open primitive
|
||||
- **Status:** Open
|
||||
- **Question:** There is no single "open workspace X" primitive — only two half-primitives in different layers. `project.open(root)` (`lib/kernel/src/project.dart`) is the only repo-targeting path and is intrinsically *in-place*: it rebuilds services in the same process, reusing the shared `daemonBus`. `newWindow()` (`lib/builtin/menubar/src/file_actions.dart`) spawns a blank detached `Process.start` with no repo argument and no env scrubbing. To open a repo in a *new* window you must spawn a blank window and then run the in-place switch inside it. Should both fold behind one `WorkspaceService.open(root, {target: thisWindow | newWindow})` that is the *sole* deriver of IPC identity from a root — and, more fundamentally, should in-place switching survive at all, or should `workspace ⇒ window ⇒ process ⇒ socket ⇒ bus ⇒ session-id` be strictly one-to-one so the leak/bleed class becomes structurally impossible?
|
||||
- **Context:** Surfaced 2026-06-14 from [T-421](../../) (status-bar branch bleeds across parallel windows). The same root cause — scattered, per-entry-point workspace lifecycle with no single fencing owner — already produced T-367 (in-place switch leaked the entire previous service set) and T-269 (kept the previous repo's Claude session). If in-place switching is abolished, the teardown burden those tickets patch disappears entirely. Relevant decisions: [D-70](../decisions/architecture.md) (per-workspace socket path), [D-56](../decisions/architecture.md) (one server per workspace), [D-72](../decisions/architecture.md) (multi-connection serial dispatch).
|
||||
- **Source:** T-421 / 2026-06-14 user review.
|
||||
|
||||
---
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
/// This file is pure (no Flutter): it turns a flat [ConversationItem] list
|
||||
/// into a list of [RenderGroup]s — each either a first-class [StickyItem] or
|
||||
/// a foldable [FoldedCluster]. The widget layer renders sticky items as
|
||||
/// before and clusters as one [activity card]. Kept separate + unit-tested
|
||||
/// before and clusters as one `activity card`. Kept separate + unit-tested
|
||||
/// because the fold rules are the load-bearing part.
|
||||
library;
|
||||
|
||||
@@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
|
||||
// breaks the cluster at every level, including L3, so parallel agents
|
||||
// never merge into one Activity card.
|
||||
if (isAgentTool(name)) return false;
|
||||
// A Workflow run is a first-class orchestration card too (T-416): it owns
|
||||
// the live agent fan-out, so it never folds into a generic Activity card.
|
||||
if (name == 'Workflow') return false;
|
||||
// The Edit/Write call stays first-class with its diff at L1/L2.
|
||||
if (level == FoldLevel.everything) return true;
|
||||
return !isDiffTool(name);
|
||||
|
||||
@@ -21,11 +21,15 @@
|
||||
/// IO wrapper the orchestrator calls. Flutter-free by design.
|
||||
library;
|
||||
|
||||
import 'dart:ffi' show Abi;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
|
||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
||||
|
||||
// Web fence (T-438, D-100): `Abi.current()` (dart:ffi) is desktop-only; the web
|
||||
// build gets a default dir name with no FFI introspection.
|
||||
import 'native_abi_stub.dart' if (dart.library.ffi) 'native_abi_io.dart';
|
||||
|
||||
/// The `--allowedTools` rule that pre-approves `clide …` Bash calls for a
|
||||
/// hosted session (T-217), so the agent isn't prompted on every IDE call.
|
||||
/// Claude Code's settings/flag syntax for a command-scoped Bash rule is
|
||||
@@ -90,26 +94,6 @@ String? resolveClideCliDir({required String? currentPath, required List<String>
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The `native/<os>-<arch>/` directory name the Makefile builds the C client
|
||||
/// into (e.g. `linux-x64`, `macos-arm64`) — used to find the dev-tree binary
|
||||
/// when clide runs un-installed (dogfooding clide-on-clide).
|
||||
String nativeClideDirName({Abi? abi}) {
|
||||
switch (abi ?? Abi.current()) {
|
||||
case Abi.macosArm64:
|
||||
return 'macos-arm64';
|
||||
case Abi.macosX64:
|
||||
return 'macos-x64';
|
||||
case Abi.linuxArm64:
|
||||
return 'linux-arm64';
|
||||
case Abi.linuxX64:
|
||||
return 'linux-x64';
|
||||
default:
|
||||
// Windows / other — clide is desktop linux/macos today; fall back to a
|
||||
// best-effort name so the probe simply misses rather than throwing.
|
||||
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of [agentBootstrap]: the env delta to overlay and the extra
|
||||
/// spawn args (context note + allow rule) to prepend to a session's argv.
|
||||
class AgentBootstrap {
|
||||
@@ -125,10 +109,13 @@ class AgentBootstrap {
|
||||
/// orchestrator merges both into one `--append-system-prompt`.
|
||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
|
||||
final home = Platform.environment['HOME'];
|
||||
final currentPath = (base ?? Platform.environment)['PATH'] ?? Platform.environment['PATH'];
|
||||
// The login-shell-resolved PATH (T-439) so a hosted claude — and the tools it
|
||||
// shells out to — find user-installed components on a desktop launch, not just
|
||||
// the sparse GUI PATH. agentEnvDelta still prepends the clide-CLI dir.
|
||||
final currentPath = resolvedToolPath();
|
||||
final candidates = <String>[
|
||||
if (home != null && home.isNotEmpty) '$home/.local/bin',
|
||||
'$workspaceRoot/native/${nativeClideDirName()}',
|
||||
'$workspaceRoot/native/${currentNativeDirName()}',
|
||||
File(Platform.resolvedExecutable).parent.path,
|
||||
];
|
||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||
|
||||
@@ -37,10 +37,13 @@ class ClaudeBanner extends StatelessWidget {
|
||||
const SizedBox(height: 18),
|
||||
ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
|
||||
const SizedBox(height: 2),
|
||||
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
|
||||
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(height: 16),
|
||||
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
|
||||
if (statusLine != null) ...[const SizedBox(height: 2), ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily)],
|
||||
if (statusLine != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true),
|
||||
],
|
||||
|
||||
@@ -16,7 +16,6 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/permission_mode_control.dart';
|
||||
import 'package:clide/builtin/claude/src/running_indicator.dart';
|
||||
import 'package:clide/builtin/claude/src/slash_commands.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -415,7 +414,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideTheme.of(context).surface;
|
||||
final theme = ClideSettings.theme.of(context).surface;
|
||||
final hasText = _controller.text.isNotEmpty;
|
||||
final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted;
|
||||
|
||||
|
||||
@@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
|
||||
/// Returns a change stream for [dir] (fires on any file event under it).
|
||||
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
|
||||
|
||||
/// Modest version-agnostic fallback used when the probe is unavailable, so
|
||||
/// the typeahead still offers the common built-ins.
|
||||
const List<String> kFallbackSlashCommands = [
|
||||
'add-dir',
|
||||
'agents',
|
||||
'clear',
|
||||
'compact',
|
||||
'config',
|
||||
'context',
|
||||
'cost',
|
||||
'doctor',
|
||||
'exit',
|
||||
'help',
|
||||
'init',
|
||||
'mcp',
|
||||
'memory',
|
||||
'model',
|
||||
'permissions',
|
||||
'resume',
|
||||
'review',
|
||||
'status',
|
||||
'usage',
|
||||
];
|
||||
/// Fallback used when the probe is unavailable. Mirrors the builtins a real
|
||||
/// CLI advertises in its stream-json `initialize` handshake (probed against
|
||||
/// 2.1.175) — i.e. the ones that genuinely work headless. It deliberately
|
||||
/// does NOT list TUI-only commands (config, permissions, status, doctor, …):
|
||||
/// this list doubles as the router's "advertised" set (T-411), and a TUI-only
|
||||
/// token here would be forwarded to the CLI and error. The composer unions
|
||||
/// [kClideOwnedCommands] on top for the typeahead (T-162).
|
||||
const List<String> kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage'];
|
||||
|
||||
class ClaudeConfig extends ChangeNotifier {
|
||||
ClaudeConfig({
|
||||
@@ -272,6 +257,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
if (probe == null) return; // stay on the static fallback
|
||||
_probe = probe;
|
||||
await _writeProbeCache(probe);
|
||||
if (_disposed) return; // a slow probe racing a teardown mustn't notify a disposed notifier
|
||||
notifyListeners();
|
||||
} finally {
|
||||
_probing = false;
|
||||
@@ -283,6 +269,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
/// not re-resolved (the binary doesn't change under us at runtime).
|
||||
Future<void> refresh() async {
|
||||
await _loadDiskConfig();
|
||||
if (_disposed) return; // a watcher-driven refresh racing a teardown mustn't notify a disposed notifier
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -293,6 +280,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
_stopWatching();
|
||||
_projectDir = dir;
|
||||
await _loadDiskConfig();
|
||||
if (_disposed) return; // a project switch racing a teardown mustn't notify a disposed notifier
|
||||
_startWatchers();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -36,8 +36,10 @@ import 'package:clide/builtin/claude/src/meta_sidebar/tab_strip.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/team_tab.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, parseUsageText;
|
||||
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show AssistantTextMessage, ConversationItem, SessionStatus;
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart' show WorkflowRun;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -83,7 +85,12 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
StreamSubscription<TeamMemberJoined>? _joinSub;
|
||||
StreamSubscription<TeamMemberLeft>? _leftSub;
|
||||
StreamSubscription<Message>? _statusSub;
|
||||
StreamSubscription<Message>? _tabSub;
|
||||
StreamSubscription<SessionStatus>? _primarySub;
|
||||
StreamSubscription<ConversationItem>? _primaryItemsSub;
|
||||
StreamSubscription<Map<String, WorkflowRun>>? _primaryWorkflowsSub;
|
||||
ClaudeUsage? _usage;
|
||||
Map<String, WorkflowRun> _workflows = const {};
|
||||
StreamSubscription<void>? _brokerChangeSub;
|
||||
Timer? _timer;
|
||||
late final Future<ClaudeStats> Function() _load;
|
||||
@@ -163,6 +170,13 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
_memberStatus.remove(m.agentId);
|
||||
});
|
||||
});
|
||||
// Slash-command navigation (T-413): /status, /config, /mcp, … publish a
|
||||
// meta.tab message; switch the sub-tab to match.
|
||||
_tabSub = kernel.messages.subscribe(publisher: 'builtin.claude', channel: 'meta.tab').listen((msg) {
|
||||
final name = msg.data['tab'] as String?;
|
||||
final tab = SidebarTab.values.where((t) => t.name == name).firstOrNull;
|
||||
if (tab != null && mounted) setState(() => _tab = tab);
|
||||
});
|
||||
// Live per-member status forwarded by the observer (T-157).
|
||||
_statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) {
|
||||
final agentId = msg.data['agentId'] as String?;
|
||||
@@ -191,15 +205,42 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
final session = _orchestrator?.byId('primary')?.session;
|
||||
_primarySub?.cancel();
|
||||
_primarySub = null;
|
||||
_primaryItemsSub?.cancel();
|
||||
_primaryItemsSub = null;
|
||||
_primaryWorkflowsSub?.cancel();
|
||||
_primaryWorkflowsSub = null;
|
||||
if (session == null) {
|
||||
if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null);
|
||||
if (mounted && (_primaryStatus != null || _workflows.isNotEmpty)) {
|
||||
setState(() {
|
||||
_primaryStatus = null;
|
||||
_workflows = const {};
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
final seed = session.status;
|
||||
if (mounted) setState(() => _primaryStatus = seed);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_primaryStatus = seed;
|
||||
_workflows = session.workflows;
|
||||
});
|
||||
}
|
||||
_primarySub = session.statusStream.listen((s) {
|
||||
if (mounted) setState(() => _primaryStatus = s);
|
||||
});
|
||||
// The Activity tab's WORKFLOWS section tracks the primary session's live
|
||||
// workflow runs (T-416).
|
||||
_primaryWorkflowsSub = session.workflowsStream.listen((w) {
|
||||
if (mounted) setState(() => _workflows = w);
|
||||
});
|
||||
// Watch for /usage responses: CLI-local output arrives as synthetic
|
||||
// assistant text; when it parses as usage, the Activity block updates
|
||||
// (T-415). Driven by the refresh control publishing '/usage'.
|
||||
_primaryItemsSub = session.items.listen((item) {
|
||||
if (item is! AssistantTextMessage || !item.synthetic) return;
|
||||
final parsed = parseUsageText(item.text);
|
||||
if (parsed != null && mounted) setState(() => _usage = parsed);
|
||||
});
|
||||
}
|
||||
|
||||
void _onConfigChange() {
|
||||
@@ -247,7 +288,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
_joinSub?.cancel();
|
||||
_leftSub?.cancel();
|
||||
_statusSub?.cancel();
|
||||
_tabSub?.cancel();
|
||||
_primarySub?.cancel();
|
||||
_primaryItemsSub?.cancel();
|
||||
_primaryWorkflowsSub?.cancel();
|
||||
_brokerChangeSub?.cancel();
|
||||
_injectCtl.dispose();
|
||||
_config?.removeListener(_onConfigChange);
|
||||
@@ -263,7 +307,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)),
|
||||
Expanded(
|
||||
child: switch (_tab) {
|
||||
SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config),
|
||||
SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage, workflows: _workflows),
|
||||
SidebarTab.team => TeamTabView(
|
||||
members: _members,
|
||||
memberStatus: _memberStatus,
|
||||
@@ -303,6 +347,8 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
||||
),
|
||||
SidebarTab.config => ConfigTabView(
|
||||
config: _config,
|
||||
status: _primaryStatus,
|
||||
models: _orchestrator?.byId('primary')?.session.availableModels,
|
||||
expanded: _expanded,
|
||||
onToggleSection: (section) => setState(() {
|
||||
if (_expanded.contains(section)) {
|
||||
|
||||
@@ -14,7 +14,9 @@ import 'clipboard_paste.dart';
|
||||
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
||||
import 'conversation_controller.dart';
|
||||
import 'conversation_view.dart';
|
||||
import 'model_picker_card.dart';
|
||||
import 'permission_mode_control.dart';
|
||||
import 'session_defaults.dart';
|
||||
import 'prompt_card.dart';
|
||||
import 'session_index.dart';
|
||||
import 'session_naming.dart';
|
||||
@@ -24,6 +26,7 @@ import 'slash_commands.dart';
|
||||
import 'stream_json_session.dart';
|
||||
import 'task_list.dart';
|
||||
import 'transcript_reader.dart';
|
||||
import 'workflow_run.dart';
|
||||
|
||||
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
||||
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
|
||||
@@ -73,6 +76,9 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
StreamSubscription<SessionStatus>? _statusSub;
|
||||
StreamSubscription<SessionEnd>? _endSub;
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
StreamSubscription<Message>? _commandSub;
|
||||
StreamSubscription<String>? _modelErrorSub;
|
||||
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
|
||||
ConversationController? _conversation;
|
||||
StreamJsonSession? _session;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
@@ -85,6 +91,17 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
/// /resume, and respawns operate on this pane's own session (T-375).
|
||||
late String? _forkSource = widget.forkSourceId;
|
||||
|
||||
/// Whether a bare `/model` opened the picker in the interaction zone
|
||||
/// (T-408). An open prompt takes precedence; the picker shows once it
|
||||
/// resolves.
|
||||
bool _modelPickerOpen = false;
|
||||
bool _effortPickerOpen = false;
|
||||
bool _permissionPickerOpen = false;
|
||||
|
||||
/// Effort level this pane's session runs at (`--effort`, T-412). Null =
|
||||
/// the CLI default. Set by /effort; carried by every respawn.
|
||||
String? _effort;
|
||||
|
||||
bool _spawned = false;
|
||||
|
||||
/// Per-session composer draft (text + caret), held here so an unsent
|
||||
@@ -115,12 +132,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
final seg = statusSegmentsAroundMode(_status);
|
||||
final mode = _status.permissionMode;
|
||||
|
||||
Widget text(String t) => ClideText(t, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusBarForeground, maxLines: 1);
|
||||
Widget text(String t) =>
|
||||
ClideText(t, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.statusBarForeground, maxLines: 1);
|
||||
|
||||
final children = <Widget>[];
|
||||
void add(Widget w) {
|
||||
if (children.isNotEmpty) {
|
||||
children.add(ClideText(' · ', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.globalTextMuted, maxLines: 1));
|
||||
children.add(ClideText(' · ', fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted, maxLines: 1));
|
||||
}
|
||||
children.add(w);
|
||||
}
|
||||
@@ -164,6 +182,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// GlobalKey and spawns once, so without this it would keep the previous
|
||||
// repo's session after a switch (T-269).
|
||||
_projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
|
||||
// Sidebar controls (and any future surface) drive this pane's session by
|
||||
// publishing slash-command text on builtin.claude/command (T-414) —
|
||||
// executed through the exact _send routing the composer uses, so the
|
||||
// control and the typed command are one code path (D-6). Only the
|
||||
// primary pane listens: the controls target the primary session, and a
|
||||
// second listener would double-execute.
|
||||
if (widget.isPrimary) {
|
||||
_commandSub = ClideKernel.of(context).messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen((msg) {
|
||||
final text = msg.data['text'] as String?;
|
||||
if (text != null && text.isNotEmpty) _send(text);
|
||||
});
|
||||
}
|
||||
// Re-fold the conversation when the activity fold-level setting changes
|
||||
// (claude.activity.fold-level command, T-235).
|
||||
ClideKernel.of(context).settings.addListener(_onSettingsChanged);
|
||||
@@ -179,11 +209,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
activeClaudeConfig?.removeListener(_onConfigChanged);
|
||||
_kernel?.settings.removeListener(_onSettingsChanged);
|
||||
_projectSub?.cancel();
|
||||
_commandSub?.cancel();
|
||||
_projectSub = null;
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
// The orchestrator owns the session, so disposing this pane does NOT kill
|
||||
// it — that's what lets a hidden/kept-alive pane keep its session (T-169).
|
||||
// A secondary tab being *closed* is a real teardown, so close its session;
|
||||
@@ -237,6 +272,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
_modelPickerOpen = false;
|
||||
_effortPickerOpen = false;
|
||||
_permissionPickerOpen = false;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
|
||||
_conversation = null;
|
||||
_session = null;
|
||||
@@ -254,6 +296,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
// New sessions inherit the user's Claude defaults (T-457): effort flows
|
||||
// through --effort below; model + permission mode are applied post-spawn.
|
||||
final settings = ClideKernel.of(context).settings;
|
||||
_effort ??= defaultEffortFlag(settings);
|
||||
var isNewSession = false;
|
||||
final ipc = _ipc();
|
||||
if (ipc == null || !ipc.isConnected) {
|
||||
setState(() => _error = 'Daemon not connected.');
|
||||
@@ -284,7 +331,14 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_sessionId ??= freshSessionId();
|
||||
try {
|
||||
managed = await orch.spawn(
|
||||
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource),
|
||||
SpawnSpec(
|
||||
id: _orchId,
|
||||
role: 'fork ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
forkSourceSessionId: forkSource,
|
||||
effort: _effort,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
||||
@@ -315,6 +369,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
cwd: repoRoot,
|
||||
resume: resume,
|
||||
transcriptPath: resume ? transcriptFile : null,
|
||||
effort: _effort,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -322,11 +377,19 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
isNewSession = !resume;
|
||||
setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId');
|
||||
}
|
||||
|
||||
_session = managed.session;
|
||||
_conversation = managed.conversation;
|
||||
// A brand-new session (not a resume or fork) starts on the user's default
|
||||
// model + permission mode (T-457). Sent as control requests; the CLI
|
||||
// applies them after init. 'default'/unset values are no-ops.
|
||||
if (isNewSession) applySessionDefaults(managed.session, settings);
|
||||
// The wire never reports effort — record what this session was spawned
|
||||
// with so the status line / sidebar can show it (T-412).
|
||||
if (_effort != null) managed.session.noteEffort(_effort!);
|
||||
// Diagnostic (T-274 follow-up): record how this pane bound its session —
|
||||
// a fresh spawn vs connecting to existing on-disk history (the seed read
|
||||
// from the transcript/sidecar). Surfaces the resume path in `make run`.
|
||||
@@ -340,6 +403,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = s);
|
||||
});
|
||||
// Workflow runs arrive on out-of-band system events that add no
|
||||
// conversation item, so the view won't rebuild on its own — drive a
|
||||
// rebuild as the run map changes so the workflow card updates live (T-416).
|
||||
_workflowsSub = managed.session.workflowsStream.listen((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
});
|
||||
// A rejected /model change (unknown name) rolls back silently in the
|
||||
// status — say why out loud (T-408).
|
||||
_modelErrorSub = managed.session.modelErrors.listen((msg) {
|
||||
_kernel?.notify.warn(msg, title: 'model');
|
||||
});
|
||||
// Surface a dead process instead of letting it look thoughtful (T-361):
|
||||
// late binders read the replayed end; live sessions stream it.
|
||||
final alreadyEnded = managed.session.end;
|
||||
@@ -357,7 +432,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (!mounted) return;
|
||||
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
|
||||
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
|
||||
setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart');
|
||||
// Surface the CLI's own reason (e.g. "Session ID … is already in use")
|
||||
// instead of an opaque "code 1" (T-437).
|
||||
final why = end.reason.isEmpty ? '' : ' — ${end.reason}';
|
||||
setState(() => _statusLine = 'claude exited (code ${end.exitCode})$why · /clear to restart');
|
||||
}
|
||||
|
||||
// Send composed text to Claude over the stream-json channel. Commands clide
|
||||
@@ -377,10 +455,152 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
case 'fork':
|
||||
_forkSession();
|
||||
return;
|
||||
case 'model':
|
||||
_modelCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'effort':
|
||||
_effortCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'permissions':
|
||||
_permissionsCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'status':
|
||||
_openMetaTab('activity');
|
||||
return;
|
||||
case 'config':
|
||||
case 'mcp':
|
||||
case 'agents':
|
||||
case 'hooks':
|
||||
_openMetaTab('config');
|
||||
return;
|
||||
case 'memory':
|
||||
_openMemory();
|
||||
return;
|
||||
case 'help':
|
||||
_helpCommand();
|
||||
return;
|
||||
}
|
||||
// Route the rest (T-411): a known TUI-only builtin never reaches the
|
||||
// session — forwarded it would error (or, un-advertised, bracket-paste to
|
||||
// the model as literal text, burning a turn). It becomes a local notice
|
||||
// card pointing at the clide-native way instead.
|
||||
final advertised = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands;
|
||||
if (routeSlashCommand(text, advertised: advertised) == SlashRoute.unavailable) {
|
||||
_session?.addLocalNotice(tuiOnlyNotice(slashCommandToken(text)!));
|
||||
return;
|
||||
}
|
||||
_session?.send(text);
|
||||
}
|
||||
|
||||
/// clide-owned `/model` (T-408): with an argument, set the model directly;
|
||||
/// bare, open the picker in the interaction zone (D-78).
|
||||
void _modelCommand(String arg) {
|
||||
if (_session == null) return;
|
||||
if (arg.isNotEmpty) {
|
||||
_session!.setModel(arg);
|
||||
return;
|
||||
}
|
||||
setState(() => _modelPickerOpen = true);
|
||||
}
|
||||
|
||||
void _pickModel(String value) {
|
||||
_session?.setModel(value);
|
||||
_closeModelPicker();
|
||||
}
|
||||
|
||||
void _closeModelPicker() {
|
||||
setState(() => _modelPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
/// clide-owned `/effort` (T-412): with a level, respawn-with-resume carrying
|
||||
/// `--effort`; bare, open the picker. No set_effort control subtype exists
|
||||
/// (probed 2.1.175), so the respawn IS the mechanism — resume keeps the
|
||||
/// conversation, only the process restarts.
|
||||
void _effortCommand(String arg) {
|
||||
if (_session == null) return;
|
||||
if (arg.isEmpty) {
|
||||
setState(() => _effortPickerOpen = true);
|
||||
return;
|
||||
}
|
||||
if (!kEffortLevels.any((l) => l.value == arg)) {
|
||||
_session!.addLocalNotice('unknown effort "$arg" — levels: ${kEffortLevels.map((l) => l.value).join(', ')}');
|
||||
return;
|
||||
}
|
||||
_setEffort(arg);
|
||||
}
|
||||
|
||||
void _pickEffort(String value) {
|
||||
_closeEffortPicker();
|
||||
_setEffort(value);
|
||||
}
|
||||
|
||||
void _closeEffortPicker() {
|
||||
setState(() => _effortPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
void _setEffort(String level) {
|
||||
final sid = _sessionId;
|
||||
if (sid == null) return;
|
||||
_effort = level;
|
||||
_kernel?.notify.info('effort $level — restarting the session to apply', title: 'effort');
|
||||
unawaited(_respawnWithSession(sid));
|
||||
}
|
||||
|
||||
/// clide-owned `/permissions` (T-413): with a mode, set it directly over
|
||||
/// set_permission_mode; bare, open a picker — the same interaction-zone
|
||||
/// pattern as /model and /effort.
|
||||
void _permissionsCommand(String arg) {
|
||||
final s = _session;
|
||||
if (s == null) return;
|
||||
if (arg.isEmpty) {
|
||||
setState(() => _permissionPickerOpen = true);
|
||||
return;
|
||||
}
|
||||
if (!kPermissionModes.any((m) => m.value == arg)) {
|
||||
s.addLocalNotice('unknown permission mode "$arg" — modes: ${kPermissionModes.map((m) => m.value).join(', ')}');
|
||||
return;
|
||||
}
|
||||
s.setPermissionMode(arg);
|
||||
}
|
||||
|
||||
void _pickPermissionMode(String value) {
|
||||
_closePermissionPicker();
|
||||
_session?.setPermissionMode(value);
|
||||
}
|
||||
|
||||
void _closePermissionPicker() {
|
||||
setState(() => _permissionPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
/// Navigate to the Claude sidebar and select a sub-tab (T-413): the
|
||||
/// /status//config//mcp//agents//hooks commands land here.
|
||||
void _openMetaTab(String tab) {
|
||||
final k = _kernel;
|
||||
if (k == null) return;
|
||||
k.panels.activateTab(Slots.sidebar, 'claude.meta');
|
||||
k.messages.publish('builtin.claude', 'meta.tab', {'tab': tab});
|
||||
}
|
||||
|
||||
/// clide-owned `/memory` (T-413): open the workspace CLAUDE.md in the editor.
|
||||
void _openMemory() {
|
||||
final root = _repoRoot;
|
||||
if (root == null) return;
|
||||
unawaited(_ipc()?.request('editor.open', args: {'path': '$root/CLAUDE.md'}));
|
||||
}
|
||||
|
||||
/// clide-owned `/help` (T-413): a local summary card — never the CLI's TUI
|
||||
/// help, which doesn't exist headless.
|
||||
void _helpCommand() {
|
||||
final advertised = (activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands).where((c) => !kClideOwnedCommands.contains(c)).toList()..sort();
|
||||
_session?.addLocalNotice(
|
||||
'clide commands: ${(kClideOwnedCommands.toList()..sort()).map((c) => '/$c').join(' ')}\n'
|
||||
'claude commands & skills: ${advertised.map((c) => '/$c').join(' ')}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a submitted prompt in the active session's history (T-163),
|
||||
/// de-duping immediate repeats. Empty/whitespace prompts are skipped.
|
||||
void _appendHistory(String text) {
|
||||
@@ -405,7 +625,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
/// background tap must never pull focus from (or resurrect) the composer
|
||||
/// over an open prompt.
|
||||
void _focusComposerOnTap() {
|
||||
if (_session?.pendingPrompt != null) return;
|
||||
if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen || _permissionPickerOpen) return;
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
@@ -477,6 +697,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
_modelPickerOpen = false;
|
||||
_effortPickerOpen = false;
|
||||
_permissionPickerOpen = false;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old session
|
||||
// Erase only after the process is dead, so claude isn't mid-write.
|
||||
final root = _repoRoot;
|
||||
@@ -502,7 +729,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
|
||||
final Widget body;
|
||||
if (_error != null) {
|
||||
@@ -532,6 +759,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
||||
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
||||
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
|
||||
emptyState: ClaudeBanner(
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
workspace: _repoRoot,
|
||||
@@ -548,9 +776,36 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
),
|
||||
// An open prompt takes the composer's space and hides the text
|
||||
// input until it's answered, so interaction stays out of the
|
||||
// conversation stream (D-78).
|
||||
// conversation stream (D-78). The /model picker uses the same
|
||||
// slot; a prompt outranks it (T-408).
|
||||
if (prompt != null && _session != null)
|
||||
ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt)
|
||||
else if (_modelPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
models: _session!.availableModels.isEmpty ? kFallbackModels : _session!.availableModels,
|
||||
currentModel: _status.model,
|
||||
onPick: _pickModel,
|
||||
onCancel: _closeModelPicker,
|
||||
)
|
||||
else if (_effortPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
title: 'effort',
|
||||
models: kEffortLevels,
|
||||
currentModel: _status.effort,
|
||||
// Exact match — containment would mark `high` inside `xhigh`.
|
||||
isCurrent: (o, c) => c != null && o.value == c,
|
||||
onPick: _pickEffort,
|
||||
onCancel: _closeEffortPicker,
|
||||
)
|
||||
else if (_permissionPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
title: 'permissions',
|
||||
models: kPermissionModes,
|
||||
currentModel: _status.permissionMode,
|
||||
isCurrent: (o, c) => c != null && o.value == c,
|
||||
onPick: _pickPermissionMode,
|
||||
onCancel: _closePermissionPicker,
|
||||
)
|
||||
else
|
||||
StreamBuilder<bool>(
|
||||
stream: _session?.busyStream,
|
||||
@@ -603,7 +858,13 @@ class _ModeBadge extends StatelessWidget {
|
||||
return Semantics(
|
||||
label: 'permission mode: ${permissionModeLabel(mode)}',
|
||||
excludeSemantics: true,
|
||||
child: ClideText(permissionModeLabel(mode), fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: permissionModeColor(mode, tokens), maxLines: 1),
|
||||
child: ClideText(
|
||||
permissionModeLabel(mode),
|
||||
fontSize: clideFontSmall,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: permissionModeColor(mode, tokens),
|
||||
maxLines: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ String nextSafePermissionMode(String current) {
|
||||
}
|
||||
|
||||
/// Status-line segments split around the permission-mode badge so the UI can
|
||||
/// render the mode as an interactive control between them (T-226). [leading]
|
||||
/// is the model; [trailing] joins context / cost / rate-limit. Either may be
|
||||
/// render the mode as an interactive control between them (T-226). `leading`
|
||||
/// is the model; `trailing` joins context / cost / rate-limit. Either may be
|
||||
/// null when there's nothing to show.
|
||||
({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) {
|
||||
final trailing = [
|
||||
@@ -92,3 +92,41 @@ String formatTokenCount(int n) {
|
||||
if (n >= 1000) return '${(n / 1000).round()}k';
|
||||
return '$n';
|
||||
}
|
||||
|
||||
/// Parsed `/usage` output (T-415). The CLI answers a forwarded `/usage`
|
||||
/// headless and free (probed 2.1.175, num_turns 0) with plain text:
|
||||
///
|
||||
/// Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam)
|
||||
/// Current week (all models): 53% used · resets Jun 15, 6:59pm (…)
|
||||
/// Current week (Sonnet only): 0% used
|
||||
class ClaudeUsage {
|
||||
const ClaudeUsage({this.session, this.week, this.weekSonnet});
|
||||
|
||||
/// The value text per line (e.g. `15% used · resets Jun 12, 3:39pm`),
|
||||
/// timezone parenthetical stripped. Null when the line wasn't present.
|
||||
final String? session;
|
||||
final String? week;
|
||||
final String? weekSonnet;
|
||||
|
||||
bool get isEmpty => session == null && week == null && weekSonnet == null;
|
||||
}
|
||||
|
||||
/// Parse `/usage` response text into a [ClaudeUsage], or null when [text]
|
||||
/// isn't usage output. Tolerant of label drift: any `Current …: …% used`
|
||||
/// line is matched by its key phrase.
|
||||
ClaudeUsage? parseUsageText(String text) {
|
||||
if (!text.contains('% used')) return null;
|
||||
String? valueOf(String keyPhrase) {
|
||||
for (final line in text.split('\n')) {
|
||||
if (!line.contains(keyPhrase)) continue;
|
||||
final colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
// Strip the trailing timezone parenthetical — noise at sidebar width.
|
||||
return line.substring(colon + 1).replaceAll(RegExp(r'\s*\([^)]*\)\s*$'), '').trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final usage = ClaudeUsage(session: valueOf('Current session'), week: valueOf('(all models)'), weekSonnet: valueOf('(Sonnet only)'));
|
||||
return usage.isEmpty ? null : usage;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/task_list.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -30,7 +29,7 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
|
||||
final tasks = widget.tasks;
|
||||
if (tasks.isEmpty) return const SizedBox.shrink(); // no chrome when empty
|
||||
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final done = tasks.where((t) => t.status == TaskStatus.completed).length;
|
||||
final inProgress = tasks.where((t) => t.status == TaskStatus.inProgress);
|
||||
final current = inProgress.isEmpty ? null : inProgress.first.text;
|
||||
|
||||
@@ -175,7 +175,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -241,13 +241,13 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
if (widget.collapsible) _caret(tokens),
|
||||
// Header label size matches ClideCollapserCard (clideFontCaption) so
|
||||
// neighbouring cards in the conversation stream align (T-344).
|
||||
ClideText(widget.label, fontSize: clideFontCaption, color: widget.accent, fontFamily: clideMonoFamily),
|
||||
ClideText(widget.label, fontSize: clideFontCaption, color: widget.accent, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
// While collapsed, show a one-line gist next to the label so the card
|
||||
// still says what it holds.
|
||||
if (_collapsed && summary != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1),
|
||||
child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context), maxLines: 1),
|
||||
),
|
||||
] else
|
||||
const Spacer(),
|
||||
@@ -322,7 +322,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
|
||||
],
|
||||
@@ -349,7 +349,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
||||
onTap: items[i].onTap,
|
||||
builder: (_, hovered, pressed) => Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -28,8 +28,8 @@ class ConversationController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Build a controller fed from the kernel [MessageBus] — it consumes
|
||||
/// the [ConversationItem]s a [TranscriptPublisher] writes onto
|
||||
/// [publisher]/[channel]. Decouples the view from the reader so several
|
||||
/// the [ConversationItem]s a `TranscriptPublisher` writes onto
|
||||
/// `publisher`/[channel]. Decouples the view from the reader so several
|
||||
/// panels can render the same conversation (team work, T-139/T-140).
|
||||
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
|
||||
final stream = messages
|
||||
|
||||
@@ -15,15 +15,18 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
|
||||
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:clide/kernel/src/keymap/pane_key_nav.dart';
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/src/terminal/terminal.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
@@ -38,11 +41,18 @@ class ConversationView extends StatefulWidget {
|
||||
this.hiddenToolUseIds = const <String>{},
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.quietErrorToolUseIds = const <String>{},
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
this.foldLevel = FoldLevel.tools,
|
||||
});
|
||||
|
||||
final ConversationController controller;
|
||||
|
||||
/// Live Workflow runs keyed by their launching `Workflow` tool-use id
|
||||
/// (T-416). A `Workflow` tool-use card with a matching run renders the
|
||||
/// dedicated run card (phases, agent rows, status) instead of the generic
|
||||
/// tool card; absent (pre-progress, or on reload) it falls back to generic.
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
/// How aggressively consecutive meta items (tool calls/results, thinking)
|
||||
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
|
||||
final FoldLevel foldLevel;
|
||||
@@ -282,7 +292,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final allItems = widget.controller.items;
|
||||
// T-263/T-264: resolve each sidechain run → owning Agent card before
|
||||
// culling, so the run is suppressed up top and folded into / nested under
|
||||
@@ -325,6 +335,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
key: ValueKey('turn.${item.uuid}'),
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
mono: ClideSettings.fonts.monoOf(context),
|
||||
collapseTools: true,
|
||||
toolUseOutcomes: widget.toolUseOutcomes,
|
||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||
@@ -332,6 +343,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
workflows: widget.workflows,
|
||||
),
|
||||
FoldedCluster(:final items) => _ActivityCard(
|
||||
key: ValueKey('cluster.${items.first.uuid}'),
|
||||
@@ -343,6 +355,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
workflows: widget.workflows,
|
||||
),
|
||||
EditRun(:final edits) => _EditRunCard(
|
||||
key: ValueKey('edits.${edits.first.uuid}'),
|
||||
@@ -376,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
return list;
|
||||
},
|
||||
);
|
||||
return ColoredBox(
|
||||
final body = ColoredBox(
|
||||
color: tokens.panelBackground,
|
||||
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized,
|
||||
);
|
||||
// Vim nav scrolls the conversation while this region holds focus under the
|
||||
// vim preset (T-406): j/k by a line, ctrl+d/u by half a viewport, gg/G to
|
||||
// the ends — G also re-arms follow-tail so new output keeps it pinned.
|
||||
return PaneKeyNav(onNav: _onNav, child: body);
|
||||
}
|
||||
|
||||
/// One "line" of scroll for j/k — a few text rows' worth.
|
||||
static const double _lineScroll = 48;
|
||||
|
||||
void _onNav(NavIntent intent, int count) {
|
||||
if (!_scroll.hasClients) return;
|
||||
final p = _scroll.position;
|
||||
final half = p.viewportDimension / 2;
|
||||
switch (intent) {
|
||||
case NavDownIntent():
|
||||
_scrollBy(_lineScroll * count);
|
||||
case NavUpIntent():
|
||||
_scrollBy(-_lineScroll * count);
|
||||
case NavPageDownIntent():
|
||||
_scrollBy(half);
|
||||
case NavPageUpIntent():
|
||||
_scrollBy(-half);
|
||||
case NavTopIntent():
|
||||
_scroll.jumpTo(0);
|
||||
_atBottom = false;
|
||||
case NavBottomIntent():
|
||||
_scroll.jumpTo(p.maxScrollExtent);
|
||||
_atBottom = true; // re-arm follow-tail (T-297)
|
||||
case NavExpandOrRightIntent() || NavCollapseOrLeftIntent() || NavActivateIntent():
|
||||
break; // a reader pane has no expand/activate semantics
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollBy(double delta) {
|
||||
final p = _scroll.position;
|
||||
final target = (p.pixels + delta).clamp(0.0, p.maxScrollExtent);
|
||||
_scroll.jumpTo(target);
|
||||
_atBottom = (p.maxScrollExtent - target) <= _bottomEpsilon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +547,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.tokens,
|
||||
required this.mono,
|
||||
this.collapseTools = false,
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.quietErrorToolUseIds = const <String>{},
|
||||
@@ -503,11 +555,17 @@ class _ConversationTurn extends StatelessWidget {
|
||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||
this.runByToolUseId = const <String, List<ConversationItem>>{},
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final ConversationItem item;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
/// The live monospace family (T-471/T-472), resolved from context by the
|
||||
/// parent and threaded in so the context-free tool-body/result helpers honour
|
||||
/// the Settings → Appearance choice.
|
||||
final String mono;
|
||||
|
||||
/// When true (top-level stream items), a tool use renders as its own
|
||||
/// collapser over a one-item list (T-305). When false (already inside a run /
|
||||
/// edit collapser), it renders the bare inner content card so collapsers
|
||||
@@ -538,6 +596,9 @@ class _ConversationTurn extends StatelessWidget {
|
||||
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
|
||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||
|
||||
/// Live Workflow runs keyed by launching tool-use id (T-416).
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = item;
|
||||
@@ -577,6 +638,17 @@ class _ConversationTurn extends StatelessWidget {
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
// CLI-local output (model "<synthetic>": a forwarded local command's
|
||||
// response or a clide-injected notice, T-411) is not Claude speaking —
|
||||
// framed + muted like the context card (T-306), attributed to clide.
|
||||
AssistantTextMessage() when i.synthetic => ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: 'clide',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
|
||||
// agent with a muted accent, never the coral "claude" brand (T-265). The
|
||||
// coral claudeAccent is reserved for the real main-thread Claude.
|
||||
@@ -686,6 +758,13 @@ class _ConversationTurn extends StatelessWidget {
|
||||
/// and its own per-item mark. An Agent/Task call also nests its visible
|
||||
/// sub-agent run in a second collapser below (T-264).
|
||||
Widget _toolUseCollapser(AssistantToolUse t) {
|
||||
// A Workflow tool-use with a live run (T-416) renders the dedicated run
|
||||
// card — phases, agent rows, status — instead of the generic tool card. No
|
||||
// run yet (pre-progress, or on reload where the system events are gone)
|
||||
// falls through to the generic collapser below.
|
||||
if (t.name == 'Workflow' && workflows[t.toolUseId] != null) {
|
||||
return _workflowCard(t, workflows[t.toolUseId]!);
|
||||
}
|
||||
final outcome = toolUseOutcomes[t.toolUseId];
|
||||
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
|
||||
final collapser = ClideCollapserCard(
|
||||
@@ -715,6 +794,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
key: ValueKey('run.${r.uuid}'),
|
||||
item: r,
|
||||
tokens: tokens,
|
||||
mono: mono,
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
@@ -729,6 +809,99 @@ class _ConversationTurn extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// A dedicated card for a Workflow run (T-416): the harness's multi-agent
|
||||
/// orchestration. The collapser header carries the run's live status (spinner
|
||||
/// while running, check when done) and a `done/total agents` counter; the body
|
||||
/// lists each fanned-out agent — grouped under phase headers when the workflow
|
||||
/// declared phases — plus the run's usage and the orchestration script.
|
||||
Widget _workflowCard(AssistantToolUse t, WorkflowRun run) {
|
||||
final title = run.name ?? 'workflow';
|
||||
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
|
||||
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
|
||||
final detail = run.done ? (run.summary ?? run.description) : run.description;
|
||||
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
|
||||
return ClideCollapserCard(
|
||||
label: 'workflow',
|
||||
color: color,
|
||||
collapsedSummary: collapsedSummary,
|
||||
counter: counter,
|
||||
status: run.done ? ClideRunStatus.success : ClideRunStatus.running,
|
||||
children: [_workflowBody(t, run)],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _workflowBody(AssistantToolUse t, WorkflowRun run) {
|
||||
final agents = run.orderedAgents;
|
||||
final phases = run.orderedPhases;
|
||||
final rows = <Widget>[];
|
||||
if (phases.isEmpty) {
|
||||
rows.addAll(agents.map(_workflowAgentRow));
|
||||
} else {
|
||||
for (final p in phases) {
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 2),
|
||||
child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow));
|
||||
}
|
||||
// Agents the deltas never tagged with a phase still render, after the
|
||||
// phased groups, so nothing fanned out is silently dropped.
|
||||
rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow));
|
||||
}
|
||||
if (rows.isEmpty) {
|
||||
rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta));
|
||||
}
|
||||
|
||||
final script = t.input['script'];
|
||||
return ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: run.done ? tokens.statusSuccess : tokens.globalFocus,
|
||||
label: run.name ?? 'workflow',
|
||||
copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input),
|
||||
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows),
|
||||
extraSegments: [
|
||||
if (run.totalTokens != null && run.totalTokens! > 0)
|
||||
CardSegment(
|
||||
label: 'usage',
|
||||
child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
if (script is String)
|
||||
CardSegment(
|
||||
label: 'script',
|
||||
child: ClideCodeBlock(source: script, language: 'javascript'),
|
||||
),
|
||||
],
|
||||
margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH),
|
||||
);
|
||||
}
|
||||
|
||||
/// One agent row in a workflow card: a state glyph (spinner while running, a
|
||||
/// muted check once done), the agent's label, and its model (T-416).
|
||||
Widget _workflowAgentRow(WorkflowAgent a) {
|
||||
final done = a.state == WorkflowAgentState.done;
|
||||
final Widget glyph = done
|
||||
? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess)
|
||||
: ClideSpinner(size: 12, color: tokens.globalTextMuted);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 16, child: Center(child: glyph)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (a.model != null && a.model!.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The inner content card for a tool use (T-305): the call body + folded
|
||||
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
|
||||
/// collapse caret — the enclosing collapser owns collapse. Used both as a
|
||||
@@ -782,7 +955,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
label: t.name,
|
||||
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
|
||||
status: status,
|
||||
body: toolInputBody(tokens, t.name, t.input),
|
||||
body: toolInputBody(tokens, t.name, t.input, mono),
|
||||
extraSegments: segments,
|
||||
// Inside a collapser the surrounding padding is even on all sides
|
||||
// (T-305): a matching bottom margin is the canvas's bottom inset and the
|
||||
@@ -839,7 +1012,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
collapsible: quiet || multiline,
|
||||
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
||||
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
|
||||
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: quiet ? tokens.globalTextMuted : tokens.statusError),
|
||||
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: mono, color: quiet ? tokens.globalTextMuted : tokens.statusError),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -860,7 +1033,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
||||
body: isOutputTool
|
||||
? ClideCodeBlock(source: t.content, language: 'text')
|
||||
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalForeground),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -896,6 +1069,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
required this.resultByToolUseId,
|
||||
required this.promptsByToolUseId,
|
||||
required this.runByToolUseId,
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final List<ConversationItem> items;
|
||||
@@ -906,6 +1080,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -921,12 +1096,14 @@ class _ActivityCard extends StatelessWidget {
|
||||
key: ValueKey('step.${item.uuid}'),
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
mono: ClideSettings.fonts.monoOf(context),
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
runByToolUseId: runByToolUseId,
|
||||
workflows: workflows,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -985,6 +1162,7 @@ class _EditRunCard extends StatelessWidget {
|
||||
key: ValueKey('edit.${item.uuid}'),
|
||||
item: item,
|
||||
tokens: tokens,
|
||||
mono: ClideSettings.fonts.monoOf(context),
|
||||
toolUseOutcomes: toolUseOutcomes,
|
||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||
toolUseById: toolUseById,
|
||||
|
||||
@@ -5,11 +5,14 @@ import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
|
||||
import 'package:clide/builtin/claude/src/conversation_view.dart' show claudeAccent;
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/pane_context_status.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
|
||||
import 'package:clide/builtin/claude/src/session_defaults.dart';
|
||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart' show kEffortLevels, kFallbackModels, kPermissionModes;
|
||||
import 'package:clide/builtin/claude/src/session_storage.dart';
|
||||
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
|
||||
@@ -99,6 +102,81 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: cycle activity fold level',
|
||||
run: _cycleFoldLevel,
|
||||
),
|
||||
// Activity settings category (T-453) — the fold level as a schema field,
|
||||
// written to kActivityFoldLevelKey; the panes already rebuild off the
|
||||
// settings notifier, so picking a level applies live.
|
||||
const SettingsCategoryContribution(
|
||||
id: 'activity',
|
||||
category: SettingsCategory(
|
||||
id: 'activity',
|
||||
title: 'Activity',
|
||||
iconName: 'cards-three',
|
||||
priority: 50,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'Conversation',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: kActivityFoldLevelKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Fold level',
|
||||
help: 'How aggressively the conversation folds tool calls, thinking, and results.',
|
||||
defaultValue: 'tools',
|
||||
options: [
|
||||
SettingsOption(value: 'none', label: 'Show everything'),
|
||||
SettingsOption(value: 'tools', label: 'Fold tool calls'),
|
||||
SettingsOption(value: 'thinking', label: 'Fold tools + thinking'),
|
||||
SettingsOption(value: 'everything', label: 'Fold all but prose'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Claude settings category (T-457) — defaults applied to NEW sessions
|
||||
// (the pane reads these keys at spawn). Effort flows through --effort;
|
||||
// model + permission mode are sent as control requests post-spawn.
|
||||
SettingsCategoryContribution(
|
||||
id: 'claude',
|
||||
category: SettingsCategory(
|
||||
id: 'claude',
|
||||
title: 'Claude',
|
||||
iconName: 'sparkle',
|
||||
priority: 40,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: 'New session defaults',
|
||||
fields: [
|
||||
SettingsField(
|
||||
key: kDefaultModelKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Model',
|
||||
help: 'Model for new sessions.',
|
||||
defaultValue: 'default',
|
||||
options: [for (final m in kFallbackModels) SettingsOption(value: m.value, label: m.displayName)],
|
||||
),
|
||||
SettingsField(
|
||||
key: kDefaultEffortKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Effort',
|
||||
help: 'Reasoning effort for new sessions (applied via --effort at spawn).',
|
||||
defaultValue: 'high',
|
||||
options: [for (final l in kEffortLevels) SettingsOption(value: l.value, label: l.displayName)],
|
||||
),
|
||||
SettingsField(
|
||||
key: kDefaultPermissionModeKey,
|
||||
kind: SettingsFieldKind.select,
|
||||
label: 'Permission mode',
|
||||
help: 'Starting permission mode for new sessions.',
|
||||
defaultValue: 'default',
|
||||
options: [for (final p in kPermissionModes) SettingsOption(value: p.value, label: p.displayName)],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// T-171: agent roster controls (D-6 CLI/UI parity).
|
||||
// Usage: clide claude.agent.show <sessionId>
|
||||
CommandContribution(
|
||||
@@ -308,6 +386,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
slot: Slots.sidebar,
|
||||
title: 'Activity',
|
||||
icon: PhosphorIcons.byName('robot'),
|
||||
// Claude's accent marks Claude's own panel in the rail (T-418) —
|
||||
// nominative use per the licenses.yaml trademark note.
|
||||
iconColor: claudeAccent,
|
||||
priority: 60,
|
||||
build: (_) => const ClaudeMetaSidebar(),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// process), so to "watch the same output" we open our OWN read-only follower
|
||||
/// on the file the command tails. This never spawns a process and never
|
||||
/// touches Claude's command — it just reads the file as it grows, like
|
||||
/// `tail -f`, and hands new bytes to [onData].
|
||||
/// `tail -f`, and hands new bytes to `onData`.
|
||||
///
|
||||
/// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather
|
||||
/// than using a watcher so it works uniformly across platforms and survives
|
||||
|
||||
@@ -25,7 +25,7 @@ void openImageLightbox(BuildContext context, String path) {
|
||||
}
|
||||
|
||||
Widget _placeholder(BuildContext context, double size) {
|
||||
final t = ClideTheme.of(context).surface;
|
||||
final t = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -46,7 +46,7 @@ class ImageThumbnail extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = ClideTheme.of(context).surface;
|
||||
final t = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Image $_fileName',
|
||||
|
||||
@@ -1,28 +1,61 @@
|
||||
/// The Activity tab: usage stats (stats-cache.json) + the primary
|
||||
/// session's live runtime row. Split out of claude_meta_sidebar.dart
|
||||
/// (T-395).
|
||||
/// The Activity tab: session controls, usage, stats (stats-cache.json), and
|
||||
/// the primary session's live runtime row. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395); session controls + the usage block are
|
||||
/// the power-panel additions (T-415).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_stats.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ActivityTabView extends StatelessWidget {
|
||||
const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config});
|
||||
const ActivityTabView({
|
||||
super.key,
|
||||
required this.stats,
|
||||
required this.primaryStatus,
|
||||
required this.config,
|
||||
this.usage,
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final ClaudeStats stats;
|
||||
final SessionStatus? primaryStatus;
|
||||
final ClaudeConfig? config;
|
||||
|
||||
/// Parsed `/usage` output for the usage block, refreshed via the refresh
|
||||
/// control (T-415). Null until the first refresh.
|
||||
final ClaudeUsage? usage;
|
||||
|
||||
/// Live Workflow runs in the primary session, keyed by launching tool-use id
|
||||
/// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with
|
||||
/// its done/total agent count and running/done state.
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
/// Publish a slash command for the primary pane to execute — the session
|
||||
/// controls are the same code path as typing the command (D-6).
|
||||
void _command(BuildContext context, String text) {
|
||||
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': text});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final latest = stats.latest;
|
||||
final u = usage;
|
||||
final sections = <MetaSection>[
|
||||
..._workflowSection(tokens),
|
||||
if (u != null)
|
||||
MetaSection('USAGE', [
|
||||
if (u.session != null) MetaRow('session', u.session!),
|
||||
if (u.week != null) MetaRow('week (all)', u.week!),
|
||||
if (u.weekSonnet != null) MetaRow('week (sonnet)', u.weekSonnet!),
|
||||
]),
|
||||
if (latest != null)
|
||||
MetaSection('TODAY', [
|
||||
MetaRow('messages', '${latest.messageCount}'),
|
||||
@@ -32,10 +65,65 @@ class ActivityTabView extends StatelessWidget {
|
||||
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
|
||||
..._runtimeSection(tokens),
|
||||
];
|
||||
if (sections.isEmpty) {
|
||||
return metaPlaceholder('No activity recorded yet.');
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
// SESSION control strip (T-415): drives the primary session through
|
||||
// the builtin.claude/command bus — identical to typing the command.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText('SESSION', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_control(context, tokens, 'clear', 'trash', '/clear'),
|
||||
_control(context, tokens, 'compact', 'arrows-in-simple', '/compact'),
|
||||
_control(context, tokens, 'fork', 'git-branch', '/fork'),
|
||||
_control(context, tokens, 'resume', 'clock-counter-clockwise', '/resume'),
|
||||
const Spacer(),
|
||||
_control(context, tokens, 'refresh usage', 'arrow-clockwise', '/usage'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (sections.isEmpty) metaPlaceholder('No activity recorded yet.') else ...metaTableChildren(tokens, sections),
|
||||
],
|
||||
);
|
||||
}
|
||||
return buildMetaTable(tokens, sections);
|
||||
|
||||
Widget _control(BuildContext context, SurfaceTokens tokens, String label, String glyph, String command) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '$label session',
|
||||
excludeSemantics: true,
|
||||
onTap: () => _command(context, command),
|
||||
child: ClideTappable(
|
||||
tooltip: '$label · $command',
|
||||
onTap: () => _command(context, command),
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: ClideIcon(PhosphorIcons.byName(glyph), size: 15, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// An aggregate WORKFLOWS section while one or more workflow runs exist this
|
||||
/// session (T-416): a row per run — its name and `done/total agents`, tinted
|
||||
/// focus while running and success once complete.
|
||||
List<MetaSection> _workflowSection(SurfaceTokens tokens) {
|
||||
final runs = workflows.values.toList();
|
||||
if (runs.isEmpty) return const [];
|
||||
return [
|
||||
MetaSection('WORKFLOWS', [
|
||||
for (final r in runs)
|
||||
MetaRow(
|
||||
r.name ?? r.taskId ?? 'workflow',
|
||||
r.agentCount == 0 ? (r.done ? 'done' : 'starting') : '${r.doneCount}/${r.agentCount} agents${r.done ? ' ✓' : ''}',
|
||||
valueColor: r.done ? tokens.statusSuccess : tokens.globalFocus,
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
|
||||
@@ -43,6 +131,7 @@ class ActivityTabView extends StatelessWidget {
|
||||
final skills = config?.skills.length;
|
||||
final rows = <MetaRow>[
|
||||
if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
|
||||
if (st?.effort != null) MetaRow('effort', st!.effort!),
|
||||
if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
|
||||
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
|
||||
if (skills != null) MetaRow('skills', '$skills'),
|
||||
|
||||
@@ -1,47 +1,80 @@
|
||||
/// The Config tab (T-183): the pinned settings table over [ClaudeConfig]
|
||||
/// plus the skills/agents/commands/hooks/permissions/MCP accordion.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395). The accordion's
|
||||
/// expansion state lives in the parent (it survives tab switches) and
|
||||
/// arrives as a prop + toggle callback.
|
||||
/// The Config tab (T-183): the settings table over [ClaudeConfig] plus the
|
||||
/// skills/agents/commands/hooks/permissions/MCP accordion. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in
|
||||
/// the parent (it survives tab switches) and arrives as a prop + toggle
|
||||
/// callback.
|
||||
///
|
||||
/// T-414 makes the settings table a control panel: model / effort /
|
||||
/// permission-mode rows are live popover controls. Picking an option
|
||||
/// publishes the explicit slash command (`/model sonnet`) on the
|
||||
/// `builtin.claude`/`command` channel; the primary Claude pane executes it
|
||||
/// through the same `_send` routing the composer uses — one implementation,
|
||||
/// two surfaces (D-6).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart' show ModelOption, kEffortLevels, kFallbackModels, kPermissionModes;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ConfigTabView extends StatelessWidget {
|
||||
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection});
|
||||
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection, this.status, this.models});
|
||||
|
||||
final ClaudeConfig? config;
|
||||
|
||||
/// The primary session's live status — drives the control rows' current
|
||||
/// values. Null before the session reports (controls fall back to the
|
||||
/// probe/settings values).
|
||||
final SessionStatus? status;
|
||||
|
||||
/// Models selectable for the primary session (from its `initialize`
|
||||
/// response); falls back to [kFallbackModels].
|
||||
final List<ModelOption>? models;
|
||||
|
||||
/// Sections currently expanded — owned by the parent state.
|
||||
final Set<ConfigSection> expanded;
|
||||
final void Function(ConfigSection section) onToggleSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final cfg = config;
|
||||
if (cfg == null) {
|
||||
return metaPlaceholder('Claude environment not loaded.');
|
||||
}
|
||||
final settings = cfg.settings;
|
||||
final model = cfg.probe?.model ?? settings['model']?.toString() ?? '—';
|
||||
final model = status?.model ?? cfg.probe?.model ?? settings['model']?.toString() ?? 'default';
|
||||
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
|
||||
final mode = cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
|
||||
final mode = status?.permissionMode ?? cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
|
||||
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
|
||||
|
||||
final children = <Widget>[
|
||||
// Pinned SETTINGS table — not collapsible.
|
||||
// Pinned SETTINGS control panel — not collapsible.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
SettingControlRow(
|
||||
label: 'model',
|
||||
value: model,
|
||||
valueColor: tokens.globalFocus,
|
||||
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
|
||||
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
|
||||
command: 'model',
|
||||
),
|
||||
SettingControlRow(label: 'effort', value: effort, options: kEffortLevels, isActive: (o) => o.value == effort, command: 'effort'),
|
||||
SettingControlRow(
|
||||
label: 'permission mode',
|
||||
value: permissionModeLabel(mode),
|
||||
options: kPermissionModes,
|
||||
isActive: (o) => o.value == mode,
|
||||
command: 'permissions',
|
||||
),
|
||||
_configRow(tokens, 'model', model, valueColor: tokens.globalFocus),
|
||||
_configRow(tokens, 'output style', outputStyle),
|
||||
_configRow(tokens, 'permission mode', permissionModeLabel(mode)),
|
||||
_configRow(tokens, 'source', '~/.claude + .claude'),
|
||||
|
||||
// ---- Accordion sections ----
|
||||
@@ -57,7 +90,7 @@ class ConfigTabView extends StatelessWidget {
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
/// One key→value row in the pinned SETTINGS table.
|
||||
/// One read-only key→value row in the pinned SETTINGS table.
|
||||
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
@@ -66,10 +99,10 @@ class ConfigTabView extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(label, muted: true, fontSize: clideFontSmall),
|
||||
child: ClideText(label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(value, fontSize: clideFontSmall, color: valueColor ?? tokens.globalForeground),
|
||||
child: ClideText(value, fontSize: kMetaFont, color: valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -223,3 +256,105 @@ class ConfigTabView extends StatelessWidget {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/// One live setting row (T-414): label + current value as a popover control on
|
||||
/// the owned anchored-menu primitive. Picking an option publishes the explicit
|
||||
/// slash command on `builtin.claude`/`command`; the primary Claude pane
|
||||
/// executes it through its normal `_send` routing — so the sidebar control and
|
||||
/// the typed command are literally the same code path (D-6).
|
||||
class SettingControlRow extends StatefulWidget {
|
||||
const SettingControlRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.isActive,
|
||||
required this.command,
|
||||
this.valueColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
/// Current value, displayed on the trigger.
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
final List<ModelOption> options;
|
||||
final bool Function(ModelOption option) isActive;
|
||||
|
||||
/// The slash-command token this control drives (`model`, `effort`,
|
||||
/// `permissions`); a pick publishes `/<command> <option.value>`.
|
||||
final String command;
|
||||
|
||||
@override
|
||||
State<SettingControlRow> createState() => _SettingControlRowState();
|
||||
}
|
||||
|
||||
class _SettingControlRowState extends State<SettingControlRow> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
|
||||
void _pick(String value) {
|
||||
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': '/${widget.command} $value'});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(widget.label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.start,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
for (final o in widget.options)
|
||||
ClideMenuItem(
|
||||
label: o.description.isEmpty ? o.displayName : '${o.displayName} — ${o.description}',
|
||||
active: widget.isActive(o),
|
||||
semanticLabel: '${widget.label}: ${o.displayName}',
|
||||
onSelect: () => _pick(o.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: '${widget.label}: ${widget.value}. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
tooltip: 'change ${widget.label}',
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => DecoratedBox(
|
||||
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClideText(widget.value, fontSize: kMetaFont, color: widget.valueColor ?? tokens.globalForeground, maxLines: 1),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
/// lib/widgets/ only when a second consumer appears.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -29,7 +28,7 @@ class MetaIconButton extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideIcon(painter, size: 12, color: hovered ? ClideTheme.of(ctx).surface.globalForeground : color),
|
||||
child: ClideIcon(painter, size: 12, color: hovered ? ClideSettings.theme.of(ctx).surface.globalForeground : color),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,11 @@ import 'package:flutter/widgets.dart';
|
||||
/// The shared label-column width + row pitch the Activity and Config tables
|
||||
/// both use, so toggling between tabs keeps every value at the same x and y.
|
||||
const double kMetaLabelColumnWidth = 110;
|
||||
const double kMetaRowPitch = 4;
|
||||
const double kMetaRowPitch = 6;
|
||||
|
||||
/// Type scale for the sidebar tables (T-414 styling pass): labels/values read
|
||||
/// at meta size (13) — the old 12px-everything read as bland and cramped.
|
||||
const double kMetaFont = clideFontMeta;
|
||||
|
||||
/// The sidebar's sub-tabs.
|
||||
enum SidebarTab { activity, team, config }
|
||||
@@ -36,18 +40,23 @@ class MetaRow {
|
||||
/// The muted empty-state body shared by every tab.
|
||||
Widget metaPlaceholder(String text) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(text, muted: true, fontSize: clideFontSmall),
|
||||
child: ClideText(text, muted: true, fontSize: kMetaFont),
|
||||
);
|
||||
|
||||
/// Key→value sections on the shared table geometry (Activity + Config).
|
||||
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) =>
|
||||
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections));
|
||||
|
||||
/// The table rows without the enclosing ListView, for tabs that compose extra
|
||||
/// widgets around the sections (the Activity tab's control strip, T-415).
|
||||
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
final s = sections[i];
|
||||
children.add(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
|
||||
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
|
||||
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
);
|
||||
for (final r in s.rows) {
|
||||
@@ -59,10 +68,10 @@ Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
|
||||
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(r.value, fontSize: clideFontSmall, color: r.valueColor ?? tokens.globalForeground),
|
||||
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -70,5 +79,5 @@ Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
);
|
||||
}
|
||||
}
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class AgentRosterRow extends StatefulWidget {
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
|
||||
/// Called when the badge cycles to a new [mode] string for this member.
|
||||
/// Called when the badge cycles to a new `mode` string for this member.
|
||||
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
|
||||
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
@@ -70,7 +70,7 @@ class _AgentRosterRowState extends State<AgentRosterRow> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final managed = widget.orchestrator?.byMemberName(widget.member.name);
|
||||
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
|
||||
final st = widget.status;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -16,7 +15,7 @@ class SidebarTabStrip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -18,7 +18,7 @@ class TaskRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final marker = switch (task.status) {
|
||||
'done' => '✓',
|
||||
'claimed' => '◈',
|
||||
|
||||
@@ -48,7 +48,7 @@ class TeamTabView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (members.isEmpty) {
|
||||
return metaPlaceholder('No team active.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/// The `/model` picker for the interaction zone (T-408, D-78): a bare
|
||||
/// `/model` swaps this card in for the composer; picking an entry sends
|
||||
/// `set_model` over the control channel and the composer returns. Esc
|
||||
/// cancels. Like [ToolPromptCard], it lives in the composer zone — never
|
||||
/// inline in the conversation.
|
||||
///
|
||||
/// Keyboard: number keys pick directly (CLI muscle memory, T-240), Up/Down
|
||||
/// move the highlight, Enter picks the highlighted entry, Esc cancels.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Whether [option] is the session's current model. Options carry aliases
|
||||
/// (`sonnet`) or full ids while the status holds the full id
|
||||
/// (`claude-sonnet-4-6`), so match on equality or alias containment.
|
||||
bool modelOptionIsCurrent(ModelOption option, String? currentModel) {
|
||||
if (currentModel == null || option.value == 'default') return false;
|
||||
if (option.value == currentModel) return true;
|
||||
return currentModel.toLowerCase().contains(option.value.toLowerCase());
|
||||
}
|
||||
|
||||
class ModelPickerCard extends StatefulWidget {
|
||||
const ModelPickerCard({
|
||||
super.key,
|
||||
required this.models,
|
||||
this.currentModel,
|
||||
required this.onPick,
|
||||
required this.onCancel,
|
||||
this.title = 'model',
|
||||
this.isCurrent = modelOptionIsCurrent,
|
||||
});
|
||||
|
||||
/// Selectable entries, in display order. Callers pass [kFallbackModels]
|
||||
/// when the session hasn't reported its list yet.
|
||||
final List<ModelOption> models;
|
||||
|
||||
/// The session's current model (full id), to mark the active entry.
|
||||
final String? currentModel;
|
||||
|
||||
/// Called once with the picked [ModelOption.value].
|
||||
final void Function(String value) onPick;
|
||||
|
||||
/// Called when the user dismisses the picker without choosing.
|
||||
final VoidCallback onCancel;
|
||||
|
||||
/// Header label. The /effort picker reuses this card with its own title
|
||||
/// and an exact-match [isCurrent] (T-412).
|
||||
final String title;
|
||||
|
||||
/// Marks the active entry. The model default ([modelOptionIsCurrent]) also
|
||||
/// alias-matches (`sonnet` ⊂ `claude-sonnet-4-6`); effort needs exact match
|
||||
/// (`high` would falsely match inside `xhigh`).
|
||||
final bool Function(ModelOption option, String? current) isCurrent;
|
||||
|
||||
@override
|
||||
State<ModelPickerCard> createState() => _ModelPickerCardState();
|
||||
}
|
||||
|
||||
class _ModelPickerCardState extends State<ModelPickerCard> {
|
||||
late int _highlight = _initialHighlight();
|
||||
|
||||
int _initialHighlight() {
|
||||
for (var i = 0; i < widget.models.length; i++) {
|
||||
if (widget.isCurrent(widget.models[i], widget.currentModel)) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent || !node.hasPrimaryFocus) return KeyEventResult.ignored;
|
||||
final hw = HardwareKeyboard.instance;
|
||||
if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored;
|
||||
final key = e.logicalKey;
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
widget.onCancel();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
setState(() => _highlight = (_highlight + 1) % widget.models.length);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
setState(() => _highlight = (_highlight - 1 + widget.models.length) % widget.models.length);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
|
||||
widget.onPick(widget.models[_highlight].value);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final digit = _digitOf(key);
|
||||
if (digit != null && digit >= 1 && digit <= widget.models.length) {
|
||||
widget.onPick(widget.models[digit - 1].value);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
static int? _digitOf(LogicalKeyboardKey key) {
|
||||
const digits = [
|
||||
LogicalKeyboardKey.digit1,
|
||||
LogicalKeyboardKey.digit2,
|
||||
LogicalKeyboardKey.digit3,
|
||||
LogicalKeyboardKey.digit4,
|
||||
LogicalKeyboardKey.digit5,
|
||||
LogicalKeyboardKey.digit6,
|
||||
LogicalKeyboardKey.digit7,
|
||||
LogicalKeyboardKey.digit8,
|
||||
LogicalKeyboardKey.digit9,
|
||||
];
|
||||
const numpad = [
|
||||
LogicalKeyboardKey.numpad1,
|
||||
LogicalKeyboardKey.numpad2,
|
||||
LogicalKeyboardKey.numpad3,
|
||||
LogicalKeyboardKey.numpad4,
|
||||
LogicalKeyboardKey.numpad5,
|
||||
LogicalKeyboardKey.numpad6,
|
||||
LogicalKeyboardKey.numpad7,
|
||||
LogicalKeyboardKey.numpad8,
|
||||
LogicalKeyboardKey.numpad9,
|
||||
];
|
||||
var i = digits.indexOf(key);
|
||||
if (i < 0) i = numpad.indexOf(key);
|
||||
return i < 0 ? null : i + 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border(top: BorderSide(color: tokens.statusInfo, width: 2)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.statusInfo),
|
||||
const Spacer(),
|
||||
ClideText(
|
||||
'↑↓ · 1-${widget.models.length} · Enter · Esc',
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (var i = 0; i < widget.models.length; i++) _row(tokens, i),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
ClideButton(label: 'cancel', variant: ClideButtonVariant.subtle, onPressed: widget.onCancel),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(SurfaceTokens tokens, int i) {
|
||||
final m = widget.models[i];
|
||||
final current = widget.isCurrent(m, widget.currentModel);
|
||||
final highlighted = i == _highlight;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ClideButton(
|
||||
label: '${i + 1}. ${current ? '●' : '○'} ${m.displayName}${m.description.isEmpty ? '' : ' — ${m.description}'}',
|
||||
variant: highlighted ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
|
||||
onPressed: () => widget.onPick(m.value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// Resolve the `native/<os>-<arch>/` directory name via FFI ABI introspection
|
||||
/// (T-438 web fence, D-100). Desktop-only; the web build uses
|
||||
/// [native_abi_stub.dart], so `dart:ffi` (here, only `Abi`) stays out of the
|
||||
/// wasm graph.
|
||||
library;
|
||||
|
||||
import 'dart:ffi' show Abi;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
/// The `<os>-<arch>` dir name for the current process (e.g. `linux-x64`,
|
||||
/// `macos-arm64`) — used to find a dev-tree `clide` binary when running
|
||||
/// un-installed. [abi] is an injection seam for tests; production passes none.
|
||||
String currentNativeDirName({Abi? abi}) {
|
||||
switch (abi ?? Abi.current()) {
|
||||
case Abi.macosArm64:
|
||||
return 'macos-arm64';
|
||||
case Abi.macosX64:
|
||||
return 'macos-x64';
|
||||
case Abi.linuxArm64:
|
||||
return 'linux-arm64';
|
||||
case Abi.linuxX64:
|
||||
return 'linux-x64';
|
||||
default:
|
||||
// Windows / other — clide is desktop linux/macOS today; fall back to a
|
||||
// best-effort name so the probe simply misses rather than throwing.
|
||||
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Web stub (T-438 web fence, D-100): no FFI ABI introspection on web. The
|
||||
/// native dir name only matters for locating a dev-tree `clide` binary, which
|
||||
/// doesn't exist on the web target — so a harmless default suffices.
|
||||
library;
|
||||
|
||||
String currentNativeDirName() => 'linux-x64';
|
||||
@@ -88,13 +88,13 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
side: ClideAnchorSide.above,
|
||||
align: ClideAnchorAlign.end,
|
||||
offset: const Offset(0, -6),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
|
||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideSettings.theme.of(ctx).surface)),
|
||||
anchor: ListenableBuilder(
|
||||
listenable: _overlay,
|
||||
builder: (ctx, _) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/// Rendered in the composer zone (not inline in the conversation) so
|
||||
/// interaction and conversation widgets don't mix — the pane swaps it in for
|
||||
/// the text input while a prompt is open. The decision is returned via
|
||||
/// [onResolve]; the pane then removes the card.
|
||||
/// `onResolve`; the pane then removes the card.
|
||||
///
|
||||
/// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no
|
||||
/// hover-revealed chrome that would fight the buttons.
|
||||
@@ -15,7 +15,6 @@ import 'dart:convert';
|
||||
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -103,7 +102,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final (accent, label, children) = widget.prompt.isQuestion ? _question(tokens) : _permission(tokens);
|
||||
// Autofocus the card so number keys pick a button/option on appear (T-240).
|
||||
// _onKey self-guards via hasPrimaryFocus, so once the user clicks a note
|
||||
@@ -122,7 +121,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: accent),
|
||||
ClideText(label, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: accent),
|
||||
const SizedBox(height: 8),
|
||||
...children,
|
||||
],
|
||||
@@ -259,7 +258,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
// or file body is visible but doesn't swamp the composer zone (D-78).
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: SingleChildScrollView(child: _inputBody(tokens, p)),
|
||||
child: SingleChildScrollView(child: _inputBody(tokens, ClideSettings.fonts.monoOf(context), p)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
@@ -284,7 +283,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
|
||||
/// Render the tool input in the shape that best fits the tool. Delegates to
|
||||
/// the shared top-level helpers (also used by ConversationView — T-168).
|
||||
Widget _inputBody(SurfaceTokens tokens, ToolPrompt p) => toolInputBody(tokens, p.toolName, p.input);
|
||||
Widget _inputBody(SurfaceTokens tokens, String mono, ToolPrompt p) => toolInputBody(tokens, p.toolName, p.input, mono);
|
||||
|
||||
// -- AskUserQuestion: single = bare, multi = stepper + review --------------
|
||||
|
||||
@@ -362,14 +361,21 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
border: Border.all(color: tokens.statusInfo),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalForeground),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
|
||||
chips.add(
|
||||
ClideText(
|
||||
text,
|
||||
fontSize: clideFontMeta,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
color: done ? tokens.statusSuccess : tokens.globalTextMuted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
chips.add(ClideText('Review', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted));
|
||||
chips.add(ClideText('Review', fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted));
|
||||
chips.add(ClideText('›', color: tokens.globalTextMuted, fontSize: 15));
|
||||
return Wrap(spacing: 10, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: chips);
|
||||
}
|
||||
@@ -399,7 +405,8 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (q.header.isNotEmpty) ClideText(q.header.toUpperCase(), fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
if (q.header.isNotEmpty)
|
||||
ClideText(q.header.toUpperCase(), fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
||||
child: ClideText(q.question, color: tokens.globalForeground),
|
||||
@@ -484,19 +491,19 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
||||
///
|
||||
/// Shared between [ToolPromptCard] (permission prompt body) and the
|
||||
/// [ConversationView] tool-use card bodies (T-168).
|
||||
Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input) {
|
||||
Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input, String mono) {
|
||||
switch (toolName) {
|
||||
case 'Bash':
|
||||
return toolBashBody(tokens, input);
|
||||
case 'Write':
|
||||
return toolWriteBody(tokens, input);
|
||||
return toolWriteBody(tokens, input, mono);
|
||||
case 'Edit':
|
||||
case 'MultiEdit':
|
||||
return toolEditBody(tokens, input);
|
||||
return toolEditBody(tokens, input, mono);
|
||||
case 'Read':
|
||||
case 'Grep':
|
||||
case 'LS':
|
||||
return toolReadLikeBody(tokens, toolName, input);
|
||||
return toolReadLikeBody(tokens, toolName, input, mono);
|
||||
default:
|
||||
return ClideCodeBlock(source: const JsonEncoder.withIndent(' ').convert(input), language: 'json');
|
||||
}
|
||||
@@ -522,21 +529,21 @@ Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
}
|
||||
|
||||
/// Write tool body: the file path + content with syntax highlighting.
|
||||
Widget toolWriteBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
Widget toolWriteBody(SurfaceTokens tokens, Map<String, dynamic> input, String mono) {
|
||||
final path = input['file_path'] as String? ?? '';
|
||||
final content = input['content'] as String? ?? '';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (path.isNotEmpty) toolPathLine(tokens, path),
|
||||
if (path.isNotEmpty) toolPathLine(tokens, path, mono),
|
||||
ClideCodeBlock(source: content, language: grammarForPath(path)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Edit / MultiEdit tool body: before/after diff view.
|
||||
Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input, String mono) {
|
||||
final path = input['file_path'] as String? ?? '';
|
||||
final oldStr = input['old_string'] as String? ?? '';
|
||||
final newStr = input['new_string'] as String? ?? '';
|
||||
@@ -545,12 +552,12 @@ Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (path.isNotEmpty) toolPathLine(tokens, path),
|
||||
ClideText('— before', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (path.isNotEmpty) toolPathLine(tokens, path, mono),
|
||||
ClideText('— before', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: mono),
|
||||
const SizedBox(height: 4),
|
||||
ClideCodeBlock(source: oldStr, language: lang),
|
||||
const SizedBox(height: 8),
|
||||
ClideText('+ after', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
ClideText('+ after', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: mono),
|
||||
const SizedBox(height: 4),
|
||||
ClideCodeBlock(source: newStr, language: lang),
|
||||
],
|
||||
@@ -560,7 +567,7 @@ Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
||||
/// Read / Grep / LS body: show the file path or pattern as a one-liner label
|
||||
/// so the card stays compact. These tools produce the interesting output in the
|
||||
/// result card rather than their input.
|
||||
Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input) {
|
||||
Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input, String mono) {
|
||||
final path = input['file_path'] ?? input['path'] ?? input['pattern'] ?? '';
|
||||
final extra = <String>[];
|
||||
if (toolName == 'Grep') {
|
||||
@@ -568,13 +575,13 @@ Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynam
|
||||
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
|
||||
}
|
||||
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
|
||||
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground);
|
||||
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalForeground);
|
||||
}
|
||||
|
||||
/// A muted file path line, shared across tool bodies.
|
||||
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
|
||||
Widget toolPathLine(SurfaceTokens tokens, String path, String mono) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
child: ClideText(path, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalTextMuted),
|
||||
);
|
||||
|
||||
// -- shared note / free-text field -------------------------------------------
|
||||
@@ -599,7 +606,7 @@ class _NoteFieldState extends State<_NoteField> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Per-user defaults applied to NEW Claude sessions (T-457). Set from the
|
||||
/// Settings → Claude category; read by the pane when it spawns a session.
|
||||
/// Effort is applied at spawn (the `--effort` flag — there's no live
|
||||
/// set_effort); model and permission mode are sent as control requests right
|
||||
/// after the session starts.
|
||||
const String kDefaultModelKey = 'app.claude.defaultModel';
|
||||
const String kDefaultEffortKey = 'app.claude.defaultEffort';
|
||||
const String kDefaultPermissionModeKey = 'app.claude.defaultPermissionMode';
|
||||
|
||||
/// The default effort for new sessions, or null to let the CLI's own default
|
||||
/// stand. `'default'` is treated as "no override" too.
|
||||
String? defaultEffortFlag(SettingsStore settings) {
|
||||
final v = settings.get<String>(kDefaultEffortKey);
|
||||
if (v == null || v.isEmpty || v == 'default') return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/// Apply the model + permission-mode defaults to a freshly-spawned [session].
|
||||
/// A null/empty/`'default'` value is a no-op — the CLI's own default stands.
|
||||
/// (Effort is handled at spawn via [defaultEffortFlag], not here.)
|
||||
void applySessionDefaults(StreamJsonSession session, SettingsStore settings) {
|
||||
final model = settings.get<String>(kDefaultModelKey);
|
||||
if (model != null && model.isNotEmpty && model != 'default') session.setModel(model);
|
||||
final perm = settings.get<String>(kDefaultPermissionModeKey);
|
||||
if (perm != null && perm.isNotEmpty && perm != 'default') session.setPermissionMode(perm);
|
||||
}
|
||||
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
|
||||
|
||||
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent
|
||||
/// `claude --session-id <sessionId>` re-creates it empty — the in-place
|
||||
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl`
|
||||
/// and the sidecar `<id>/` directory claude keeps beside it. Best-effort:
|
||||
/// missing entries are not an error. The caller MUST have killed the session's
|
||||
/// process first, so claude is not mid-write.
|
||||
/// `/clear` path for the primary pane (T-268). Removes the `<id>.jsonl`, plus
|
||||
/// a per-session `<id>/` sidecar dir if one exists (best-effort; missing
|
||||
/// entries are not an error). Note the shared per-project `memory/` dir that
|
||||
/// claude 2.1.x keeps beside transcripts is deliberately left alone — it is
|
||||
/// not per-session.
|
||||
///
|
||||
/// The caller MUST have AWAITED the session's process death first (T-437): a
|
||||
/// still-live claude re-flushes its transcript and keeps the id registered, so
|
||||
/// the respawn's `--session-id` is rejected as "already in use" (exit 1).
|
||||
/// [ClaudeSessionOrchestrator.close] now awaits that death before this runs.
|
||||
Future<void> clearSessionTranscript(String projectDir, String sessionId) async {
|
||||
final file = File('$projectDir/$sessionId.jsonl');
|
||||
if (await file.exists()) await file.delete();
|
||||
@@ -124,12 +130,18 @@ String freshSessionId() {
|
||||
/// same id). Expands an FNV-1a stream into 16 bytes.
|
||||
String _deterministicUuid(String seed) {
|
||||
final bytes = <int>[];
|
||||
var h = 0xcbf29ce484222325;
|
||||
// FNV-1a 64-bit offset basis, split into two 32-bit halves so the dart2js
|
||||
// web fallback accepts it (a full 64-bit literal "can't be represented
|
||||
// exactly in JavaScript" — T-438). Correct on the VM/wasm; web never derives
|
||||
// a session id (no claude process there).
|
||||
var h = (0xcbf29ce4 << 32) | 0x84222325;
|
||||
const prime = 0x100000001b3;
|
||||
for (var i = 0; i < 16; i++) {
|
||||
for (final c in utf8.encode('$seed:$i')) {
|
||||
h ^= c;
|
||||
h = (h * prime) & 0xFFFFFFFFFFFFFFFF;
|
||||
// 64-bit modular wrap is implicit on the VM/wasm; the explicit
|
||||
// `& 0xFFFFFFFFFFFFFFFF` was a no-op and a dart2js-incompatible literal.
|
||||
h = h * prime;
|
||||
}
|
||||
bytes.add(h & 0xff);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
///
|
||||
/// A session is a `claude` stream-json process clide spawns and renders; a
|
||||
/// pane is just a *view* on one. The orchestrator decouples a session's
|
||||
/// lifecycle from any pane: [spawn] starts and registers it, [show]/[hide]
|
||||
/// toggle visibility WITHOUT tearing the process down, and [close] kills it.
|
||||
/// lifecycle from any pane: `spawn` starts and registers it, `show`/`hide`
|
||||
/// toggle visibility WITHOUT tearing the process down, and `close` kills it.
|
||||
/// This is the one primitive behind teammate / secondary tab / forked branch
|
||||
/// (Phase 2): they are all just managed sessions shown as panes.
|
||||
///
|
||||
@@ -49,6 +49,7 @@ class SpawnSpec {
|
||||
this.team = false,
|
||||
this.memberName,
|
||||
this.forkSourceSessionId,
|
||||
this.effort,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -81,6 +82,12 @@ class SpawnSpec {
|
||||
/// Takes precedence over [resume]/[sessionId] for arg selection.
|
||||
final String? forkSourceSessionId;
|
||||
|
||||
/// Effort level passed to `claude --effort` (low/medium/high/xhigh/max,
|
||||
/// T-412). Null spawns without the flag — the CLI uses its configured
|
||||
/// default (settings.json `effortLevel`). No set_effort control subtype
|
||||
/// exists, so changing effort means respawn-with-resume carrying this.
|
||||
final String? effort;
|
||||
|
||||
/// Whether this spec spawns a forked session.
|
||||
bool get isFork => forkSourceSessionId != null;
|
||||
}
|
||||
@@ -238,7 +245,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
|
||||
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs];
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
...bootstrap.extraArgs,
|
||||
if (spec.effort != null) ...['--effort', spec.effort!],
|
||||
...sessionArgs,
|
||||
];
|
||||
|
||||
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
|
||||
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
|
||||
@@ -300,12 +313,18 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Kill and forget a session (the real teardown). The conversation's
|
||||
/// onDispose kills the process + closes its streams.
|
||||
/// onDispose kills the process + closes its streams; we then AWAIT the
|
||||
/// session's teardown so the `claude` process is genuinely dead before we
|
||||
/// return (T-437). Callers respawn the primary on the same deterministic
|
||||
/// `--session-id` right after /clear — if the old process were still alive,
|
||||
/// claude 2.1.177 would reject the id as "already in use" and the respawn
|
||||
/// would exit 1.
|
||||
Future<void> close(String id) async {
|
||||
final m = _sessions.remove(id);
|
||||
if (m == null) return;
|
||||
broker.removeMember(id);
|
||||
m.conversation.dispose();
|
||||
m.conversation.dispose(); // cancels the item subscription; kicks off session teardown
|
||||
await m.session.dispose(); // idempotent — awaits the real process exit
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -60,7 +59,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideTheme.of(context).surface;
|
||||
final theme = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||
import 'package:clide/builtin/claude/src/session_picker.dart' show relativeTime;
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -55,7 +54,7 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ClideTheme.of(context).surface;
|
||||
final theme = ClideSettings.theme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
|
||||
@@ -30,8 +30,29 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
|
||||
/// Slash commands clide handles itself instead of forwarding to Claude:
|
||||
/// Claude Code's own handling forks the session to a new id that clide's
|
||||
/// transcript reader can't follow, so clide owns the semantics (T-156).
|
||||
/// `/fork` branches the current session into a new pane (T-172).
|
||||
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'};
|
||||
/// `/fork` branches the current session into a new pane (T-172). `/model`
|
||||
/// is interactive in the CLI's TUI only — forwarded it does nothing — so
|
||||
/// clide owns it as a set_model control request / picker (T-408). `/effort`
|
||||
/// has no control subtype, so clide owns it as a respawn-with-resume
|
||||
/// carrying `--effort` (T-412). `/permissions` is a picker over
|
||||
/// set_permission_mode; the rest navigate to clide surfaces (T-413):
|
||||
/// /status//config//mcp//agents//hooks → the Claude sidebar tabs,
|
||||
/// /memory → CLAUDE.md in the editor, /help → a local command summary.
|
||||
const Set<String> kClideOwnedCommands = {
|
||||
'clear',
|
||||
'resume',
|
||||
'fork',
|
||||
'model',
|
||||
'effort',
|
||||
'permissions',
|
||||
'status',
|
||||
'config',
|
||||
'mcp',
|
||||
'agents',
|
||||
'hooks',
|
||||
'memory',
|
||||
'help',
|
||||
};
|
||||
|
||||
/// The clide-owned command in [text] (a single-line leading-slash token in
|
||||
/// [kClideOwnedCommands]), or null.
|
||||
@@ -40,6 +61,93 @@ String? clideOwnedCommand(String text) {
|
||||
return token != null && kClideOwnedCommands.contains(token) ? token : null;
|
||||
}
|
||||
|
||||
/// Where slash input goes (T-411). One source of truth so a TUI-only command
|
||||
/// neither errors raw from the CLI nor bracket-pastes to the model as text
|
||||
/// (burning a real turn — observed with /effort on claude 2.1.175).
|
||||
enum SlashRoute {
|
||||
/// clide implements it natively ([kClideOwnedCommands]).
|
||||
owned,
|
||||
|
||||
/// The CLI handles it headless — advertised in the `initialize` handshake's
|
||||
/// `slash_commands` (skills + the headless builtins: compact, context, …).
|
||||
forward,
|
||||
|
||||
/// A known TUI-only builtin: never forwarded; clide shows a local notice
|
||||
/// with the clide-native way ([kTuiOnlyCommands]).
|
||||
unavailable,
|
||||
}
|
||||
|
||||
/// Claude Code TUI-only builtins (probed against 2.1.175: not advertised in
|
||||
/// stream-json, and forwarding would either error "isn't available in this
|
||||
/// environment" or — worse, for un-advertised tokens — bracket-paste to the
|
||||
/// model as literal text). Value = the clide-native pointer shown in the
|
||||
/// notice card. Commands clide later implements move to [kClideOwnedCommands].
|
||||
const Map<String, String> kTuiOnlyCommands = {
|
||||
'effort': '', // owned (T-412) — only routes here if ever removed from owned
|
||||
'status': '', // owned (T-413)
|
||||
'cost': 'cost and context usage live in the Claude sidebar (Activity tab)',
|
||||
'context': '', // advertised on current CLIs — only routes here on older ones
|
||||
'help': '', // owned (T-413)
|
||||
'config': '', // owned (T-413)
|
||||
'permissions': '', // owned (T-413)
|
||||
'memory': '', // owned (T-413)
|
||||
'mcp': '', // owned (T-413)
|
||||
'agents': '', // owned (T-413)
|
||||
'hooks': '', // owned (T-413)
|
||||
'todos': "Claude's task list docks above the composer",
|
||||
'model': '', // owned (T-408) — only routes here if ever removed from owned
|
||||
'doctor': 'run `claude doctor` in a terminal',
|
||||
'login': 'run `claude` in a terminal and use /login there',
|
||||
'logout': 'run `claude` in a terminal and use /logout there',
|
||||
'exit': 'close the pane or switch sessions instead',
|
||||
'vim': 'clide ships its own editor vim mode',
|
||||
'add-dir': '',
|
||||
'bashes': '',
|
||||
'bug': '',
|
||||
'export': '',
|
||||
'fast': '',
|
||||
'ide': "you're already in one",
|
||||
'install-github-app': '',
|
||||
'migrate-installer': '',
|
||||
'output-style': '',
|
||||
'pr-comments': '',
|
||||
'privacy-settings': '',
|
||||
'release-notes': '',
|
||||
'rewind': '',
|
||||
'statusline': '',
|
||||
'terminal-setup': '',
|
||||
'upgrade': '',
|
||||
};
|
||||
|
||||
/// Route [text] (composer input). Null when it isn't slash-command input —
|
||||
/// send it as a normal message. Precedence: owned > advertised > TUI-only
|
||||
/// catalog > forward (unknown tokens stay literal text via bracketed paste).
|
||||
SlashRoute? routeSlashCommand(String text, {required Iterable<String> advertised}) {
|
||||
final token = slashCommandToken(text);
|
||||
if (token == null) return null;
|
||||
if (kClideOwnedCommands.contains(token)) return SlashRoute.owned;
|
||||
if (advertised.contains(token)) return SlashRoute.forward;
|
||||
if (kTuiOnlyCommands.containsKey(token)) return SlashRoute.unavailable;
|
||||
return SlashRoute.forward;
|
||||
}
|
||||
|
||||
/// The notice text for a TUI-only [token] — the CLI's own phrasing plus the
|
||||
/// clide-native pointer when the catalog has one.
|
||||
String tuiOnlyNotice(String token) {
|
||||
final hint = kTuiOnlyCommands[token] ?? '';
|
||||
final base = "/$token is a Claude Code TUI command — it isn't available in clide's conversation pane.";
|
||||
return hint.isEmpty ? base : '$base\n→ $hint';
|
||||
}
|
||||
|
||||
/// The argument text after the command token — `"/model sonnet"` → `"sonnet"`
|
||||
/// — trimmed; empty when there is none (`"/model"`). Null when [text] isn't
|
||||
/// single-line leading-slash input.
|
||||
String? slashCommandArg(String text) {
|
||||
if (slashCommandToken(text) == null) return null;
|
||||
final ws = text.indexOf(RegExp(r'\s'));
|
||||
return ws < 0 ? '' : text.substring(ws + 1).trim();
|
||||
}
|
||||
|
||||
bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n';
|
||||
|
||||
/// An in-progress slash query at the cursor — the `/` position and the word
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/src/util/value_stream.dart';
|
||||
|
||||
/// The claude subprocess, abstracted so tests drive it without spawning.
|
||||
@@ -106,7 +107,20 @@ class ClaudeStreamJsonProcess extends StreamJsonProcess {
|
||||
|
||||
@override
|
||||
Future<void> kill() async {
|
||||
// Await the process's ACTUAL death, not just the signal (T-437). clide
|
||||
// respawns the primary on the SAME deterministic --session-id right after
|
||||
// /clear; if the old process is still alive (or still flushing its
|
||||
// transcript) when the new one starts, claude 2.1.177 rejects the id with
|
||||
// "Session ID … is already in use" and the respawn exits 1. SIGTERM first
|
||||
// (claude cleans its session registry on it), escalate to SIGKILL if it
|
||||
// doesn't go, and only return once exitCode has resolved.
|
||||
_proc.kill();
|
||||
try {
|
||||
await _proc.exitCode.timeout(const Duration(seconds: 2));
|
||||
} on TimeoutException {
|
||||
_proc.kill(ProcessSignal.sigkill);
|
||||
await _proc.exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -147,6 +161,53 @@ abstract class McpServer {
|
||||
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments);
|
||||
}
|
||||
|
||||
/// A model selectable for a session, from the `initialize` control_response's
|
||||
/// `models[]` (T-408). Pure data, Flutter-free.
|
||||
class ModelOption {
|
||||
const ModelOption({required this.value, required this.displayName, this.description = ''});
|
||||
|
||||
/// The id/alias sent in `set_model` — e.g. `default`, `sonnet`, `opus`.
|
||||
final String value;
|
||||
|
||||
/// Human label, e.g. `Sonnet`.
|
||||
final String displayName;
|
||||
|
||||
/// One-line blurb shown muted next to the label.
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// Effort levels `claude --effort` accepts (probed against 2.1.175). There is
|
||||
/// NO set_effort control subtype (probed: rejected), so changing effort
|
||||
/// respawns the session with the flag — resume keeps the conversation (T-412).
|
||||
/// Expressed as [ModelOption]s so the /effort picker reuses the /model card.
|
||||
const List<ModelOption> kEffortLevels = [
|
||||
ModelOption(value: 'low', displayName: 'low', description: 'fastest, minimal thinking'),
|
||||
ModelOption(value: 'medium', displayName: 'medium', description: 'balanced'),
|
||||
ModelOption(value: 'high', displayName: 'high', description: 'thorough'),
|
||||
ModelOption(value: 'xhigh', displayName: 'xhigh', description: 'deeper reasoning'),
|
||||
ModelOption(value: 'max', displayName: 'max', description: 'maximum thinking budget'),
|
||||
];
|
||||
|
||||
/// Permission modes for the /permissions picker (T-413), set over the
|
||||
/// set_permission_mode control request. Bypass is last and explicit — the
|
||||
/// footgun stays visible but never the default reach (T-181).
|
||||
const List<ModelOption> kPermissionModes = [
|
||||
ModelOption(value: 'default', displayName: 'default', description: 'ask before sensitive tools'),
|
||||
ModelOption(value: 'acceptEdits', displayName: 'acceptEdits', description: 'auto-approve file edits'),
|
||||
ModelOption(value: 'plan', displayName: 'plan', description: 'read-only planning mode'),
|
||||
ModelOption(value: 'bypassPermissions', displayName: 'bypassPermissions', description: 'no prompts at all — careful'),
|
||||
];
|
||||
|
||||
/// Fallback picker entries for when the `initialize` response hasn't arrived
|
||||
/// (or carried no models): the stable aliases every claude build accepts
|
||||
/// (T-408). `default` resets to the CLI's configured model.
|
||||
const List<ModelOption> kFallbackModels = [
|
||||
ModelOption(value: 'default', displayName: 'Default', description: 'recommended — the CLI\'s configured model'),
|
||||
ModelOption(value: 'sonnet', displayName: 'Sonnet', description: 'fast, great for everyday tasks'),
|
||||
ModelOption(value: 'opus', displayName: 'Opus', description: 'most capable'),
|
||||
ModelOption(value: 'haiku', displayName: 'Haiku', description: 'fastest, lightweight'),
|
||||
];
|
||||
|
||||
/// An interactive prompt Claude is blocked on, from the stream-json control
|
||||
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
|
||||
/// an `AskUserQuestion`. Pure data; the decision goes back via
|
||||
@@ -240,6 +301,20 @@ class SessionEnd {
|
||||
|
||||
final int exitCode;
|
||||
final List<String> stderrTail;
|
||||
|
||||
/// The most recent non-empty stderr line — the CLI's own error message when
|
||||
/// it dies (e.g. "Session ID … is already in use") — for surfacing in the
|
||||
/// pane so a non-zero exit is never an opaque "code 1" (T-437). Empty when
|
||||
/// stderr was silent; capped so a stray long line can't blow out the status
|
||||
/// line.
|
||||
String get reason {
|
||||
for (final line in stderrTail.reversed) {
|
||||
final t = line.trim();
|
||||
if (t.isEmpty) continue;
|
||||
return t.length > 200 ? '${t.substring(0, 200)}…' : t;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
class StreamJsonSession {
|
||||
@@ -261,6 +336,27 @@ class StreamJsonSession {
|
||||
String? _claudeSessionId;
|
||||
int _localSeq = 0;
|
||||
|
||||
/// The `initialize` handshake's request id — its control_response carries
|
||||
/// the selectable `models[]` (T-408).
|
||||
String? _initRequestId;
|
||||
|
||||
/// In-flight `set_model` request ids → the model the status held before the
|
||||
/// optimistic merge, so an error response can roll it back (T-408).
|
||||
final _pendingSetModel = <String, String?>{};
|
||||
|
||||
List<ModelOption> _availableModels = const [];
|
||||
|
||||
/// Models selectable for this session, from the `initialize` response.
|
||||
/// Empty until that response arrives (callers fall back to
|
||||
/// [kFallbackModels]).
|
||||
List<ModelOption> get availableModels => _availableModels;
|
||||
|
||||
final _modelErrorCtl = StreamController<String>.broadcast();
|
||||
|
||||
/// Errors from rejected `set_model` requests (e.g. an unknown model name),
|
||||
/// for the pane to surface (T-408).
|
||||
Stream<String> get modelErrors => _modelErrorCtl.stream;
|
||||
|
||||
/// Token-by-token streaming state (T-168, wire shape verified by T-184).
|
||||
///
|
||||
/// With `--include-partial-messages`, claude emits the in-progress reply as
|
||||
@@ -309,6 +405,20 @@ class StreamJsonSession {
|
||||
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
||||
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
|
||||
|
||||
/// Live Workflow runs, keyed by their launching `Workflow` tool-use id
|
||||
/// (T-416). Accumulated from the out-of-band `system` task_* events the
|
||||
/// harness emits while a workflow runs in the background; the conversation
|
||||
/// card and the sidebar indicator both read this snapshot. Ephemeral — the
|
||||
/// events aren't in the resumed transcript, so this is empty on reload.
|
||||
final _workflows = <String, WorkflowRun>{};
|
||||
final _workflowsCtl = ValueStream<Map<String, WorkflowRun>>.seeded(const {});
|
||||
|
||||
/// The current workflow runs, keyed by launching tool-use id.
|
||||
Map<String, WorkflowRun> get workflows => Map.unmodifiable(_workflows);
|
||||
|
||||
/// Emits the workflow-run map whenever a `system` task event updates it.
|
||||
Stream<Map<String, WorkflowRun>> get workflowsStream => _workflowsCtl.stream;
|
||||
|
||||
/// Whether a turn is in flight (between a send and claude's `result`). Drives
|
||||
/// the composer's Stop affordance.
|
||||
bool _busy = false;
|
||||
@@ -368,14 +478,15 @@ class StreamJsonSession {
|
||||
// code is not (T-361).
|
||||
final exit = _proc.exitCode;
|
||||
if (exit != null) unawaited(exit.then(_onExit));
|
||||
// Declaring our in-process MCP servers in the `initialize` handshake is what
|
||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
|
||||
// when we actually host a server, so a plain session is unchanged.
|
||||
if (_mcpServers.isNotEmpty) {
|
||||
// The `initialize` handshake is side-effect-free (verified in the protocol
|
||||
// spike) and does double duty: declaring our in-process MCP servers is what
|
||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
|
||||
// response's `models[]` feeds the /model picker (T-408).
|
||||
_initRequestId = 'init-${_localSeq++}';
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'init-${_localSeq++}',
|
||||
'request_id': _initRequestId,
|
||||
'request': {
|
||||
'subtype': 'initialize',
|
||||
'hooks': <String, dynamic>{},
|
||||
@@ -384,7 +495,6 @@ class StreamJsonSession {
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onLine(String line) {
|
||||
final trimmed = line.trim();
|
||||
@@ -411,6 +521,12 @@ class StreamJsonSession {
|
||||
_onControlRequest(ev);
|
||||
return;
|
||||
}
|
||||
// Responses to OUR control requests: the initialize result (models) and
|
||||
// set_model acks/errors (T-408).
|
||||
if (ev['type'] == 'control_response') {
|
||||
_onControlResponse(ev);
|
||||
return;
|
||||
}
|
||||
// A `result` ends the turn — clear the busy/interruptible state and reset
|
||||
// streaming state so the next turn is fresh.
|
||||
if (ev['type'] == 'result') {
|
||||
@@ -427,6 +543,15 @@ class StreamJsonSession {
|
||||
return;
|
||||
}
|
||||
|
||||
// Workflow run progress (T-416): the harness reports a backgrounded Workflow
|
||||
// tool's fan-out on out-of-band `system` task_* events keyed by the
|
||||
// launching tool-use id. Fold them into the run snapshot and notify; they
|
||||
// carry no conversation item, so don't fall through to the parser.
|
||||
if (isWorkflowSystemEvent(ev)) {
|
||||
_onWorkflowEvent(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalise a streamed reply: when the real text `assistant` event for a
|
||||
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
|
||||
// so the controller replaces the placeholder in place rather than appending
|
||||
@@ -500,6 +625,15 @@ class StreamJsonSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one workflow `system` task event into its run snapshot, keyed by the
|
||||
/// launching tool-use id, and publish the updated map (T-416).
|
||||
void _onWorkflowEvent(Map<String, dynamic> ev) {
|
||||
final id = ev['tool_use_id'] as String;
|
||||
final prior = _workflows[id] ?? WorkflowRun(toolUseId: id);
|
||||
_workflows[id] = prior.foldEvent(ev);
|
||||
_workflowsCtl.add(Map.unmodifiable(_workflows));
|
||||
}
|
||||
|
||||
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
|
||||
/// item the UI resolves; every other subtype is answered with an error so
|
||||
/// the turn never hangs waiting on us (D-78).
|
||||
@@ -742,6 +876,18 @@ class StreamJsonSession {
|
||||
_setBusy(true);
|
||||
}
|
||||
|
||||
/// Inject a clide-local notice card into the conversation — nothing is sent
|
||||
/// to claude. Used by the slash-command router for TUI-only commands
|
||||
/// (T-411); renders as the muted synthetic "clide" card.
|
||||
void addLocalNotice(String text) {
|
||||
_items.add(AssistantTextMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text, synthetic: true));
|
||||
}
|
||||
|
||||
/// Record the effort level this session was spawned with (`--effort`,
|
||||
/// T-412). The wire never reports effort, so the spawner tells the status
|
||||
/// what it set; the status line / sidebar read it from [SessionStatus].
|
||||
void noteEffort(String level) => _mergeStatus(SessionStatus(effort: level));
|
||||
|
||||
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
|
||||
/// the `interrupt` control_request; claude cancels the current turn and ends
|
||||
/// it with a `result`, which clears [busy]. Safe to call when idle.
|
||||
@@ -778,6 +924,59 @@ class StreamJsonSession {
|
||||
_mergeStatus(SessionStatus(permissionMode: mode));
|
||||
}
|
||||
|
||||
/// Set the model for subsequent turns (T-408). Sends a `set_model`
|
||||
/// control_request; [model] is an alias (`sonnet`, `opus`) or full id, and
|
||||
/// `default` resets to the CLI's configured model. The status merges
|
||||
/// optimistically (mirroring [setPermissionMode]); an error response rolls
|
||||
/// it back and surfaces on [modelErrors].
|
||||
void setModel(String model) {
|
||||
final rid = 'set-model-${_localSeq++}';
|
||||
_pendingSetModel[rid] = _status.model;
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': rid,
|
||||
'request': {'subtype': 'set_model', 'model': model},
|
||||
}),
|
||||
);
|
||||
// `default` resolves to a model only the CLI knows — leave the status to
|
||||
// the next assistant event in that case.
|
||||
if (model != 'default') _mergeStatus(SessionStatus(model: model));
|
||||
}
|
||||
|
||||
/// A `control_response` to one of our requests: capture the initialize
|
||||
/// result's `models[]`, and roll back + surface a rejected set_model (T-408).
|
||||
void _onControlResponse(Map<String, dynamic> ev) {
|
||||
final resp = ev['response'];
|
||||
if (resp is! Map) return;
|
||||
final rid = resp['request_id'] as String?;
|
||||
if (rid == null) return;
|
||||
final isError = resp['subtype'] == 'error';
|
||||
if (rid == _initRequestId && !isError) {
|
||||
final result = resp['response'];
|
||||
final models = result is Map ? result['models'] : null;
|
||||
if (models is List) {
|
||||
_availableModels = List.unmodifiable([
|
||||
for (final m in models)
|
||||
if (m is Map && m['value'] is String)
|
||||
ModelOption(
|
||||
value: m['value'] as String,
|
||||
displayName: m['displayName'] as String? ?? m['value'] as String,
|
||||
description: m['description'] as String? ?? '',
|
||||
),
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_pendingSetModel.containsKey(rid)) {
|
||||
final previous = _pendingSetModel.remove(rid);
|
||||
if (isError) {
|
||||
if (previous != null) _mergeStatus(SessionStatus(model: previous));
|
||||
_modelErrorCtl.add(resp['error'] as String? ?? 'model change rejected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process exited under a live session. Flip every "in flight"
|
||||
/// surface off so the pane reflects reality instead of spinning forever.
|
||||
void _onExit(int code) {
|
||||
@@ -793,15 +992,24 @@ class StreamJsonSession {
|
||||
_endCtl.add(_end!);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
/// Idempotent: the conversation controller's [dispose] fires this
|
||||
/// unawaited while a caller (the orchestrator's [ClaudeSessionOrchestrator.close])
|
||||
/// awaits it to know the process is truly dead (T-437). Caching the future
|
||||
/// makes both paths share one teardown rather than killing/closing twice.
|
||||
Future<void> dispose() => _disposeFuture ??= _dispose();
|
||||
Future<void>? _disposeFuture;
|
||||
|
||||
Future<void> _dispose() async {
|
||||
_disposed = true; // deliberate teardown — suppress the exit-watch path
|
||||
await _sub?.cancel();
|
||||
await _proc.kill();
|
||||
await _proc.kill(); // awaits the process's real exit (T-437)
|
||||
await _items.close();
|
||||
await _statusCtl.close();
|
||||
await _workflowsCtl.close();
|
||||
await _sessionIdCtl.close();
|
||||
await _pendingCtl.close();
|
||||
await _busyCtl.close();
|
||||
await _endCtl.close();
|
||||
await _modelErrorCtl.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
/// and the full workspace pane read from this one model — they share state,
|
||||
/// they do NOT each hold their own copy.
|
||||
///
|
||||
/// [postAsUser] is the user's write path: it routes by @tag (one agent or
|
||||
/// broadcast) and, when the interrupt flag is set, calls [interrupt()] on the
|
||||
/// `postAsUser` is the user's write path: it routes by @tag (one agent or
|
||||
/// broadcast) and, when the interrupt flag is set, calls `interrupt()` on the
|
||||
/// target session THEN delivers the message.
|
||||
///
|
||||
/// Flutter-free on purpose: this module (like [TeamBroker]) runs under
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
///
|
||||
/// Displays the live broker chat timeline as colour-coded rows and provides a
|
||||
/// quick @-post composer. Tapping the pop-out icon opens the full chat pane
|
||||
/// ([claude.team-chat] workspace tab).
|
||||
/// (`claude.team-chat` workspace tab).
|
||||
///
|
||||
/// Both this widget and [TeamChatPane] read from the same [TeamChatModel] —
|
||||
/// there is one model, two surfaces.
|
||||
@@ -122,7 +122,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final messages = widget.model.messages;
|
||||
|
||||
return Column(
|
||||
@@ -291,7 +291,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final messages = widget.model.messages;
|
||||
|
||||
return Column(
|
||||
|
||||
@@ -73,7 +73,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_members.isEmpty) return widget.lead;
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -178,7 +178,7 @@ class _TeammateTile extends StatelessWidget {
|
||||
children: [
|
||||
Container(width: 3, height: 13, color: accent),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(member.name, fontSize: clideFontSmall, color: accent, fontFamily: clideMonoFamily),
|
||||
ClideText(member.name, fontSize: clideFontSmall, color: accent, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
///
|
||||
/// # Version drift-guard
|
||||
/// If the envelope `version` field has an unfamiliar major version the reader
|
||||
/// warns via [onWarn] (or stderr if omitted) and degrades gracefully — it
|
||||
/// warns via `onWarn` (or stderr if omitted) and degrades gracefully — it
|
||||
/// parses whatever it can and skips the rest rather than crashing.
|
||||
library;
|
||||
|
||||
@@ -114,12 +114,19 @@ final class AssistantTextMessage extends ConversationItem {
|
||||
super.parentUuid,
|
||||
super.parentToolUseId,
|
||||
required this.text,
|
||||
this.synthetic = false,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
/// CLI-local output, not the model: the wire marks it `model: "<synthetic>"`
|
||||
/// (a forwarded local command's response — /usage output, "/x isn't
|
||||
/// available in this environment", …). clide-injected notices use it too.
|
||||
/// Rendered as a muted "clide" card, never coral Claude prose (T-411).
|
||||
final bool synthetic;
|
||||
|
||||
@override
|
||||
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars)';
|
||||
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars${synthetic ? ', synthetic' : ''})';
|
||||
}
|
||||
|
||||
/// Extended thinking block from an assistant turn.
|
||||
@@ -220,7 +227,7 @@ class TranscriptReader {
|
||||
/// [pollInterval] controls how often the reader polls for new data and
|
||||
/// session switches (default 500 ms).
|
||||
///
|
||||
/// [onWarn] receives warning messages from the version drift-guard.
|
||||
/// `onWarn` receives warning messages from the version drift-guard.
|
||||
/// If omitted, warnings are written to stderr.
|
||||
TranscriptReader(
|
||||
this.workspacePath, {
|
||||
@@ -412,7 +419,7 @@ class TranscriptReader {
|
||||
}
|
||||
|
||||
/// Parse a single JSONL line into its items (forwarding any version
|
||||
/// warnings to [onWarn]). Public so tests exercise the real parser.
|
||||
/// warnings to `onWarn`). Public so tests exercise the real parser.
|
||||
List<ConversationItem> parseLine(String line) {
|
||||
final parsed = parseTranscriptChunk(line);
|
||||
for (final w in parsed.warnings) {
|
||||
@@ -426,7 +433,7 @@ class TranscriptReader {
|
||||
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
|
||||
/// and the reader [merge]s deltas into a running status.
|
||||
class SessionStatus {
|
||||
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo});
|
||||
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo, this.effort});
|
||||
|
||||
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
|
||||
final String? model;
|
||||
@@ -451,7 +458,13 @@ class SessionStatus {
|
||||
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
|
||||
final String? rateLimitInfo;
|
||||
|
||||
bool get isEmpty => model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null;
|
||||
/// The session's effort level (`--effort`, T-412). The wire never reports
|
||||
/// it — clide records what it spawned with via [StreamJsonSession.noteEffort];
|
||||
/// null means the CLI default (settings.json `effortLevel`).
|
||||
final String? effort;
|
||||
|
||||
bool get isEmpty =>
|
||||
model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null && effort == null;
|
||||
|
||||
/// Overlay [other]'s non-null fields onto this one.
|
||||
SessionStatus merge(SessionStatus other) => SessionStatus(
|
||||
@@ -461,6 +474,7 @@ class SessionStatus {
|
||||
cost: other.cost ?? cost,
|
||||
contextWindow: other.contextWindow ?? contextWindow,
|
||||
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
|
||||
effort: other.effort ?? effort,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -471,10 +485,11 @@ class SessionStatus {
|
||||
other.contextTokens == contextTokens &&
|
||||
other.cost == cost &&
|
||||
other.contextWindow == contextWindow &&
|
||||
other.rateLimitInfo == rateLimitInfo;
|
||||
other.rateLimitInfo == rateLimitInfo &&
|
||||
other.effort == effort;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo);
|
||||
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo, effort);
|
||||
}
|
||||
|
||||
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and
|
||||
@@ -582,7 +597,9 @@ void _extractAssistantStatus(Map<String, dynamic> envelope, _StatusAcc status) {
|
||||
final message = envelope['message'] as Map?;
|
||||
if (message == null) return;
|
||||
final model = message['model'] as String?;
|
||||
if (model != null && model.isNotEmpty) status.model = model;
|
||||
// "<synthetic>" marks CLI-local output (a forwarded local command's
|
||||
// response) — not a model switch; it must not clobber the tracked model.
|
||||
if (model != null && model.isNotEmpty && model != kSyntheticModel) status.model = model;
|
||||
final usage = message['usage'] as Map?;
|
||||
if (usage != null) {
|
||||
int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
|
||||
@@ -656,6 +673,9 @@ void _parseUserInto(
|
||||
}
|
||||
}
|
||||
|
||||
/// The model marker on CLI-local output (forwarded local-command responses).
|
||||
const String kSyntheticModel = '<synthetic>';
|
||||
|
||||
void _parseAssistantInto(
|
||||
Map<String, dynamic> envelope,
|
||||
String uuid,
|
||||
@@ -669,6 +689,7 @@ void _parseAssistantInto(
|
||||
if (message == null) return;
|
||||
final content = message['content'];
|
||||
if (content is! List) return;
|
||||
final synthetic = (message['model'] as String?) == kSyntheticModel;
|
||||
|
||||
for (final item in content) {
|
||||
if (item is! Map) continue;
|
||||
@@ -684,6 +705,7 @@ void _parseAssistantInto(
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
text: text,
|
||||
synthetic: synthetic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/// Live state of a Claude Code Workflow run (T-416).
|
||||
///
|
||||
/// A Workflow is the harness's multi-agent orchestration tool. The model calls
|
||||
/// it as an ordinary `tool_use` (`name: "Workflow"`, `input: {script}`); the
|
||||
/// tool returns immediately ("launched in background") and the run's real
|
||||
/// progress arrives out-of-band on stream-json `type: "system"` events keyed by
|
||||
/// the launching tool-use id. This file is the pure, Flutter-free model that
|
||||
/// folds those events into a snapshot the conversation/sidebar surfaces render.
|
||||
///
|
||||
/// Wire shape (captured by the T-416 spike, claude 2.1.175):
|
||||
/// - `task_started` — task_id, tool_use_id, description, workflow_name,
|
||||
/// prompt (script source)
|
||||
/// - `task_progress` — usage{total_tokens,tool_uses,duration_ms}, summary,
|
||||
/// and `workflow_progress[]`, a DELTA list mixing
|
||||
/// `{type:"workflow_phase", index, title}` and
|
||||
/// `{type:"workflow_agent", index, label, phaseIndex?,
|
||||
/// phaseTitle?, model, state(start|progress|done),
|
||||
/// agentId?}` — partial, merged by index.
|
||||
/// - `task_updated` — patch{status, end_time}
|
||||
/// - `task_notification` — terminal status:"completed", summary, usage
|
||||
///
|
||||
/// Limit: these events are ephemeral (not persisted to the resumed transcript
|
||||
/// JSONL), so live progress shows during the session; on reload only the tool
|
||||
/// card + its "launched in background" result survive.
|
||||
library;
|
||||
|
||||
/// Lifecycle of a single workflow agent, from its `state` field.
|
||||
enum WorkflowAgentState { start, progress, done, unknown }
|
||||
|
||||
WorkflowAgentState parseWorkflowAgentState(Object? raw) => switch (raw) {
|
||||
'start' || 'queued' || 'running' => WorkflowAgentState.start,
|
||||
'progress' => WorkflowAgentState.progress,
|
||||
'done' || 'complete' || 'completed' => WorkflowAgentState.done,
|
||||
_ => WorkflowAgentState.unknown,
|
||||
};
|
||||
|
||||
/// One phase declared by `meta.phases` / a `phase()` call.
|
||||
class WorkflowPhase {
|
||||
const WorkflowPhase({required this.index, required this.title});
|
||||
|
||||
final int index;
|
||||
final String title;
|
||||
}
|
||||
|
||||
/// One agent fanned out by the workflow. Fields accrete across `task_progress`
|
||||
/// deltas — a later delta fills in `agentId` / upgrades `model` / advances
|
||||
/// `state`, so [mergeDelta] overlays non-null fields onto the prior snapshot.
|
||||
class WorkflowAgent {
|
||||
const WorkflowAgent({
|
||||
required this.index,
|
||||
required this.label,
|
||||
this.model,
|
||||
this.state = WorkflowAgentState.start,
|
||||
this.agentId,
|
||||
this.phaseIndex,
|
||||
this.phaseTitle,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final String label;
|
||||
final String? model;
|
||||
final WorkflowAgentState state;
|
||||
final String? agentId;
|
||||
final int? phaseIndex;
|
||||
final String? phaseTitle;
|
||||
|
||||
/// Fold a raw `workflow_agent` delta entry onto this snapshot, keeping prior
|
||||
/// values where the delta omits a field.
|
||||
WorkflowAgent mergeDelta(Map<String, dynamic> e) => WorkflowAgent(
|
||||
index: index,
|
||||
label: (e['label'] as String?)?.isNotEmpty == true ? e['label'] as String : label,
|
||||
model: (e['model'] as String?) ?? model,
|
||||
state: e.containsKey('state') ? parseWorkflowAgentState(e['state']) : state,
|
||||
agentId: (e['agentId'] as String?) ?? agentId,
|
||||
phaseIndex: (e['phaseIndex'] as num?)?.toInt() ?? phaseIndex,
|
||||
phaseTitle: (e['phaseTitle'] as String?) ?? phaseTitle,
|
||||
);
|
||||
|
||||
static WorkflowAgent fromDelta(Map<String, dynamic> e) => WorkflowAgent(
|
||||
index: (e['index'] as num).toInt(),
|
||||
label: (e['label'] as String?) ?? '',
|
||||
model: e['model'] as String?,
|
||||
state: parseWorkflowAgentState(e['state']),
|
||||
agentId: e['agentId'] as String?,
|
||||
phaseIndex: (e['phaseIndex'] as num?)?.toInt(),
|
||||
phaseTitle: e['phaseTitle'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// An immutable snapshot of one workflow run. [foldEvent] returns a new snapshot
|
||||
/// with a single `system` task event applied (the session keeps one per
|
||||
/// launching tool-use id and replaces it as events arrive).
|
||||
class WorkflowRun {
|
||||
const WorkflowRun({
|
||||
required this.toolUseId,
|
||||
this.taskId,
|
||||
this.name,
|
||||
this.description,
|
||||
this.summary,
|
||||
this.done = false,
|
||||
this.totalTokens,
|
||||
this.toolUses,
|
||||
this.durationMs,
|
||||
this.phases = const {},
|
||||
this.agents = const {},
|
||||
});
|
||||
|
||||
/// The launching `Workflow` tool-use id — the join key to the conversation
|
||||
/// card and across all of this run's system events.
|
||||
final String toolUseId;
|
||||
|
||||
/// The harness task id (e.g. `wy01fihjt`), assigned at `task_started`.
|
||||
final String? taskId;
|
||||
|
||||
/// `workflow_name` from `meta.name`.
|
||||
final String? name;
|
||||
final String? description;
|
||||
final String? summary;
|
||||
|
||||
/// True once a `task_updated{status:completed}` or `task_notification`
|
||||
/// terminal event lands.
|
||||
final bool done;
|
||||
|
||||
final int? totalTokens;
|
||||
final int? toolUses;
|
||||
final int? durationMs;
|
||||
|
||||
/// Phase index → phase. Empty for a phase-less workflow.
|
||||
final Map<int, WorkflowPhase> phases;
|
||||
|
||||
/// Agent index → agent snapshot.
|
||||
final Map<int, WorkflowAgent> agents;
|
||||
|
||||
bool get running => !done;
|
||||
int get agentCount => agents.length;
|
||||
int get doneCount => agents.values.where((a) => a.state == WorkflowAgentState.done).length;
|
||||
|
||||
/// Agents in index order — the order the script fanned them out.
|
||||
List<WorkflowAgent> get orderedAgents {
|
||||
final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Phases in index order.
|
||||
List<WorkflowPhase> get orderedPhases {
|
||||
final list = phases.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
return list;
|
||||
}
|
||||
|
||||
WorkflowRun _copyWith({
|
||||
String? taskId,
|
||||
String? name,
|
||||
String? description,
|
||||
String? summary,
|
||||
bool? done,
|
||||
int? totalTokens,
|
||||
int? toolUses,
|
||||
int? durationMs,
|
||||
Map<int, WorkflowPhase>? phases,
|
||||
Map<int, WorkflowAgent>? agents,
|
||||
}) => WorkflowRun(
|
||||
toolUseId: toolUseId,
|
||||
taskId: taskId ?? this.taskId,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
summary: summary ?? this.summary,
|
||||
done: done ?? this.done,
|
||||
totalTokens: totalTokens ?? this.totalTokens,
|
||||
toolUses: toolUses ?? this.toolUses,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
phases: phases ?? this.phases,
|
||||
agents: agents ?? this.agents,
|
||||
);
|
||||
|
||||
/// Apply one `system` task event ([ev]) and return the updated snapshot.
|
||||
/// [ev] must already be the decoded envelope; unknown subtypes return `this`.
|
||||
WorkflowRun foldEvent(Map<String, dynamic> ev) {
|
||||
switch (ev['subtype']) {
|
||||
case 'task_started':
|
||||
return _copyWith(taskId: ev['task_id'] as String?, name: ev['workflow_name'] as String?, description: ev['description'] as String?);
|
||||
case 'task_progress':
|
||||
return _foldProgress(ev);
|
||||
case 'task_updated':
|
||||
final patch = ev['patch'];
|
||||
final status = patch is Map ? patch['status'] as String? : null;
|
||||
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null);
|
||||
case 'task_notification':
|
||||
final status = ev['status'] as String?;
|
||||
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null, summary: ev['summary'] as String?)._foldUsage(ev['usage']);
|
||||
default:
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
WorkflowRun _foldProgress(Map<String, dynamic> ev) {
|
||||
final phases = Map<int, WorkflowPhase>.from(this.phases);
|
||||
final agents = Map<int, WorkflowAgent>.from(this.agents);
|
||||
final progress = ev['workflow_progress'];
|
||||
if (progress is List) {
|
||||
for (final raw in progress) {
|
||||
if (raw is! Map) continue;
|
||||
final e = raw.cast<String, dynamic>();
|
||||
final idx = (e['index'] as num?)?.toInt();
|
||||
if (idx == null) continue;
|
||||
switch (e['type']) {
|
||||
case 'workflow_phase':
|
||||
phases[idx] = WorkflowPhase(index: idx, title: (e['title'] as String?) ?? 'phase $idx');
|
||||
case 'workflow_agent':
|
||||
final prior = agents[idx];
|
||||
agents[idx] = prior != null ? prior.mergeDelta(e) : WorkflowAgent.fromDelta(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return _copyWith(summary: ev['summary'] as String?, phases: phases, agents: agents)._foldUsage(ev['usage']);
|
||||
}
|
||||
|
||||
WorkflowRun _foldUsage(Object? usage) {
|
||||
if (usage is! Map) return this;
|
||||
return _copyWith(
|
||||
totalTokens: (usage['total_tokens'] as num?)?.toInt(),
|
||||
toolUses: (usage['tool_uses'] as num?)?.toInt(),
|
||||
durationMs: (usage['duration_ms'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `system` subtypes that carry workflow run progress (T-416). Other system
|
||||
/// subtypes (`init`, `hook_*`, `thinking_tokens`) are unrelated and left alone.
|
||||
const Set<String> kWorkflowSystemSubtypes = {'task_started', 'task_progress', 'task_updated', 'task_notification'};
|
||||
|
||||
/// True when [ev] is a `system` event carrying workflow run progress that names
|
||||
/// a launching tool-use id we can key on.
|
||||
bool isWorkflowSystemEvent(Map<String, dynamic> ev) =>
|
||||
ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true;
|
||||
@@ -92,8 +92,8 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
final d = _decision;
|
||||
if (d == null) return const Padding(padding: EdgeInsets.all(12), child: ClideText('Select a decision to view details.', muted: true));
|
||||
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final isDark = ClideSettings.theme.of(context).dark;
|
||||
final typeColors = DecisionTypeColors.forTheme(dark: isDark);
|
||||
|
||||
final id = d['id'] as String? ?? '';
|
||||
@@ -148,19 +148,22 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: clideMonoFamily),
|
||||
ClideText(id, fontSize: clideFontSmall, color: typeColor, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const Spacer(),
|
||||
if (domain != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.panelBorder, borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
child: ClideText(domain, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(title, fontSize: 15, fontWeight: FontWeight.w500),
|
||||
if (date != null) ...[const SizedBox(height: 6), ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: clideMonoFamily)],
|
||||
if (date != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
ClideText(date, muted: true, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
if (status != null && status != 'active') ...[const SizedBox(height: 8), _StatusBadge(status: status, tokens: tokens)],
|
||||
],
|
||||
),
|
||||
@@ -168,7 +171,7 @@ class _DecisionDetailViewState extends State<DecisionDetailView> {
|
||||
if (body != null && body.isNotEmpty) ...[const SizedBox(height: 12), ClideMarkdown(body, onRecordTap: (id) => _navigateToRecord(context, id))],
|
||||
if (refs.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
ClideText('CROSS-REFERENCES', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(height: 6),
|
||||
for (final ref in refs) _RefCard(ref: ref, tokens: tokens),
|
||||
],
|
||||
@@ -201,7 +204,7 @@ class _RefCard extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(targetId, fontSize: clideFontSmall, color: tokens.globalFocus, fontFamily: clideMonoFamily),
|
||||
ClideText(targetId, fontSize: clideFontSmall, color: tokens.globalFocus, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(refType, fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
],
|
||||
@@ -227,7 +230,7 @@ class _StatusBadge extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: color.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText(status.toUpperCase(), fontSize: clideFontBadge, color: color, fontFamily: clideMonoFamily),
|
||||
child: ClideText(status.toUpperCase(), fontSize: clideFontBadge, color: color, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
if (_loading) return const Center(child: ClideText('Loading decisions...', muted: true));
|
||||
if (_error != null) return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
if (_decisions.isEmpty) {
|
||||
@@ -153,7 +153,7 @@ class _DecisionsViewState extends State<DecisionsView> {
|
||||
final questions = filtered.where((d) => d.type == 'question').toList();
|
||||
final rejected = filtered.where((d) => d.type == 'rejected').toList();
|
||||
|
||||
final isDark = ClideTheme.of(context).dark;
|
||||
final isDark = ClideSettings.theme.of(context).dark;
|
||||
final typeColors = DecisionTypeColors.forTheme(dark: isDark);
|
||||
|
||||
return Column(
|
||||
@@ -309,9 +309,10 @@ class _DecisionCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
ClideText(entry.id, fontSize: clideFontSmall, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const Spacer(),
|
||||
if (entry.domain != null) ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
if (entry.domain != null)
|
||||
ClideText(entry.domain!, fontSize: clideFontBadge, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -321,7 +322,7 @@ class _DecisionCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: tokens.statusSuccess.withAlpha(0x30), borderRadius: BorderRadius.circular(3)),
|
||||
child: ClideText('resolved', fontSize: clideFontBadge, color: tokens.statusSuccess, fontFamily: clideMonoFamily),
|
||||
child: ClideText('resolved', fontSize: clideFontBadge, color: tokens.statusSuccess, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -9,7 +9,6 @@ library;
|
||||
import 'package:clide/builtin/deeplink/src/deep_link.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@@ -68,7 +67,7 @@ class _DeepLinkConfirmDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = ClideTheme.of(context).surface;
|
||||
final t = ClideSettings.theme.of(context).surface;
|
||||
return ClideSurface(
|
||||
width: 440,
|
||||
color: t.modalSurfaceBackground,
|
||||
@@ -83,7 +82,7 @@ class _DeepLinkConfirmDialog extends StatelessWidget {
|
||||
const SizedBox(height: 6),
|
||||
ClideText('A clide:// link from outside the app is asking to:', muted: true, fontSize: clideFontSmall),
|
||||
const SizedBox(height: 8),
|
||||
ClideText(action.describe, fontFamily: clideMonoFamily, fontSize: clideFontSmall, color: t.globalForeground),
|
||||
ClideText(action.describe, fontFamily: ClideSettings.fonts.monoOf(context), fontSize: clideFontSmall, color: t.globalForeground),
|
||||
const SizedBox(height: 8),
|
||||
ClideText('Only allow this if you trust where the link came from.', fontSize: clideFontMeta, color: t.statusWarning),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
@@ -53,6 +53,23 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
// Editor split (D-049, D-054)
|
||||
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
|
||||
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
|
||||
// Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every
|
||||
// preset; the vim preset additionally binds gt/gT to these (T-405 part 2,
|
||||
// once a global multi-chord matcher lands — see T-404).
|
||||
CommandContribution(
|
||||
id: 'workspace.tab.next',
|
||||
command: 'workspace.tab.next',
|
||||
title: 'Next Workspace Tab',
|
||||
defaultBinding: 'ctrl+pagedown',
|
||||
run: _nextWorkspaceTab,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'workspace.tab.previous',
|
||||
command: 'workspace.tab.previous',
|
||||
title: 'Previous Workspace Tab',
|
||||
defaultBinding: 'ctrl+pageup',
|
||||
run: _prevWorkspaceTab,
|
||||
),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
@@ -195,6 +212,27 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _nextWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: true);
|
||||
Future<IpcResponse> _prevWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: false);
|
||||
|
||||
/// Cycle the workspace tab strip with wraparound (T-405). A no-op when there
|
||||
/// are fewer than two tabs. Activating a tab also focuses the workspace slot
|
||||
/// so the newly-shown pane takes keyboard focus.
|
||||
Future<IpcResponse> _cycleWorkspaceTab({required bool forward}) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
final tabs = ctx.panels.tabsFor(Slots.workspace);
|
||||
if (tabs.length < 2) return IpcResponse.ok(id: '', data: const {'cycled': false});
|
||||
final active = ctx.panels.activeTabIn(Slots.workspace);
|
||||
final cur = tabs.indexWhere((t) => t.id == active);
|
||||
final start = cur < 0 ? 0 : cur;
|
||||
final next = (start + (forward ? 1 : -1) + tabs.length) % tabs.length;
|
||||
final nextId = tabs[next].id;
|
||||
ctx.panels.activateTab(Slots.workspace, nextId);
|
||||
ctx.focus.setActive(slot: Slots.workspace, contributionId: nextId);
|
||||
return IpcResponse.ok(id: '', data: {'active': nextId});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _focusRight(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
|
||||
@@ -86,7 +86,7 @@ class _DiffViewState extends State<DiffView> {
|
||||
return ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Semantics(
|
||||
label: 'diff view',
|
||||
container: true,
|
||||
@@ -136,7 +136,7 @@ class _DiffToolbar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
@@ -180,7 +180,7 @@ class _FileDiff extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final path = diff['path'] as String? ?? '';
|
||||
final isBinary = diff['binary'] as bool? ?? false;
|
||||
final isNew = diff['new'] as bool? ?? false;
|
||||
@@ -250,7 +250,7 @@ class _HunkView extends StatelessWidget {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: clideMonoFamily),
|
||||
child: ClideText(header, fontSize: clideFontMono, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
),
|
||||
for (final lineObj in lines) _DiffLineRow(line: (lineObj as Map).cast<String, Object?>()),
|
||||
],
|
||||
@@ -264,7 +264,7 @@ class _DiffLineRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final kind = line['kind'] as String? ?? 'context';
|
||||
final text = line['text'] as String? ?? '';
|
||||
final oldLineNo = line['oldLineNo'] as num?;
|
||||
@@ -294,7 +294,7 @@ class _DiffLineRow extends StatelessWidget {
|
||||
oldLineNo != null ? '${oldLineNo.toInt()}' : '',
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
@@ -305,15 +305,22 @@ class _DiffLineRow extends StatelessWidget {
|
||||
newLineNo != null ? '${newLineNo.toInt()}' : '',
|
||||
fontSize: clideFontMono,
|
||||
muted: true,
|
||||
fontFamily: clideMonoFamily,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily),
|
||||
ClideText(prefix, fontSize: clideFontMono, color: fg, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: ClideText(text, fontSize: clideFontMono, color: fg, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.clip),
|
||||
child: ClideText(
|
||||
text,
|
||||
fontSize: clideFontMono,
|
||||
color: fg,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier {
|
||||
_dirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
case 'editor.selection-changed':
|
||||
// An external setSelection moved the caret server-side (find-in-files
|
||||
// line jump, ex-line `:N` goto — T-407). Mirror it onto the active
|
||||
// buffer so the view's caret follows. Skipped while our own local edits
|
||||
// are in flight — their echo already carries the authoritative caret.
|
||||
final id = e.data['id'] as String?;
|
||||
if (id != null && id == _activeId && _pendingLocalEdits == 0) {
|
||||
final sel = e.data['selection'];
|
||||
if (sel is Map) {
|
||||
_selection = Selection.fromJson(sel.cast<String, Object?>());
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
case 'editor.settings-changed':
|
||||
// A source (e.g. a saved .editorconfig) re-resolved the buffer's
|
||||
// settings. Refresh the active buffer's copy so indent/ruler update.
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'vim_edit_ops.dart';
|
||||
|
||||
/// Tier-2 editor pane. Shows one tab per open buffer via the shared
|
||||
/// [MultitabPane] (the same strip the Claude pane uses); the body
|
||||
/// reflects the daemon's active buffer. The daemon ([EditorRegistry])
|
||||
/// reflects the daemon's active buffer. The daemon (`EditorRegistry`)
|
||||
/// is the source of truth for which buffers are open and which is
|
||||
/// active — the local [MultitabController] is reconciled from it, and
|
||||
/// tab gestures (select / close) are routed back as `editor.activate`
|
||||
@@ -54,10 +54,16 @@ class _EditorViewState extends State<EditorView> {
|
||||
super.initState();
|
||||
_text = SyntaxTextController(syntax: _syntax);
|
||||
_focus = FocusNode();
|
||||
_focus.addListener(_onFocusChanged);
|
||||
_text.addListener(_onTextChanged);
|
||||
_tabs.addListener(_onTabsChanged);
|
||||
}
|
||||
|
||||
/// Publish `editor.focused` so non-editor panes can guard their vim nav
|
||||
/// bindings (`!editor.focused`) — when the editor holds focus, j/k/h/l/gg/G
|
||||
/// stay buffer motions; when a pane holds focus they become nav (T-406).
|
||||
void _onFocusChanged() => _keymap?.setScopeFlag('editor.focused', _focus.hasFocus);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -75,12 +81,14 @@ class _EditorViewState extends State<EditorView> {
|
||||
void dispose() {
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.dispose();
|
||||
_focus.removeListener(_onFocusChanged);
|
||||
_focus.dispose();
|
||||
_tabs.removeListener(_onTabsChanged);
|
||||
_tabs.dispose();
|
||||
_controller?.removeListener(_onControllerChanged);
|
||||
_controller?.dispose();
|
||||
_keymap?.removeListener(_onModeChanged);
|
||||
_keymap?.clearScopeFlag('editor.focused');
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -100,12 +108,20 @@ class _EditorViewState extends State<EditorView> {
|
||||
final c = _controller!;
|
||||
_syncTabs(c);
|
||||
_text.updatePath(c.activePath);
|
||||
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
|
||||
if (c.content != _lastRemoteContent) {
|
||||
_lastRemoteContent = c.content;
|
||||
final sel = TextSelection(baseOffset: c.selection.start.clamp(0, c.content.length), extentOffset: c.selection.end.clamp(0, c.content.length));
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.value = TextEditingValue(text: c.content, selection: sel);
|
||||
_text.addListener(_onTextChanged);
|
||||
} else if (sel != _text.value.selection) {
|
||||
// Selection-only change from an external setSelection (ex-line `:N` goto,
|
||||
// find-in-files line jump on the already-active buffer) — content is
|
||||
// unchanged, so move just the caret. The focused field scrolls it into
|
||||
// view (T-407).
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.value = _text.value.copyWith(selection: sel);
|
||||
_text.addListener(_onTextChanged);
|
||||
}
|
||||
setState(() {}); // tab/title refresh
|
||||
}
|
||||
@@ -266,7 +282,13 @@ class _EditorViewState extends State<EditorView> {
|
||||
}
|
||||
|
||||
void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) {
|
||||
if (intent is! InvokeCommandIntent) return;
|
||||
if (intent is! InvokeCommandIntent) {
|
||||
// A typed app intent the matcher fired (e.g. the ex-line `:` open or ZZ).
|
||||
// The editor only owns editor.vim.* / mode commands; bubble anything else
|
||||
// to the app-root Actions so it reaches its global handler (T-407).
|
||||
Actions.maybeInvoke(context, intent);
|
||||
return;
|
||||
}
|
||||
final id = intent.commandId;
|
||||
if (!id.startsWith('editor.vim.')) {
|
||||
// Mode change (vim.mode.*) or any other command.
|
||||
@@ -289,7 +311,7 @@ class _EditorViewState extends State<EditorView> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
_text.tokens = tokens;
|
||||
if (c == null) return const SizedBox.shrink();
|
||||
|
||||
@@ -349,7 +371,12 @@ class _TextBody extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(color: foreground, fontSize: clideFontMono, fontFamily: clideMonoFamily, fontFamilyFallback: clideMonoFamilyFallback);
|
||||
final style = TextStyle(
|
||||
color: foreground,
|
||||
fontSize: clideFontMono,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
fontFamilyFallback: clideMonoFamilyFallback,
|
||||
);
|
||||
final editable = EditableText(
|
||||
controller: controller,
|
||||
focusNode: focus,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export 'src/extension.dart';
|
||||
export 'src/extensions_notice.dart';
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
import 'package:clide/builtin/extensions_ui/src/extensions_notice.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
|
||||
/// Tier-0 stub. Real implementation lands in a later tier; the extension
|
||||
/// is registered so the extensions-ui surface can list it as "installed,
|
||||
/// not yet implemented" and its id is reserved.
|
||||
/// Extensions settings tab (T-456). Built-in extensions are always on and there
|
||||
/// is no third-party install path yet, so the tab is a "watch this space"
|
||||
/// notice pointing at the records that track extension management (D-16 / T-8)
|
||||
/// rather than a toggle list. The real enable/install UI lands with third-party
|
||||
/// (Lua) extensions.
|
||||
class ExtensionsUiExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.extensions-ui';
|
||||
@override
|
||||
String get title => 'Extensions UI';
|
||||
@override
|
||||
String get version => '0.0.0-stub';
|
||||
String get version => '0.1.0';
|
||||
@override
|
||||
List<String> get dependsOn => const [];
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => const [];
|
||||
List<ContributionPoint> get contributions => [
|
||||
SettingsControlContribution(id: 'extensions-ui.notice-control', customId: 'extensions.notice', builder: (_) => const ExtensionsNotice()),
|
||||
const SettingsCategoryContribution(
|
||||
id: 'extensions',
|
||||
category: SettingsCategory(
|
||||
id: 'extensions',
|
||||
title: 'Extensions',
|
||||
iconName: 'puzzle-piece',
|
||||
priority: 80,
|
||||
sections: [
|
||||
SettingsSection(
|
||||
label: '',
|
||||
fields: [SettingsField(key: 'app.extensions._notice', kind: SettingsFieldKind.custom, label: '', customId: 'extensions.notice')],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The Extensions settings tab's "watch this space" notice (T-456).
|
||||
///
|
||||
/// Built-in extensions are always on and there's no third-party install path
|
||||
/// yet, so there's nothing to manage. Rather than ship a toggle list that could
|
||||
/// brick the app, the tab explains that extension management arrives with
|
||||
/// third-party (Lua) extensions and points at the records that track it.
|
||||
/// Rendered inside the section card (no own surface) via the custom-control
|
||||
/// registry.
|
||||
class ExtensionsNotice extends StatelessWidget {
|
||||
const ExtensionsNotice({super.key});
|
||||
|
||||
static const ns = 'builtin.extensions-ui';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final i = ClideSettings.i18n.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.byName('puzzle-piece'), size: 18, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
ClideText(
|
||||
i.string('notice.title', namespace: ns, placeholder: 'Extension management is coming'),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClideText(
|
||||
i.string(
|
||||
'notice.body',
|
||||
namespace: ns,
|
||||
placeholder:
|
||||
'Installing, enabling, and disabling extensions arrives with third-party (Lua) extension support. For now the built-in extensions are always on.',
|
||||
),
|
||||
color: tokens.globalTextMuted,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClideText(
|
||||
i.string('notice.tracked', namespace: ns, placeholder: 'Tracked in T-8 (Tier 6) · D-16'),
|
||||
color: tokens.globalTextMuted,
|
||||
fontSize: clideFontCaption,
|
||||
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,26 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// One row in the flattened, currently-visible tree (T-406). The visible set is
|
||||
/// a pre-order walk of the root plus the children of every expanded directory —
|
||||
/// the same order the tree renders — so a selection cursor can move over it with
|
||||
/// j/k.
|
||||
@immutable
|
||||
class TreeNode {
|
||||
const TreeNode({required this.path, required this.name, required this.isDirectory, required this.depth});
|
||||
|
||||
final String path;
|
||||
final String name;
|
||||
final bool isDirectory;
|
||||
final int depth;
|
||||
}
|
||||
|
||||
class FileTreeController extends ChangeNotifier {
|
||||
FileTreeController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
@@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier {
|
||||
final Map<String, List<FileEntry>> _entries = {};
|
||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
||||
|
||||
/// Display name of the workspace root row ('' path).
|
||||
String get rootName => _rootPath?.split(Platform.pathSeparator).last ?? '';
|
||||
|
||||
// -- Keyboard selection cursor (T-406) -------------------------------------
|
||||
|
||||
/// The path of the currently selected row, or null when nothing is selected.
|
||||
/// '' is the workspace-root row.
|
||||
String? _selectedPath;
|
||||
String? get selectedPath => _selectedPath;
|
||||
|
||||
/// The flattened, currently-visible rows in render order: the root, then the
|
||||
/// children of every expanded directory, depth-first.
|
||||
List<TreeNode> visibleNodes() {
|
||||
final out = <TreeNode>[];
|
||||
if (_rootPath == null) return out;
|
||||
out.add(TreeNode(path: '', name: rootName, isDirectory: true, depth: 0));
|
||||
if (isExpanded('')) _appendChildren('', 1, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
void _appendChildren(String path, int depth, List<TreeNode> out) {
|
||||
final entries = _entries[path];
|
||||
if (entries == null) return;
|
||||
for (final e in entries) {
|
||||
out.add(TreeNode(path: e.path, name: e.name, isDirectory: e.isDirectory, depth: depth));
|
||||
if (e.isDirectory && _expanded.contains(e.path)) _appendChildren(e.path, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
TreeNode? _selectedNode([List<TreeNode>? nodes]) {
|
||||
final list = nodes ?? visibleNodes();
|
||||
for (final n in list) {
|
||||
if (n.path == _selectedPath) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Move the selection cursor [delta] rows (negative = up), clamped to the
|
||||
/// visible list. A first move with nothing selected lands on the first row
|
||||
/// (down) or last row (up).
|
||||
void moveSelection(int delta) {
|
||||
final nodes = visibleNodes();
|
||||
if (nodes.isEmpty) return;
|
||||
final cur = nodes.indexWhere((n) => n.path == _selectedPath);
|
||||
final next = cur < 0 ? (delta > 0 ? 0 : nodes.length - 1) : (cur + delta).clamp(0, nodes.length - 1);
|
||||
if (nodes[next].path == _selectedPath) return;
|
||||
_selectedPath = nodes[next].path;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Select the first ([top]) or last visible row — vim gg / G.
|
||||
void selectEdge({required bool top}) {
|
||||
final nodes = visibleNodes();
|
||||
if (nodes.isEmpty) return;
|
||||
final path = (top ? nodes.first : nodes.last).path;
|
||||
if (path == _selectedPath) return;
|
||||
_selectedPath = path;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Collapse the selected directory, or — if it's already collapsed (or a
|
||||
/// file) — step the selection out to its parent row (vim `h`).
|
||||
Future<void> collapseOrOut() async {
|
||||
final node = _selectedNode();
|
||||
if (node == null) return;
|
||||
if (node.isDirectory && node.path != '' && _expanded.contains(node.path)) {
|
||||
await toggle(node.path); // collapse in place; selection stays on the dir
|
||||
return;
|
||||
}
|
||||
if (node.path == '') return; // already at root
|
||||
_selectedPath = _parentOf(node.path);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Expand the selected directory, or — if it's already expanded — step the
|
||||
/// selection into its first child (vim `l`). A file is a no-op.
|
||||
Future<void> expandOrInto() async {
|
||||
final node = _selectedNode();
|
||||
if (node == null || !node.isDirectory) return;
|
||||
if (!_expanded.contains(node.path)) {
|
||||
await toggle(node.path); // expand
|
||||
return;
|
||||
}
|
||||
final children = _entries[node.path];
|
||||
if (children != null && children.isNotEmpty) {
|
||||
_selectedPath = children.first.path;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the selected row to an action target for the view: a directory to
|
||||
/// toggle, or a file path to open (vim `o` / `enter`). Returns null when
|
||||
/// nothing is selected.
|
||||
({bool isDirectory, String path})? activateTarget() {
|
||||
final node = _selectedNode();
|
||||
if (node == null) return null;
|
||||
return (isDirectory: node.isDirectory, path: node.path);
|
||||
}
|
||||
|
||||
List<FileEntry> allLoadedEntries() {
|
||||
final out = <FileEntry>[];
|
||||
for (final list in _entries.values) {
|
||||
|
||||
@@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget {
|
||||
class _FileTreeViewState extends State<FileTreeView> {
|
||||
FileTreeController? _controller;
|
||||
String _filter = '';
|
||||
final ScrollController _scroll = ScrollController();
|
||||
|
||||
/// Key on the currently-selected row, so a keyboard move can scroll it into
|
||||
/// view (T-406).
|
||||
final GlobalKey _selectedKey = GlobalKey();
|
||||
|
||||
/// Half-page step for ctrl+d / ctrl+u over the flattened tree.
|
||||
static const int _pageStep = 10;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -39,9 +47,52 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onNav(NavIntent intent, int count, FileTreeController c) {
|
||||
switch (intent) {
|
||||
case NavDownIntent():
|
||||
c.moveSelection(count);
|
||||
case NavUpIntent():
|
||||
c.moveSelection(-count);
|
||||
case NavPageDownIntent():
|
||||
c.moveSelection(_pageStep);
|
||||
case NavPageUpIntent():
|
||||
c.moveSelection(-_pageStep);
|
||||
case NavTopIntent():
|
||||
c.selectEdge(top: true);
|
||||
case NavBottomIntent():
|
||||
c.selectEdge(top: false);
|
||||
case NavExpandOrRightIntent():
|
||||
unawaited(c.expandOrInto());
|
||||
case NavCollapseOrLeftIntent():
|
||||
unawaited(c.collapseOrOut());
|
||||
case NavActivateIntent():
|
||||
_activateSelected(c);
|
||||
}
|
||||
}
|
||||
|
||||
void _activateSelected(FileTreeController c) {
|
||||
final t = c.activateTarget();
|
||||
if (t == null) return;
|
||||
if (t.isDirectory) {
|
||||
unawaited(c.toggle(t.path));
|
||||
} else {
|
||||
openWorkspaceFile(ClideKernel.of(context), t.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the selected row into view after the frame it's laid out in.
|
||||
void _ensureSelectedVisible() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _selectedKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
Scrollable.ensureVisible(ctx, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: const Duration(milliseconds: 80));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
@@ -57,6 +108,23 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
final selected = c.selectedPath;
|
||||
if (_filter.isEmpty && selected != null) _ensureSelectedVisible();
|
||||
final scroller = SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0, selectedPath: selected, selectedKey: _selectedKey),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1, selectedPath: selected, selectedKey: _selectedKey),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
||||
@@ -65,20 +133,10 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
label: 'file tree — $rootName',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
|
||||
// region holds focus under the vim preset (T-406). The filter
|
||||
// box sits outside it, so typing a filter is never intercepted.
|
||||
child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -97,11 +155,13 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
}
|
||||
|
||||
class _Children extends StatelessWidget {
|
||||
const _Children({required this.path, required this.controller, required this.depth});
|
||||
const _Children({required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -117,58 +177,67 @@ class _Children extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
|
||||
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
if (controller.isExpanded(e.path))
|
||||
_Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
],
|
||||
)
|
||||
else
|
||||
_FileRow(name: e.name, path: e.path, depth: depth),
|
||||
_FileRow(name: e.name, path: e.path, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirRow extends StatelessWidget {
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = controller.isExpanded(path);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '${expanded ? 'Collapse' : 'Expand'} $name',
|
||||
onTap: () => controller.toggle(path),
|
||||
child: _Row(
|
||||
key: selected ? selectedKey : null,
|
||||
depth: depth,
|
||||
onTap: () => controller.toggle(path),
|
||||
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
|
||||
label: name,
|
||||
rotateLeading: expanded,
|
||||
selected: selected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({required this.name, required this.path, required this.depth});
|
||||
const _FileRow({required this.name, required this.path, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
onTap: () => _openFile(context, path),
|
||||
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
|
||||
child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +249,7 @@ class _FileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
|
||||
const _Row({super.key, required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false, this.selected = false});
|
||||
|
||||
final int depth;
|
||||
final VoidCallback onTap;
|
||||
@@ -188,14 +257,23 @@ class _Row extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final bool rotateLeading;
|
||||
|
||||
/// True when the keyboard selection cursor is on this row (T-406) — draws a
|
||||
/// persistent highlight + accent ring, distinct from transient hover.
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
final leftPadding = 8.0 + (depth * 14.0);
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
decoration: selected
|
||||
? BoxDecoration(
|
||||
color: tokens.sidebarItemHover,
|
||||
border: Border.all(color: tokens.globalFocus, width: 1),
|
||||
)
|
||||
: (hovered ? BoxDecoration(color: tokens.sidebarItemHover) : null),
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -223,7 +301,7 @@ class _FilteredFileRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final tokens = ClideSettings.theme.of(context).surface;
|
||||
return ClideTappable(
|
||||
onTap: () => openWorkspaceFile(ClideKernel.of(context), entry.path),
|
||||
builder: (context, hovered, _) => Container(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user