Compare commits
@@ -112,9 +112,37 @@ tooltip → tooltipBackground / tooltipForeground / tooltipBorder
|
|||||||
dropdown → dropdownBackground / dropdownForeground / dropdownBorder
|
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
|
## Anti-patterns
|
||||||
|
|
||||||
- `globalBackground` for panel fill → use `panelBackground`
|
- `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`
|
- `listItemHoverBackground` in sidebar → use `sidebarItemHover`
|
||||||
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
|
- Tab active bg = `panelBackground` → use `panelHeader` (elevated chrome)
|
||||||
- Tab active border = `globalFocus` → use `panelActiveBorder`
|
- 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
|
#!/bin/sh
|
||||||
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout)
|
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout).
|
||||||
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-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/linux-x64/clide
|
||||||
/native/macos-arm64/clide
|
/native/macos-arm64/clide
|
||||||
/native/macos-x64/clide
|
/native/macos-x64/clide
|
||||||
|
/native/windows-x64/clide.exe
|
||||||
|
/native/windows-x64/clide.obj
|
||||||
|
|
||||||
# -- Test, coverage, profile output ------------------------------------
|
# -- Test, coverage, profile output ------------------------------------
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
|
revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,11 +13,11 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||||
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||||
- platform: web
|
- platform: windows
|
||||||
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||||
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
|
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -232,3 +232,54 @@ 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 ('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 ('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 ('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);
|
||||||
|
|||||||
@@ -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
+141
@@ -16,6 +16,147 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
## [2.4.0] — 2026-06-12
|
||||||
|
|
||||||
### Added
|
### 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.
|
- **`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.
|
- **[`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/).
|
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/).
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bulle
|
|||||||
ci/changelog_gate.sh
|
ci/changelog_gate.sh
|
||||||
|
|
||||||
.PHONY: smoke-bundle
|
.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
|
ci/smoke_bundle.sh
|
||||||
|
|
||||||
# -- web UI harness ------------------------------------------------------
|
# -- 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).
|
build-macos: gen-build-info ## flutter build macos (desktop bundle).
|
||||||
flutter build macos
|
flutter build macos
|
||||||
|
|
||||||
|
.PHONY: build-windows
|
||||||
|
build-windows: gen-build-info ## flutter build windows (desktop bundle).
|
||||||
|
flutter build windows
|
||||||
|
|
||||||
# -- install / uninstall -----------------------------------------------------
|
# -- install / uninstall -----------------------------------------------------
|
||||||
|
|
||||||
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
|
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
|
||||||
@@ -196,6 +200,9 @@ ifeq ($(FLUTTER_OS),linux)
|
|||||||
else ifeq ($(FLUTTER_OS),macos)
|
else ifeq ($(FLUTTER_OS),macos)
|
||||||
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
|
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
|
||||||
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli
|
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
|
endif
|
||||||
|
|
||||||
ICON_SIZES := 16 32 48 128 192 256 512
|
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.
|
# target picks up whatever `cc` is on PATH.
|
||||||
|
|
||||||
CLIDE_CLI_SRC := native/clide-cli/clide.c
|
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
|
CC ?= cc
|
||||||
|
|
||||||
.PHONY: clide-cli
|
.PHONY: clide-cli
|
||||||
@@ -299,7 +310,11 @@ clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
|
|||||||
|
|
||||||
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
|
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
|
||||||
@mkdir -p $(dir $(CLIDE_CLI_BIN))
|
@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)
|
$(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC)
|
||||||
|
endif
|
||||||
@echo "==> built $(CLIDE_CLI_BIN)"
|
@echo "==> built $(CLIDE_CLI_BIN)"
|
||||||
|
|
||||||
.PHONY: clide-cli-clean
|
.PHONY: clide-cli-clean
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ bindings:
|
|||||||
- intent: dismiss
|
- intent: dismiss
|
||||||
keys: escape
|
keys: escape
|
||||||
when: quickOpen.open
|
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
|
- intent: findInFiles.open
|
||||||
keys: [ctrl+shift+f, meta+shift+f]
|
keys: [ctrl+shift+f, meta+shift+f]
|
||||||
- intent: focus.nextPanel
|
- intent: focus.nextPanel
|
||||||
@@ -63,6 +68,96 @@ bindings:
|
|||||||
- intent: text.scaleReset
|
- intent: text.scaleReset
|
||||||
keys: [ctrl+0, meta+0]
|
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 ------------------------------------------------
|
# ---- Mode transitions ------------------------------------------------
|
||||||
- intent: command:vim.mode.visual
|
- intent: command:vim.mode.visual
|
||||||
keys: v
|
keys: v
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ self:
|
|||||||
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
|
||||||
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
# (runs implicitly on every build/run/test). Don't hand-edit; bump
|
||||||
# pubspec instead.
|
# pubspec instead.
|
||||||
version: "2.4.0"
|
version: "2.5.0"
|
||||||
homepage: https://github.com/postmeridiem/clide
|
homepage: https://github.com/postmeridiem/clide
|
||||||
license: MIT
|
license: MIT
|
||||||
license_file: assets/LICENSE
|
license_file: assets/LICENSE
|
||||||
|
|||||||
@@ -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
|
# --concurrency=1: these spawn real PTYs and compete for fds when run in
|
||||||
# parallel, which flaked them (registry/session). Serialize — the proper fix
|
# parallel, which flaked them (registry/session). Serialize — the proper fix
|
||||||
# for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193)
|
# 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
|
# The parallel pool excludes both pty (runs under dart test, above) and
|
||||||
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
|
# 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
|
# start" regression gate. Flutter integration tests prefer one file at
|
||||||
# a time on desktop; we iterate to avoid the "Unable to start the app"
|
# a time on desktop; we iterate to avoid the "Unable to start the app"
|
||||||
# error that hits when they run as a batch.
|
# 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
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
for f in integration_test/*_test.dart; do
|
for f in integration_test/*_test.dart; do
|
||||||
echo "==> integration_test: $f"
|
echo "==> integration_test: $f"
|
||||||
flutter test "$f"
|
flutter test -d linux "$f"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -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-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-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-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-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-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_
|
- [D-35: Kanban / waterfall, not Scrum](decisions/process.md#d-35-kanban--waterfall-not-scrum) — _process_
|
||||||
@@ -141,6 +141,7 @@ 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-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-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-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_
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ You might also want, project-permitting:
|
|||||||
- [Q-47: Live mixed documents — implement?](questions/design.md#q-47-live-mixed-documents--implement) — _design_
|
- [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-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-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
|
## Resolved questions
|
||||||
|
|
||||||
@@ -196,6 +197,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-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-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-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
|
## Rejected
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ Toolchain, supply chain, CI, ignore strategy.
|
|||||||
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
|
- **Cost:** Longer PR descriptions for deps; occasional reinvention of a convenience. Accepted.
|
||||||
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
|
- **Raised by:** 2026-04-21 planning; reinforced by user feedback memory.
|
||||||
|
|
||||||
### D-32: CI — Gitea primary, Linux-only runners, not yet activated
|
### D-32: CI — GitHub Actions, Linux + Windows runners, active
|
||||||
- **Date:** 2026-04-21
|
- **Date:** 2026-04-21 (amended 2026-06-15)
|
||||||
- **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.
|
- **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:** 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.
|
- **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:** PRs don't run CI yet; `make push-check` is the gate until activation.
|
- **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.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-42: Dependencies documented in `licenses.yaml`
|
### 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."
|
- **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.
|
- **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?
|
### 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.
|
- **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.
|
- **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).
|
- **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
|
/// 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
|
/// into a list of [RenderGroup]s — each either a first-class [StickyItem] or
|
||||||
/// a foldable [FoldedCluster]. The widget layer renders sticky items as
|
/// 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.
|
/// because the fold rules are the load-bearing part.
|
||||||
library;
|
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
|
// breaks the cluster at every level, including L3, so parallel agents
|
||||||
// never merge into one Activity card.
|
// never merge into one Activity card.
|
||||||
if (isAgentTool(name)) return false;
|
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.
|
// The Edit/Write call stays first-class with its diff at L1/L2.
|
||||||
if (level == FoldLevel.everything) return true;
|
if (level == FoldLevel.everything) return true;
|
||||||
return !isDiffTool(name);
|
return !isDiffTool(name);
|
||||||
|
|||||||
@@ -21,11 +21,15 @@
|
|||||||
/// IO wrapper the orchestrator calls. Flutter-free by design.
|
/// IO wrapper the orchestrator calls. Flutter-free by design.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:ffi' show Abi;
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:clide/src/env/shell_env.dart' show resolvedToolPath;
|
||||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
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
|
/// 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.
|
/// 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
|
/// 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;
|
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
|
/// 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.
|
/// spawn args (context note + allow rule) to prepend to a session's argv.
|
||||||
class AgentBootstrap {
|
class AgentBootstrap {
|
||||||
@@ -125,10 +109,13 @@ class AgentBootstrap {
|
|||||||
/// orchestrator merges both into one `--append-system-prompt`.
|
/// orchestrator merges both into one `--append-system-prompt`.
|
||||||
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
|
AgentBootstrap agentBootstrap(String workspaceRoot, {Map<String, String>? base}) {
|
||||||
final home = Platform.environment['HOME'];
|
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>[
|
final candidates = <String>[
|
||||||
if (home != null && home.isNotEmpty) '$home/.local/bin',
|
if (home != null && home.isNotEmpty) '$home/.local/bin',
|
||||||
'$workspaceRoot/native/${nativeClideDirName()}',
|
'$workspaceRoot/native/${currentNativeDirName()}',
|
||||||
File(Platform.resolvedExecutable).parent.path,
|
File(Platform.resolvedExecutable).parent.path,
|
||||||
];
|
];
|
||||||
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
final cliDir = resolveClideCliDir(currentPath: currentPath, candidateDirs: candidates, isExecutableFile: _isExecutableFile);
|
||||||
|
|||||||
@@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
|
|||||||
/// Returns a change stream for [dir] (fires on any file event under it).
|
/// Returns a change stream for [dir] (fires on any file event under it).
|
||||||
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
|
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
|
||||||
|
|
||||||
/// Modest version-agnostic fallback used when the probe is unavailable, so
|
/// Fallback used when the probe is unavailable. Mirrors the builtins a real
|
||||||
/// the typeahead still offers the common built-ins.
|
/// CLI advertises in its stream-json `initialize` handshake (probed against
|
||||||
const List<String> kFallbackSlashCommands = [
|
/// 2.1.175) — i.e. the ones that genuinely work headless. It deliberately
|
||||||
'add-dir',
|
/// does NOT list TUI-only commands (config, permissions, status, doctor, …):
|
||||||
'agents',
|
/// this list doubles as the router's "advertised" set (T-411), and a TUI-only
|
||||||
'clear',
|
/// token here would be forwarded to the CLI and error. The composer unions
|
||||||
'compact',
|
/// [kClideOwnedCommands] on top for the typeahead (T-162).
|
||||||
'config',
|
const List<String> kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage'];
|
||||||
'context',
|
|
||||||
'cost',
|
|
||||||
'doctor',
|
|
||||||
'exit',
|
|
||||||
'help',
|
|
||||||
'init',
|
|
||||||
'mcp',
|
|
||||||
'memory',
|
|
||||||
'model',
|
|
||||||
'permissions',
|
|
||||||
'resume',
|
|
||||||
'review',
|
|
||||||
'status',
|
|
||||||
'usage',
|
|
||||||
];
|
|
||||||
|
|
||||||
class ClaudeConfig extends ChangeNotifier {
|
class ClaudeConfig extends ChangeNotifier {
|
||||||
ClaudeConfig({
|
ClaudeConfig({
|
||||||
@@ -272,6 +257,7 @@ class ClaudeConfig extends ChangeNotifier {
|
|||||||
if (probe == null) return; // stay on the static fallback
|
if (probe == null) return; // stay on the static fallback
|
||||||
_probe = probe;
|
_probe = probe;
|
||||||
await _writeProbeCache(probe);
|
await _writeProbeCache(probe);
|
||||||
|
if (_disposed) return; // a slow probe racing a teardown mustn't notify a disposed notifier
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} finally {
|
} finally {
|
||||||
_probing = false;
|
_probing = false;
|
||||||
@@ -283,6 +269,7 @@ class ClaudeConfig extends ChangeNotifier {
|
|||||||
/// not re-resolved (the binary doesn't change under us at runtime).
|
/// not re-resolved (the binary doesn't change under us at runtime).
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
await _loadDiskConfig();
|
await _loadDiskConfig();
|
||||||
|
if (_disposed) return; // a watcher-driven refresh racing a teardown mustn't notify a disposed notifier
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +280,7 @@ class ClaudeConfig extends ChangeNotifier {
|
|||||||
_stopWatching();
|
_stopWatching();
|
||||||
_projectDir = dir;
|
_projectDir = dir;
|
||||||
await _loadDiskConfig();
|
await _loadDiskConfig();
|
||||||
|
if (_disposed) return; // a project switch racing a teardown mustn't notify a disposed notifier
|
||||||
_startWatchers();
|
_startWatchers();
|
||||||
notifyListeners();
|
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/meta_sidebar/team_tab.dart';
|
||||||
import 'package:clide/builtin/claude/src/session_orchestrator.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/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_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:clide/kernel/kernel.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
@@ -83,7 +85,12 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
StreamSubscription<TeamMemberJoined>? _joinSub;
|
StreamSubscription<TeamMemberJoined>? _joinSub;
|
||||||
StreamSubscription<TeamMemberLeft>? _leftSub;
|
StreamSubscription<TeamMemberLeft>? _leftSub;
|
||||||
StreamSubscription<Message>? _statusSub;
|
StreamSubscription<Message>? _statusSub;
|
||||||
|
StreamSubscription<Message>? _tabSub;
|
||||||
StreamSubscription<SessionStatus>? _primarySub;
|
StreamSubscription<SessionStatus>? _primarySub;
|
||||||
|
StreamSubscription<ConversationItem>? _primaryItemsSub;
|
||||||
|
StreamSubscription<Map<String, WorkflowRun>>? _primaryWorkflowsSub;
|
||||||
|
ClaudeUsage? _usage;
|
||||||
|
Map<String, WorkflowRun> _workflows = const {};
|
||||||
StreamSubscription<void>? _brokerChangeSub;
|
StreamSubscription<void>? _brokerChangeSub;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
late final Future<ClaudeStats> Function() _load;
|
late final Future<ClaudeStats> Function() _load;
|
||||||
@@ -163,6 +170,13 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
_memberStatus.remove(m.agentId);
|
_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).
|
// Live per-member status forwarded by the observer (T-157).
|
||||||
_statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) {
|
_statusSub = kernel.messages.subscribe(channel: ClaudeConversation.memberStatusChannel).listen((msg) {
|
||||||
final agentId = msg.data['agentId'] as String?;
|
final agentId = msg.data['agentId'] as String?;
|
||||||
@@ -191,15 +205,42 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
final session = _orchestrator?.byId('primary')?.session;
|
final session = _orchestrator?.byId('primary')?.session;
|
||||||
_primarySub?.cancel();
|
_primarySub?.cancel();
|
||||||
_primarySub = null;
|
_primarySub = null;
|
||||||
|
_primaryItemsSub?.cancel();
|
||||||
|
_primaryItemsSub = null;
|
||||||
|
_primaryWorkflowsSub?.cancel();
|
||||||
|
_primaryWorkflowsSub = null;
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null);
|
if (mounted && (_primaryStatus != null || _workflows.isNotEmpty)) {
|
||||||
|
setState(() {
|
||||||
|
_primaryStatus = null;
|
||||||
|
_workflows = const {};
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final seed = session.status;
|
final seed = session.status;
|
||||||
if (mounted) setState(() => _primaryStatus = seed);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_primaryStatus = seed;
|
||||||
|
_workflows = session.workflows;
|
||||||
|
});
|
||||||
|
}
|
||||||
_primarySub = session.statusStream.listen((s) {
|
_primarySub = session.statusStream.listen((s) {
|
||||||
if (mounted) setState(() => _primaryStatus = 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() {
|
void _onConfigChange() {
|
||||||
@@ -247,7 +288,10 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
_joinSub?.cancel();
|
_joinSub?.cancel();
|
||||||
_leftSub?.cancel();
|
_leftSub?.cancel();
|
||||||
_statusSub?.cancel();
|
_statusSub?.cancel();
|
||||||
|
_tabSub?.cancel();
|
||||||
_primarySub?.cancel();
|
_primarySub?.cancel();
|
||||||
|
_primaryItemsSub?.cancel();
|
||||||
|
_primaryWorkflowsSub?.cancel();
|
||||||
_brokerChangeSub?.cancel();
|
_brokerChangeSub?.cancel();
|
||||||
_injectCtl.dispose();
|
_injectCtl.dispose();
|
||||||
_config?.removeListener(_onConfigChange);
|
_config?.removeListener(_onConfigChange);
|
||||||
@@ -263,7 +307,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)),
|
SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: switch (_tab) {
|
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(
|
SidebarTab.team => TeamTabView(
|
||||||
members: _members,
|
members: _members,
|
||||||
memberStatus: _memberStatus,
|
memberStatus: _memberStatus,
|
||||||
@@ -303,6 +347,8 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
|
|||||||
),
|
),
|
||||||
SidebarTab.config => ConfigTabView(
|
SidebarTab.config => ConfigTabView(
|
||||||
config: _config,
|
config: _config,
|
||||||
|
status: _primaryStatus,
|
||||||
|
models: _orchestrator?.byId('primary')?.session.availableModels,
|
||||||
expanded: _expanded,
|
expanded: _expanded,
|
||||||
onToggleSection: (section) => setState(() {
|
onToggleSection: (section) => setState(() {
|
||||||
if (_expanded.contains(section)) {
|
if (_expanded.contains(section)) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'clipboard_paste.dart';
|
|||||||
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
||||||
import 'conversation_controller.dart';
|
import 'conversation_controller.dart';
|
||||||
import 'conversation_view.dart';
|
import 'conversation_view.dart';
|
||||||
|
import 'model_picker_card.dart';
|
||||||
import 'permission_mode_control.dart';
|
import 'permission_mode_control.dart';
|
||||||
import 'prompt_card.dart';
|
import 'prompt_card.dart';
|
||||||
import 'session_index.dart';
|
import 'session_index.dart';
|
||||||
@@ -24,6 +25,7 @@ import 'slash_commands.dart';
|
|||||||
import 'stream_json_session.dart';
|
import 'stream_json_session.dart';
|
||||||
import 'task_list.dart';
|
import 'task_list.dart';
|
||||||
import 'transcript_reader.dart';
|
import 'transcript_reader.dart';
|
||||||
|
import 'workflow_run.dart';
|
||||||
|
|
||||||
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
||||||
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
|
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
|
||||||
@@ -73,6 +75,9 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
StreamSubscription<SessionStatus>? _statusSub;
|
StreamSubscription<SessionStatus>? _statusSub;
|
||||||
StreamSubscription<SessionEnd>? _endSub;
|
StreamSubscription<SessionEnd>? _endSub;
|
||||||
StreamSubscription<ProjectOpened>? _projectSub;
|
StreamSubscription<ProjectOpened>? _projectSub;
|
||||||
|
StreamSubscription<Message>? _commandSub;
|
||||||
|
StreamSubscription<String>? _modelErrorSub;
|
||||||
|
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
|
||||||
ConversationController? _conversation;
|
ConversationController? _conversation;
|
||||||
StreamJsonSession? _session;
|
StreamJsonSession? _session;
|
||||||
SessionStatus _status = const SessionStatus();
|
SessionStatus _status = const SessionStatus();
|
||||||
@@ -85,6 +90,17 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
/// /resume, and respawns operate on this pane's own session (T-375).
|
/// /resume, and respawns operate on this pane's own session (T-375).
|
||||||
late String? _forkSource = widget.forkSourceId;
|
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;
|
bool _spawned = false;
|
||||||
|
|
||||||
/// Per-session composer draft (text + caret), held here so an unsent
|
/// Per-session composer draft (text + caret), held here so an unsent
|
||||||
@@ -164,6 +180,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
// GlobalKey and spawns once, so without this it would keep the previous
|
// GlobalKey and spawns once, so without this it would keep the previous
|
||||||
// repo's session after a switch (T-269).
|
// repo's session after a switch (T-269).
|
||||||
_projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
|
_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
|
// Re-fold the conversation when the activity fold-level setting changes
|
||||||
// (claude.activity.fold-level command, T-235).
|
// (claude.activity.fold-level command, T-235).
|
||||||
ClideKernel.of(context).settings.addListener(_onSettingsChanged);
|
ClideKernel.of(context).settings.addListener(_onSettingsChanged);
|
||||||
@@ -179,11 +207,16 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
activeClaudeConfig?.removeListener(_onConfigChanged);
|
activeClaudeConfig?.removeListener(_onConfigChanged);
|
||||||
_kernel?.settings.removeListener(_onSettingsChanged);
|
_kernel?.settings.removeListener(_onSettingsChanged);
|
||||||
_projectSub?.cancel();
|
_projectSub?.cancel();
|
||||||
|
_commandSub?.cancel();
|
||||||
_projectSub = null;
|
_projectSub = null;
|
||||||
_statusSub?.cancel();
|
_statusSub?.cancel();
|
||||||
_statusSub = null;
|
_statusSub = null;
|
||||||
_endSub?.cancel();
|
_endSub?.cancel();
|
||||||
_endSub = null;
|
_endSub = null;
|
||||||
|
_modelErrorSub?.cancel();
|
||||||
|
_modelErrorSub = null;
|
||||||
|
_workflowsSub?.cancel();
|
||||||
|
_workflowsSub = null;
|
||||||
// The orchestrator owns the session, so disposing this pane does NOT kill
|
// 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).
|
// 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;
|
// A secondary tab being *closed* is a real teardown, so close its session;
|
||||||
@@ -237,6 +270,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
_statusSub = null;
|
_statusSub = null;
|
||||||
_endSub?.cancel();
|
_endSub?.cancel();
|
||||||
_endSub = null;
|
_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
|
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
|
||||||
_conversation = null;
|
_conversation = null;
|
||||||
_session = null;
|
_session = null;
|
||||||
@@ -284,7 +324,14 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
_sessionId ??= freshSessionId();
|
_sessionId ??= freshSessionId();
|
||||||
try {
|
try {
|
||||||
managed = await orch.spawn(
|
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) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
||||||
@@ -315,6 +362,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
resume: resume,
|
resume: resume,
|
||||||
transcriptPath: resume ? transcriptFile : null,
|
transcriptPath: resume ? transcriptFile : null,
|
||||||
|
effort: _effort,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -327,6 +375,9 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
|
|
||||||
_session = managed.session;
|
_session = managed.session;
|
||||||
_conversation = managed.conversation;
|
_conversation = managed.conversation;
|
||||||
|
// 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 —
|
// 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
|
// a fresh spawn vs connecting to existing on-disk history (the seed read
|
||||||
// from the transcript/sidecar). Surfaces the resume path in `make run`.
|
// from the transcript/sidecar). Surfaces the resume path in `make run`.
|
||||||
@@ -340,6 +391,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _status = s);
|
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):
|
// Surface a dead process instead of letting it look thoughtful (T-361):
|
||||||
// late binders read the replayed end; live sessions stream it.
|
// late binders read the replayed end; live sessions stream it.
|
||||||
final alreadyEnded = managed.session.end;
|
final alreadyEnded = managed.session.end;
|
||||||
@@ -357,7 +420,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
|
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
|
||||||
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
|
_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
|
// Send composed text to Claude over the stream-json channel. Commands clide
|
||||||
@@ -377,10 +443,152 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
case 'fork':
|
case 'fork':
|
||||||
_forkSession();
|
_forkSession();
|
||||||
return;
|
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);
|
_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),
|
/// Record a submitted prompt in the active session's history (T-163),
|
||||||
/// de-duping immediate repeats. Empty/whitespace prompts are skipped.
|
/// de-duping immediate repeats. Empty/whitespace prompts are skipped.
|
||||||
void _appendHistory(String text) {
|
void _appendHistory(String text) {
|
||||||
@@ -405,7 +613,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
/// background tap must never pull focus from (or resurrect) the composer
|
/// background tap must never pull focus from (or resurrect) the composer
|
||||||
/// over an open prompt.
|
/// over an open prompt.
|
||||||
void _focusComposerOnTap() {
|
void _focusComposerOnTap() {
|
||||||
if (_session?.pendingPrompt != null) return;
|
if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen || _permissionPickerOpen) return;
|
||||||
_composerFocus.requestFocus();
|
_composerFocus.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +685,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
_statusSub = null;
|
_statusSub = null;
|
||||||
_endSub?.cancel();
|
_endSub?.cancel();
|
||||||
_endSub = null;
|
_endSub = null;
|
||||||
|
_modelErrorSub?.cancel();
|
||||||
|
_modelErrorSub = null;
|
||||||
|
_workflowsSub?.cancel();
|
||||||
|
_workflowsSub = null;
|
||||||
|
_modelPickerOpen = false;
|
||||||
|
_effortPickerOpen = false;
|
||||||
|
_permissionPickerOpen = false;
|
||||||
await activeSessionOrchestrator?.close(_orchId); // kills the old session
|
await activeSessionOrchestrator?.close(_orchId); // kills the old session
|
||||||
// Erase only after the process is dead, so claude isn't mid-write.
|
// Erase only after the process is dead, so claude isn't mid-write.
|
||||||
final root = _repoRoot;
|
final root = _repoRoot;
|
||||||
@@ -532,6 +747,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
||||||
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
||||||
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||||
|
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
|
||||||
emptyState: ClaudeBanner(
|
emptyState: ClaudeBanner(
|
||||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||||
workspace: _repoRoot,
|
workspace: _repoRoot,
|
||||||
@@ -548,9 +764,36 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
),
|
),
|
||||||
// An open prompt takes the composer's space and hides the text
|
// An open prompt takes the composer's space and hides the text
|
||||||
// input until it's answered, so interaction stays out of the
|
// 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)
|
if (prompt != null && _session != null)
|
||||||
ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt)
|
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
|
else
|
||||||
StreamBuilder<bool>(
|
StreamBuilder<bool>(
|
||||||
stream: _session?.busyStream,
|
stream: _session?.busyStream,
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ String nextSafePermissionMode(String current) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Status-line segments split around the permission-mode badge so the UI can
|
/// 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]
|
/// render the mode as an interactive control between them (T-226). `leading`
|
||||||
/// is the model; [trailing] joins context / cost / rate-limit. Either may be
|
/// is the model; `trailing` joins context / cost / rate-limit. Either may be
|
||||||
/// null when there's nothing to show.
|
/// null when there's nothing to show.
|
||||||
({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) {
|
({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) {
|
||||||
final trailing = [
|
final trailing = [
|
||||||
@@ -92,3 +92,41 @@ String formatTokenCount(int n) {
|
|||||||
if (n >= 1000) return '${(n / 1000).round()}k';
|
if (n >= 1000) return '${(n / 1000).round()}k';
|
||||||
return '$n';
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ class ConversationController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a controller fed from the kernel [MessageBus] — it consumes
|
/// Build a controller fed from the kernel [MessageBus] — it consumes
|
||||||
/// the [ConversationItem]s a [TranscriptPublisher] writes onto
|
/// the [ConversationItem]s a `TranscriptPublisher` writes onto
|
||||||
/// [publisher]/[channel]. Decouples the view from the reader so several
|
/// `publisher`/[channel]. Decouples the view from the reader so several
|
||||||
/// panels can render the same conversation (team work, T-139/T-140).
|
/// 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}) {
|
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
|
||||||
final stream = messages
|
final stream = messages
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
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/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_card.dart';
|
||||||
import 'package:clide/builtin/claude/src/conversation_controller.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/file_tail_follower.dart';
|
||||||
import 'package:clide/builtin/claude/src/image_thumbnail.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/prompt_card.dart';
|
||||||
import 'package:clide/builtin/claude/src/transcript_reader.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/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/syntax/language_map.dart';
|
||||||
import 'package:clide/kernel/src/theme/controller.dart';
|
import 'package:clide/kernel/src/theme/controller.dart';
|
||||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
@@ -38,11 +42,18 @@ class ConversationView extends StatefulWidget {
|
|||||||
this.hiddenToolUseIds = const <String>{},
|
this.hiddenToolUseIds = const <String>{},
|
||||||
this.toolUseOutcomes = const <String, bool>{},
|
this.toolUseOutcomes = const <String, bool>{},
|
||||||
this.quietErrorToolUseIds = const <String>{},
|
this.quietErrorToolUseIds = const <String>{},
|
||||||
|
this.workflows = const <String, WorkflowRun>{},
|
||||||
this.foldLevel = FoldLevel.tools,
|
this.foldLevel = FoldLevel.tools,
|
||||||
});
|
});
|
||||||
|
|
||||||
final ConversationController controller;
|
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)
|
/// How aggressively consecutive meta items (tool calls/results, thinking)
|
||||||
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
|
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
|
||||||
final FoldLevel foldLevel;
|
final FoldLevel foldLevel;
|
||||||
@@ -332,6 +343,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
runByToolUseId: fold.runByToolUseId,
|
runByToolUseId: fold.runByToolUseId,
|
||||||
|
workflows: widget.workflows,
|
||||||
),
|
),
|
||||||
FoldedCluster(:final items) => _ActivityCard(
|
FoldedCluster(:final items) => _ActivityCard(
|
||||||
key: ValueKey('cluster.${items.first.uuid}'),
|
key: ValueKey('cluster.${items.first.uuid}'),
|
||||||
@@ -343,6 +355,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
runByToolUseId: fold.runByToolUseId,
|
runByToolUseId: fold.runByToolUseId,
|
||||||
|
workflows: widget.workflows,
|
||||||
),
|
),
|
||||||
EditRun(:final edits) => _EditRunCard(
|
EditRun(:final edits) => _EditRunCard(
|
||||||
key: ValueKey('edits.${edits.first.uuid}'),
|
key: ValueKey('edits.${edits.first.uuid}'),
|
||||||
@@ -376,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
return list;
|
return list;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return ColoredBox(
|
final body = ColoredBox(
|
||||||
color: tokens.panelBackground,
|
color: tokens.panelBackground,
|
||||||
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized,
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,6 +554,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||||
this.runByToolUseId = const <String, List<ConversationItem>>{},
|
this.runByToolUseId = const <String, List<ConversationItem>>{},
|
||||||
|
this.workflows = const <String, WorkflowRun>{},
|
||||||
});
|
});
|
||||||
|
|
||||||
final ConversationItem item;
|
final ConversationItem item;
|
||||||
@@ -538,6 +590,9 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
|
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
|
||||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||||
|
|
||||||
|
/// Live Workflow runs keyed by launching tool-use id (T-416).
|
||||||
|
final Map<String, WorkflowRun> workflows;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final i = item;
|
final i = item;
|
||||||
@@ -577,6 +632,17 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
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
|
// 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
|
// agent with a muted accent, never the coral "claude" brand (T-265). The
|
||||||
// coral claudeAccent is reserved for the real main-thread Claude.
|
// coral claudeAccent is reserved for the real main-thread Claude.
|
||||||
@@ -686,6 +752,13 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
/// and its own per-item mark. An Agent/Task call also nests its visible
|
/// and its own per-item mark. An Agent/Task call also nests its visible
|
||||||
/// sub-agent run in a second collapser below (T-264).
|
/// sub-agent run in a second collapser below (T-264).
|
||||||
Widget _toolUseCollapser(AssistantToolUse t) {
|
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 outcome = toolUseOutcomes[t.toolUseId];
|
||||||
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
|
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
|
||||||
final collapser = ClideCollapserCard(
|
final collapser = ClideCollapserCard(
|
||||||
@@ -729,6 +802,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
|
/// 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
|
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
|
||||||
/// collapse caret — the enclosing collapser owns collapse. Used both as a
|
/// collapse caret — the enclosing collapser owns collapse. Used both as a
|
||||||
@@ -896,6 +1062,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
required this.resultByToolUseId,
|
required this.resultByToolUseId,
|
||||||
required this.promptsByToolUseId,
|
required this.promptsByToolUseId,
|
||||||
required this.runByToolUseId,
|
required this.runByToolUseId,
|
||||||
|
this.workflows = const <String, WorkflowRun>{},
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<ConversationItem> items;
|
final List<ConversationItem> items;
|
||||||
@@ -906,6 +1073,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||||
|
final Map<String, WorkflowRun> workflows;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -927,6 +1095,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
runByToolUseId: runByToolUseId,
|
runByToolUseId: runByToolUseId,
|
||||||
|
workflows: workflows,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:clide/clide.dart';
|
|||||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
|
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_config.dart';
|
||||||
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
|
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/claude_session_host.dart';
|
||||||
import 'package:clide/builtin/claude/src/session_orchestrator.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/pane_context_status.dart';
|
||||||
@@ -308,6 +309,9 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
slot: Slots.sidebar,
|
slot: Slots.sidebar,
|
||||||
title: 'Activity',
|
title: 'Activity',
|
||||||
icon: PhosphorIcons.byName('robot'),
|
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,
|
priority: 60,
|
||||||
build: (_) => const ClaudeMetaSidebar(),
|
build: (_) => const ClaudeMetaSidebar(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// process), so to "watch the same output" we open our OWN read-only follower
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// than using a watcher so it works uniformly across platforms and survives
|
||||||
|
|||||||
@@ -1,28 +1,61 @@
|
|||||||
/// The Activity tab: usage stats (stats-cache.json) + the primary
|
/// The Activity tab: session controls, usage, stats (stats-cache.json), and
|
||||||
/// session's live runtime row. Split out of claude_meta_sidebar.dart
|
/// the primary session's live runtime row. Split out of
|
||||||
/// (T-395).
|
/// claude_meta_sidebar.dart (T-395); session controls + the usage block are
|
||||||
|
/// the power-panel additions (T-415).
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
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_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/meta_sidebar/models.dart';
|
||||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
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/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class ActivityTabView extends StatelessWidget {
|
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 ClaudeStats stats;
|
||||||
final SessionStatus? primaryStatus;
|
final SessionStatus? primaryStatus;
|
||||||
final ClaudeConfig? config;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
final latest = stats.latest;
|
final latest = stats.latest;
|
||||||
|
final u = usage;
|
||||||
final sections = <MetaSection>[
|
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)
|
if (latest != null)
|
||||||
MetaSection('TODAY', [
|
MetaSection('TODAY', [
|
||||||
MetaRow('messages', '${latest.messageCount}'),
|
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}')]),
|
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
|
||||||
..._runtimeSection(tokens),
|
..._runtimeSection(tokens),
|
||||||
];
|
];
|
||||||
if (sections.isEmpty) {
|
|
||||||
return metaPlaceholder('No activity recorded yet.');
|
return ListView(
|
||||||
}
|
padding: const EdgeInsets.all(12),
|
||||||
return buildMetaTable(tokens, sections);
|
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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
|
||||||
@@ -43,6 +131,7 @@ class ActivityTabView extends StatelessWidget {
|
|||||||
final skills = config?.skills.length;
|
final skills = config?.skills.length;
|
||||||
final rows = <MetaRow>[
|
final rows = <MetaRow>[
|
||||||
if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
|
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?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
|
||||||
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
|
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
|
||||||
if (skills != null) MetaRow('skills', '$skills'),
|
if (skills != null) MetaRow('skills', '$skills'),
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
/// The Config tab (T-183): the pinned settings table over [ClaudeConfig]
|
/// The Config tab (T-183): the settings table over [ClaudeConfig] plus the
|
||||||
/// plus the skills/agents/commands/hooks/permissions/MCP accordion.
|
/// skills/agents/commands/hooks/permissions/MCP accordion. Split out of
|
||||||
/// Split out of claude_meta_sidebar.dart (T-395). The accordion's
|
/// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in
|
||||||
/// expansion state lives in the parent (it survives tab switches) and
|
/// the parent (it survives tab switches) and arrives as a prop + toggle
|
||||||
/// arrives as a prop + toggle callback.
|
/// 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;
|
library;
|
||||||
|
|
||||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
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/claude_status.dart' show permissionModeLabel;
|
||||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
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/kernel/kernel.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
class ConfigTabView extends StatelessWidget {
|
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;
|
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.
|
/// Sections currently expanded — owned by the parent state.
|
||||||
final Set<ConfigSection> expanded;
|
final Set<ConfigSection> expanded;
|
||||||
final void Function(ConfigSection section) onToggleSection;
|
final void Function(ConfigSection section) onToggleSection;
|
||||||
@@ -29,19 +47,34 @@ class ConfigTabView extends StatelessWidget {
|
|||||||
return metaPlaceholder('Claude environment not loaded.');
|
return metaPlaceholder('Claude environment not loaded.');
|
||||||
}
|
}
|
||||||
final settings = cfg.settings;
|
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 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>[
|
final children = <Widget>[
|
||||||
// Pinned SETTINGS table — not collapsible.
|
// Pinned SETTINGS control panel — not collapsible.
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
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, 'output style', outputStyle),
|
||||||
_configRow(tokens, 'permission mode', permissionModeLabel(mode)),
|
|
||||||
_configRow(tokens, 'source', '~/.claude + .claude'),
|
_configRow(tokens, 'source', '~/.claude + .claude'),
|
||||||
|
|
||||||
// ---- Accordion sections ----
|
// ---- Accordion sections ----
|
||||||
@@ -57,7 +90,7 @@ class ConfigTabView extends StatelessWidget {
|
|||||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
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}) {
|
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||||
@@ -66,10 +99,10 @@ class ConfigTabView extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: kMetaLabelColumnWidth,
|
width: kMetaLabelColumnWidth,
|
||||||
child: ClideText(label, muted: true, fontSize: clideFontSmall),
|
child: ClideText(label, muted: true, fontSize: kMetaFont),
|
||||||
),
|
),
|
||||||
Expanded(
|
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;
|
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 = ClideTheme.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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import 'package:flutter/widgets.dart';
|
|||||||
/// The shared label-column width + row pitch the Activity and Config tables
|
/// 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.
|
/// both use, so toggling between tabs keeps every value at the same x and y.
|
||||||
const double kMetaLabelColumnWidth = 110;
|
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.
|
/// The sidebar's sub-tabs.
|
||||||
enum SidebarTab { activity, team, config }
|
enum SidebarTab { activity, team, config }
|
||||||
@@ -36,18 +40,23 @@ class MetaRow {
|
|||||||
/// The muted empty-state body shared by every tab.
|
/// The muted empty-state body shared by every tab.
|
||||||
Widget metaPlaceholder(String text) => Padding(
|
Widget metaPlaceholder(String text) => Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
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).
|
/// 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>[];
|
final children = <Widget>[];
|
||||||
for (var i = 0; i < sections.length; i++) {
|
for (var i = 0; i < sections.length; i++) {
|
||||||
final s = sections[i];
|
final s = sections[i];
|
||||||
children.add(
|
children.add(
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 16, bottom: 6),
|
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
|
||||||
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
for (final r in s.rows) {
|
for (final r in s.rows) {
|
||||||
@@ -59,10 +68,10 @@ Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: kMetaLabelColumnWidth,
|
width: kMetaLabelColumnWidth,
|
||||||
child: ClideText(r.label, muted: true, fontSize: clideFontSmall),
|
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
|
||||||
),
|
),
|
||||||
Expanded(
|
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, String text) onInjectSubmit;
|
||||||
final void Function(String memberName) onClose;
|
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
|
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
|
||||||
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
|
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
|
||||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/// 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/controller.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 = ClideTheme.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: clideMonoFamily, color: tokens.statusInfo),
|
||||||
|
const Spacer(),
|
||||||
|
ClideText('↑↓ · 1-${widget.models.length} · Enter · Esc', fontSize: clideFontMeta, fontFamily: clideMonoFamily, 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';
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
/// Rendered in the composer zone (not inline in the conversation) so
|
/// Rendered in the composer zone (not inline in the conversation) so
|
||||||
/// interaction and conversation widgets don't mix — the pane swaps it in for
|
/// 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
|
/// 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
|
/// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no
|
||||||
/// hover-revealed chrome that would fight the buttons.
|
/// hover-revealed chrome that would fight the buttons.
|
||||||
|
|||||||
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
|
|||||||
|
|
||||||
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent
|
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent
|
||||||
/// `claude --session-id <sessionId>` re-creates it empty — the in-place
|
/// `claude --session-id <sessionId>` re-creates it empty — the in-place
|
||||||
/// `/clear` path for the primary pane (T-268). Removes both the `<id>.jsonl`
|
/// `/clear` path for the primary pane (T-268). Removes the `<id>.jsonl`, plus
|
||||||
/// and the sidecar `<id>/` directory claude keeps beside it. Best-effort:
|
/// a per-session `<id>/` sidecar dir if one exists (best-effort; missing
|
||||||
/// missing entries are not an error. The caller MUST have killed the session's
|
/// entries are not an error). Note the shared per-project `memory/` dir that
|
||||||
/// process first, so claude is not mid-write.
|
/// 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 {
|
Future<void> clearSessionTranscript(String projectDir, String sessionId) async {
|
||||||
final file = File('$projectDir/$sessionId.jsonl');
|
final file = File('$projectDir/$sessionId.jsonl');
|
||||||
if (await file.exists()) await file.delete();
|
if (await file.exists()) await file.delete();
|
||||||
@@ -124,12 +130,18 @@ String freshSessionId() {
|
|||||||
/// same id). Expands an FNV-1a stream into 16 bytes.
|
/// same id). Expands an FNV-1a stream into 16 bytes.
|
||||||
String _deterministicUuid(String seed) {
|
String _deterministicUuid(String seed) {
|
||||||
final bytes = <int>[];
|
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;
|
const prime = 0x100000001b3;
|
||||||
for (var i = 0; i < 16; i++) {
|
for (var i = 0; i < 16; i++) {
|
||||||
for (final c in utf8.encode('$seed:$i')) {
|
for (final c in utf8.encode('$seed:$i')) {
|
||||||
h ^= c;
|
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);
|
bytes.add(h & 0xff);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
///
|
///
|
||||||
/// A session is a `claude` stream-json process clide spawns and renders; a
|
/// 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
|
/// pane is just a *view* on one. The orchestrator decouples a session's
|
||||||
/// lifecycle from any pane: [spawn] starts and registers it, [show]/[hide]
|
/// lifecycle from any pane: `spawn` starts and registers it, `show`/`hide`
|
||||||
/// toggle visibility WITHOUT tearing the process down, and [close] kills it.
|
/// toggle visibility WITHOUT tearing the process down, and `close` kills it.
|
||||||
/// This is the one primitive behind teammate / secondary tab / forked branch
|
/// This is the one primitive behind teammate / secondary tab / forked branch
|
||||||
/// (Phase 2): they are all just managed sessions shown as panes.
|
/// (Phase 2): they are all just managed sessions shown as panes.
|
||||||
///
|
///
|
||||||
@@ -49,6 +49,7 @@ class SpawnSpec {
|
|||||||
this.team = false,
|
this.team = false,
|
||||||
this.memberName,
|
this.memberName,
|
||||||
this.forkSourceSessionId,
|
this.forkSourceSessionId,
|
||||||
|
this.effort,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -81,6 +82,12 @@ class SpawnSpec {
|
|||||||
/// Takes precedence over [resume]/[sessionId] for arg selection.
|
/// Takes precedence over [resume]/[sessionId] for arg selection.
|
||||||
final String? forkSourceSessionId;
|
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.
|
/// Whether this spec spawns a forked session.
|
||||||
bool get isFork => forkSourceSessionId != null;
|
bool get isFork => forkSourceSessionId != null;
|
||||||
}
|
}
|
||||||
@@ -238,7 +245,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
|||||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||||
}
|
}
|
||||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
|
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 proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
|
||||||
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
|
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
|
/// 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 {
|
Future<void> close(String id) async {
|
||||||
final m = _sessions.remove(id);
|
final m = _sessions.remove(id);
|
||||||
if (m == null) return;
|
if (m == null) return;
|
||||||
broker.removeMember(id);
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,29 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
|
|||||||
/// Slash commands clide handles itself instead of forwarding to Claude:
|
/// 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
|
/// 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).
|
/// transcript reader can't follow, so clide owns the semantics (T-156).
|
||||||
/// `/fork` branches the current session into a new pane (T-172).
|
/// `/fork` branches the current session into a new pane (T-172). `/model`
|
||||||
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'};
|
/// 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
|
/// The clide-owned command in [text] (a single-line leading-slash token in
|
||||||
/// [kClideOwnedCommands]), or null.
|
/// [kClideOwnedCommands]), or null.
|
||||||
@@ -40,6 +61,93 @@ String? clideOwnedCommand(String text) {
|
|||||||
return token != null && kClideOwnedCommands.contains(token) ? token : null;
|
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';
|
bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n';
|
||||||
|
|
||||||
/// An in-progress slash query at the cursor — the `/` position and the word
|
/// An in-progress slash query at the cursor — the `/` position and the word
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
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';
|
import 'package:clide/src/util/value_stream.dart';
|
||||||
|
|
||||||
/// The claude subprocess, abstracted so tests drive it without spawning.
|
/// The claude subprocess, abstracted so tests drive it without spawning.
|
||||||
@@ -106,7 +107,20 @@ class ClaudeStreamJsonProcess extends StreamJsonProcess {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> kill() async {
|
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();
|
_proc.kill();
|
||||||
|
try {
|
||||||
|
await _proc.exitCode.timeout(const Duration(seconds: 2));
|
||||||
|
} on TimeoutException {
|
||||||
|
_proc.kill(ProcessSignal.sigkill);
|
||||||
|
await _proc.exitCode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -147,6 +161,53 @@ abstract class McpServer {
|
|||||||
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments);
|
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
|
/// An interactive prompt Claude is blocked on, from the stream-json control
|
||||||
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
|
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
|
||||||
/// an `AskUserQuestion`. Pure data; the decision goes back via
|
/// an `AskUserQuestion`. Pure data; the decision goes back via
|
||||||
@@ -240,6 +301,20 @@ class SessionEnd {
|
|||||||
|
|
||||||
final int exitCode;
|
final int exitCode;
|
||||||
final List<String> stderrTail;
|
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 {
|
class StreamJsonSession {
|
||||||
@@ -261,6 +336,27 @@ class StreamJsonSession {
|
|||||||
String? _claudeSessionId;
|
String? _claudeSessionId;
|
||||||
int _localSeq = 0;
|
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).
|
/// Token-by-token streaming state (T-168, wire shape verified by T-184).
|
||||||
///
|
///
|
||||||
/// With `--include-partial-messages`, claude emits the in-progress reply as
|
/// With `--include-partial-messages`, claude emits the in-progress reply as
|
||||||
@@ -309,6 +405,20 @@ class StreamJsonSession {
|
|||||||
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
||||||
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
|
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
|
/// Whether a turn is in flight (between a send and claude's `result`). Drives
|
||||||
/// the composer's Stop affordance.
|
/// the composer's Stop affordance.
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
@@ -368,22 +478,22 @@ class StreamJsonSession {
|
|||||||
// code is not (T-361).
|
// code is not (T-361).
|
||||||
final exit = _proc.exitCode;
|
final exit = _proc.exitCode;
|
||||||
if (exit != null) unawaited(exit.then(_onExit));
|
if (exit != null) unawaited(exit.then(_onExit));
|
||||||
// Declaring our in-process MCP servers in the `initialize` handshake is what
|
// The `initialize` handshake is side-effect-free (verified in the protocol
|
||||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
|
// spike) and does double duty: declaring our in-process MCP servers is what
|
||||||
// when we actually host a server, so a plain session is unchanged.
|
// makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
|
||||||
if (_mcpServers.isNotEmpty) {
|
// response's `models[]` feeds the /model picker (T-408).
|
||||||
_proc.writeLine(
|
_initRequestId = 'init-${_localSeq++}';
|
||||||
jsonEncode({
|
_proc.writeLine(
|
||||||
'type': 'control_request',
|
jsonEncode({
|
||||||
'request_id': 'init-${_localSeq++}',
|
'type': 'control_request',
|
||||||
'request': {
|
'request_id': _initRequestId,
|
||||||
'subtype': 'initialize',
|
'request': {
|
||||||
'hooks': <String, dynamic>{},
|
'subtype': 'initialize',
|
||||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
'hooks': <String, dynamic>{},
|
||||||
},
|
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||||
}),
|
},
|
||||||
);
|
}),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLine(String line) {
|
void _onLine(String line) {
|
||||||
@@ -411,6 +521,12 @@ class StreamJsonSession {
|
|||||||
_onControlRequest(ev);
|
_onControlRequest(ev);
|
||||||
return;
|
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
|
// A `result` ends the turn — clear the busy/interruptible state and reset
|
||||||
// streaming state so the next turn is fresh.
|
// streaming state so the next turn is fresh.
|
||||||
if (ev['type'] == 'result') {
|
if (ev['type'] == 'result') {
|
||||||
@@ -427,6 +543,15 @@ class StreamJsonSession {
|
|||||||
return;
|
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
|
// Finalise a streamed reply: when the real text `assistant` event for a
|
||||||
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
|
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
|
||||||
// so the controller replaces the placeholder in place rather than appending
|
// 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]
|
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
|
||||||
/// item the UI resolves; every other subtype is answered with an error so
|
/// item the UI resolves; every other subtype is answered with an error so
|
||||||
/// the turn never hangs waiting on us (D-78).
|
/// the turn never hangs waiting on us (D-78).
|
||||||
@@ -742,6 +876,18 @@ class StreamJsonSession {
|
|||||||
_setBusy(true);
|
_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
|
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
|
||||||
/// the `interrupt` control_request; claude cancels the current turn and ends
|
/// the `interrupt` control_request; claude cancels the current turn and ends
|
||||||
/// it with a `result`, which clears [busy]. Safe to call when idle.
|
/// it with a `result`, which clears [busy]. Safe to call when idle.
|
||||||
@@ -778,6 +924,59 @@ class StreamJsonSession {
|
|||||||
_mergeStatus(SessionStatus(permissionMode: mode));
|
_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"
|
/// The process exited under a live session. Flip every "in flight"
|
||||||
/// surface off so the pane reflects reality instead of spinning forever.
|
/// surface off so the pane reflects reality instead of spinning forever.
|
||||||
void _onExit(int code) {
|
void _onExit(int code) {
|
||||||
@@ -793,15 +992,24 @@ class StreamJsonSession {
|
|||||||
_endCtl.add(_end!);
|
_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
|
_disposed = true; // deliberate teardown — suppress the exit-watch path
|
||||||
await _sub?.cancel();
|
await _sub?.cancel();
|
||||||
await _proc.kill();
|
await _proc.kill(); // awaits the process's real exit (T-437)
|
||||||
await _items.close();
|
await _items.close();
|
||||||
await _statusCtl.close();
|
await _statusCtl.close();
|
||||||
|
await _workflowsCtl.close();
|
||||||
await _sessionIdCtl.close();
|
await _sessionIdCtl.close();
|
||||||
await _pendingCtl.close();
|
await _pendingCtl.close();
|
||||||
await _busyCtl.close();
|
await _busyCtl.close();
|
||||||
await _endCtl.close();
|
await _endCtl.close();
|
||||||
|
await _modelErrorCtl.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
/// and the full workspace pane read from this one model — they share state,
|
/// and the full workspace pane read from this one model — they share state,
|
||||||
/// they do NOT each hold their own copy.
|
/// they do NOT each hold their own copy.
|
||||||
///
|
///
|
||||||
/// [postAsUser] is the user's write path: it routes by @tag (one agent or
|
/// `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
|
/// broadcast) and, when the interrupt flag is set, calls `interrupt()` on the
|
||||||
/// target session THEN delivers the message.
|
/// target session THEN delivers the message.
|
||||||
///
|
///
|
||||||
/// Flutter-free on purpose: this module (like [TeamBroker]) runs under
|
/// 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
|
/// 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
|
/// 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] —
|
/// Both this widget and [TeamChatPane] read from the same [TeamChatModel] —
|
||||||
/// there is one model, two surfaces.
|
/// there is one model, two surfaces.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
///
|
///
|
||||||
/// # Version drift-guard
|
/// # Version drift-guard
|
||||||
/// If the envelope `version` field has an unfamiliar major version the reader
|
/// 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.
|
/// parses whatever it can and skips the rest rather than crashing.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
@@ -114,12 +114,19 @@ final class AssistantTextMessage extends ConversationItem {
|
|||||||
super.parentUuid,
|
super.parentUuid,
|
||||||
super.parentToolUseId,
|
super.parentToolUseId,
|
||||||
required this.text,
|
required this.text,
|
||||||
|
this.synthetic = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String text;
|
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
|
@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.
|
/// Extended thinking block from an assistant turn.
|
||||||
@@ -220,7 +227,7 @@ class TranscriptReader {
|
|||||||
/// [pollInterval] controls how often the reader polls for new data and
|
/// [pollInterval] controls how often the reader polls for new data and
|
||||||
/// session switches (default 500 ms).
|
/// 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.
|
/// If omitted, warnings are written to stderr.
|
||||||
TranscriptReader(
|
TranscriptReader(
|
||||||
this.workspacePath, {
|
this.workspacePath, {
|
||||||
@@ -412,7 +419,7 @@ class TranscriptReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a single JSONL line into its items (forwarding any version
|
/// 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) {
|
List<ConversationItem> parseLine(String line) {
|
||||||
final parsed = parseTranscriptChunk(line);
|
final parsed = parseTranscriptChunk(line);
|
||||||
for (final w in parsed.warnings) {
|
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,
|
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
|
||||||
/// and the reader [merge]s deltas into a running status.
|
/// and the reader [merge]s deltas into a running status.
|
||||||
class SessionStatus {
|
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`.
|
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
|
||||||
final String? model;
|
final String? model;
|
||||||
@@ -451,7 +458,13 @@ class SessionStatus {
|
|||||||
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
|
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
|
||||||
final String? rateLimitInfo;
|
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.
|
/// Overlay [other]'s non-null fields onto this one.
|
||||||
SessionStatus merge(SessionStatus other) => SessionStatus(
|
SessionStatus merge(SessionStatus other) => SessionStatus(
|
||||||
@@ -461,6 +474,7 @@ class SessionStatus {
|
|||||||
cost: other.cost ?? cost,
|
cost: other.cost ?? cost,
|
||||||
contextWindow: other.contextWindow ?? contextWindow,
|
contextWindow: other.contextWindow ?? contextWindow,
|
||||||
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
|
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
|
||||||
|
effort: other.effort ?? effort,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -471,10 +485,11 @@ class SessionStatus {
|
|||||||
other.contextTokens == contextTokens &&
|
other.contextTokens == contextTokens &&
|
||||||
other.cost == cost &&
|
other.cost == cost &&
|
||||||
other.contextWindow == contextWindow &&
|
other.contextWindow == contextWindow &&
|
||||||
other.rateLimitInfo == rateLimitInfo;
|
other.rateLimitInfo == rateLimitInfo &&
|
||||||
|
other.effort == effort;
|
||||||
|
|
||||||
@override
|
@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
|
/// 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?;
|
final message = envelope['message'] as Map?;
|
||||||
if (message == null) return;
|
if (message == null) return;
|
||||||
final model = message['model'] as String?;
|
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?;
|
final usage = message['usage'] as Map?;
|
||||||
if (usage != null) {
|
if (usage != null) {
|
||||||
int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
|
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(
|
void _parseAssistantInto(
|
||||||
Map<String, dynamic> envelope,
|
Map<String, dynamic> envelope,
|
||||||
String uuid,
|
String uuid,
|
||||||
@@ -669,6 +689,7 @@ void _parseAssistantInto(
|
|||||||
if (message == null) return;
|
if (message == null) return;
|
||||||
final content = message['content'];
|
final content = message['content'];
|
||||||
if (content is! List) return;
|
if (content is! List) return;
|
||||||
|
final synthetic = (message['model'] as String?) == kSyntheticModel;
|
||||||
|
|
||||||
for (final item in content) {
|
for (final item in content) {
|
||||||
if (item is! Map) continue;
|
if (item is! Map) continue;
|
||||||
@@ -684,6 +705,7 @@ void _parseAssistantInto(
|
|||||||
parentUuid: parentUuid,
|
parentUuid: parentUuid,
|
||||||
parentToolUseId: parentToolUseId,
|
parentToolUseId: parentToolUseId,
|
||||||
text: text,
|
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;
|
||||||
@@ -53,6 +53,23 @@ class DefaultLayoutExtension extends ClideExtension {
|
|||||||
// Editor split (D-049, D-054)
|
// Editor split (D-049, D-054)
|
||||||
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
|
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),
|
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
|
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||||
for (var i = 0; i < 5; i++)
|
for (var i = 0; i < 5; i++)
|
||||||
CommandContribution(
|
CommandContribution(
|
||||||
@@ -195,6 +212,27 @@ class DefaultLayoutExtension extends ClideExtension {
|
|||||||
return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
|
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 {
|
Future<IpcResponse> _focusRight(List<String> args) async {
|
||||||
final ctx = _ctx;
|
final ctx = _ctx;
|
||||||
if (ctx == null) return _notActivated();
|
if (ctx == null) return _notActivated();
|
||||||
|
|||||||
@@ -204,6 +204,19 @@ class EditorController extends ChangeNotifier {
|
|||||||
_dirty = false;
|
_dirty = false;
|
||||||
notifyListeners();
|
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':
|
case 'editor.settings-changed':
|
||||||
// A source (e.g. a saved .editorconfig) re-resolved the buffer's
|
// A source (e.g. a saved .editorconfig) re-resolved the buffer's
|
||||||
// settings. Refresh the active buffer's copy so indent/ruler update.
|
// 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
|
/// Tier-2 editor pane. Shows one tab per open buffer via the shared
|
||||||
/// [MultitabPane] (the same strip the Claude pane uses); the body
|
/// [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
|
/// is the source of truth for which buffers are open and which is
|
||||||
/// active — the local [MultitabController] is reconciled from it, and
|
/// active — the local [MultitabController] is reconciled from it, and
|
||||||
/// tab gestures (select / close) are routed back as `editor.activate`
|
/// tab gestures (select / close) are routed back as `editor.activate`
|
||||||
@@ -54,10 +54,16 @@ class _EditorViewState extends State<EditorView> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_text = SyntaxTextController(syntax: _syntax);
|
_text = SyntaxTextController(syntax: _syntax);
|
||||||
_focus = FocusNode();
|
_focus = FocusNode();
|
||||||
|
_focus.addListener(_onFocusChanged);
|
||||||
_text.addListener(_onTextChanged);
|
_text.addListener(_onTextChanged);
|
||||||
_tabs.addListener(_onTabsChanged);
|
_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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
@@ -75,12 +81,14 @@ class _EditorViewState extends State<EditorView> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_text.removeListener(_onTextChanged);
|
_text.removeListener(_onTextChanged);
|
||||||
_text.dispose();
|
_text.dispose();
|
||||||
|
_focus.removeListener(_onFocusChanged);
|
||||||
_focus.dispose();
|
_focus.dispose();
|
||||||
_tabs.removeListener(_onTabsChanged);
|
_tabs.removeListener(_onTabsChanged);
|
||||||
_tabs.dispose();
|
_tabs.dispose();
|
||||||
_controller?.removeListener(_onControllerChanged);
|
_controller?.removeListener(_onControllerChanged);
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
_keymap?.removeListener(_onModeChanged);
|
_keymap?.removeListener(_onModeChanged);
|
||||||
|
_keymap?.clearScopeFlag('editor.focused');
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +108,20 @@ class _EditorViewState extends State<EditorView> {
|
|||||||
final c = _controller!;
|
final c = _controller!;
|
||||||
_syncTabs(c);
|
_syncTabs(c);
|
||||||
_text.updatePath(c.activePath);
|
_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) {
|
if (c.content != _lastRemoteContent) {
|
||||||
_lastRemoteContent = c.content;
|
_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.removeListener(_onTextChanged);
|
||||||
_text.value = TextEditingValue(text: c.content, selection: sel);
|
_text.value = TextEditingValue(text: c.content, selection: sel);
|
||||||
_text.addListener(_onTextChanged);
|
_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
|
setState(() {}); // tab/title refresh
|
||||||
}
|
}
|
||||||
@@ -266,7 +282,13 @@ class _EditorViewState extends State<EditorView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _dispatchVim(Intent intent, int count, KernelServices kernel, {required bool visual}) {
|
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;
|
final id = intent.commandId;
|
||||||
if (!id.startsWith('editor.vim.')) {
|
if (!id.startsWith('editor.vim.')) {
|
||||||
// Mode change (vim.mode.*) or any other command.
|
// Mode change (vim.mode.*) or any other command.
|
||||||
|
|||||||
@@ -9,11 +9,26 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
import 'package:clide/kernel/kernel.dart';
|
import 'package:clide/kernel/kernel.dart';
|
||||||
import 'package:flutter/foundation.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 {
|
class FileTreeController extends ChangeNotifier {
|
||||||
FileTreeController({required this.ipc, required this.events}) {
|
FileTreeController({required this.ipc, required this.events}) {
|
||||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||||
@@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier {
|
|||||||
final Map<String, List<FileEntry>> _entries = {};
|
final Map<String, List<FileEntry>> _entries = {};
|
||||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
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() {
|
List<FileEntry> allLoadedEntries() {
|
||||||
final out = <FileEntry>[];
|
final out = <FileEntry>[];
|
||||||
for (final list in _entries.values) {
|
for (final list in _entries.values) {
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget {
|
|||||||
class _FileTreeViewState extends State<FileTreeView> {
|
class _FileTreeViewState extends State<FileTreeView> {
|
||||||
FileTreeController? _controller;
|
FileTreeController? _controller;
|
||||||
String _filter = '';
|
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
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -39,9 +47,52 @@ class _FileTreeViewState extends State<FileTreeView> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
|
_scroll.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final c = _controller;
|
final c = _controller;
|
||||||
@@ -57,6 +108,23 @@ class _FileTreeViewState extends State<FileTreeView> {
|
|||||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
|
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
|
||||||
}
|
}
|
||||||
final rootName = root.split(Platform.pathSeparator).last;
|
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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
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',
|
label: 'file tree — $rootName',
|
||||||
container: true,
|
container: true,
|
||||||
explicitChildNodes: true,
|
explicitChildNodes: true,
|
||||||
child: SingleChildScrollView(
|
// Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
// region holds focus under the vim preset (T-406). The filter
|
||||||
child: Column(
|
// box sits outside it, so typing a filter is never intercepted.
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller,
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -97,11 +155,13 @@ class _FileTreeViewState extends State<FileTreeView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _Children extends StatelessWidget {
|
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 String path;
|
||||||
final FileTreeController controller;
|
final FileTreeController controller;
|
||||||
final int depth;
|
final int depth;
|
||||||
|
final String? selectedPath;
|
||||||
|
final Key? selectedKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -117,58 +177,67 @@ class _Children extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
|
_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),
|
if (controller.isExpanded(e.path))
|
||||||
|
_Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
else
|
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 {
|
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 name;
|
||||||
final String path;
|
final String path;
|
||||||
final FileTreeController controller;
|
final FileTreeController controller;
|
||||||
final int depth;
|
final int depth;
|
||||||
|
final String? selectedPath;
|
||||||
|
final Key? selectedKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final expanded = controller.isExpanded(path);
|
final expanded = controller.isExpanded(path);
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
|
final selected = path == selectedPath;
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: '${expanded ? 'Collapse' : 'Expand'} $name',
|
label: '${expanded ? 'Collapse' : 'Expand'} $name',
|
||||||
onTap: () => controller.toggle(path),
|
onTap: () => controller.toggle(path),
|
||||||
child: _Row(
|
child: _Row(
|
||||||
|
key: selected ? selectedKey : null,
|
||||||
depth: depth,
|
depth: depth,
|
||||||
onTap: () => controller.toggle(path),
|
onTap: () => controller.toggle(path),
|
||||||
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
|
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
|
||||||
label: name,
|
label: name,
|
||||||
rotateLeading: expanded,
|
rotateLeading: expanded,
|
||||||
|
selected: selected,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileRow extends StatelessWidget {
|
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 name;
|
||||||
final String path;
|
final String path;
|
||||||
final int depth;
|
final int depth;
|
||||||
|
final String? selectedPath;
|
||||||
|
final Key? selectedKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selected = path == selectedPath;
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'Open $name',
|
label: 'Open $name',
|
||||||
onTap: () => _openFile(context, path),
|
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 {
|
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 int depth;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
@@ -188,6 +257,10 @@ class _Row extends StatelessWidget {
|
|||||||
final Widget? leading;
|
final Widget? leading;
|
||||||
final bool rotateLeading;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideTheme.of(context).surface;
|
||||||
@@ -195,7 +268,12 @@ class _Row extends StatelessWidget {
|
|||||||
return ClideTappable(
|
return ClideTappable(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
builder: (context, hovered, _) => Container(
|
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),
|
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
/// merged health/toggle status-bar widget, and the `dock.toggle` command.
|
/// merged health/toggle status-bar widget, and the `dock.toggle` command.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:clide/builtin/output/src/dock_status_item.dart';
|
import 'package:clide/builtin/output/src/dock_status_item.dart';
|
||||||
import 'package:clide/builtin/output/src/output_view.dart';
|
import 'package:clide/builtin/output/src/output_view.dart';
|
||||||
import 'package:clide/clide.dart';
|
import 'package:clide/clide.dart';
|
||||||
@@ -35,7 +37,20 @@ class OutputExtension extends ClideExtension {
|
|||||||
slot: Slots.dock,
|
slot: Slots.dock,
|
||||||
title: 'Output',
|
title: 'Output',
|
||||||
priority: -100, // sort before Problems in the dock tab bar
|
priority: -100, // sort before Problems in the dock tab bar
|
||||||
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing),
|
build: (ctx) {
|
||||||
|
// The Level chip is the live verbosity toggle (T-433): drive the kernel
|
||||||
|
// logger + persist app.log.level so the choice survives restart and
|
||||||
|
// matches the `clide log level` CLI (D-6 parity).
|
||||||
|
final k = ClideKernel.of(ctx);
|
||||||
|
return OutputView(
|
||||||
|
ring: k.logRing,
|
||||||
|
initialLevel: k.log.minLevel,
|
||||||
|
onMinLevelChanged: (level) {
|
||||||
|
k.log.minLevel = level;
|
||||||
|
unawaited(k.settings.set('app.log.level', level.name));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
StatusItemContribution(
|
StatusItemContribution(
|
||||||
id: 'output.dock-toggle',
|
id: 'output.dock-toggle',
|
||||||
|
|||||||
@@ -10,15 +10,23 @@ import 'package:clide/kernel/src/log_ring.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class OutputController extends ChangeNotifier {
|
class OutputController extends ChangeNotifier {
|
||||||
OutputController(this.ring) {
|
OutputController(this.ring, {LogLevel? initialLevel, this.onMinLevelChanged}) : minLevel = initialLevel ?? LogLevel.debug {
|
||||||
_sub = ring.changes.listen((_) => notifyListeners());
|
_sub = ring.changes.listen((_) => notifyListeners());
|
||||||
}
|
}
|
||||||
|
|
||||||
final LogRing ring;
|
final LogRing ring;
|
||||||
late final StreamSubscription<void> _sub;
|
late final StreamSubscription<void> _sub;
|
||||||
|
|
||||||
/// Minimum level shown. Defaults to debug (trace is firehose-noise).
|
/// Invoked when the Level chip changes the level — the dock chip is the live
|
||||||
LogLevel minLevel = LogLevel.debug;
|
/// dev/prod verbosity toggle (T-433), not just a view filter. The owner wires
|
||||||
|
/// this to set the kernel `Logger.minLevel` and persist `app.log.level`. Null
|
||||||
|
/// in tests / when no kernel is attached, leaving the chip a pure view filter.
|
||||||
|
final void Function(LogLevel)? onMinLevelChanged;
|
||||||
|
|
||||||
|
/// Minimum level shown — initialized from the kernel logger's level so the
|
||||||
|
/// chip reflects the real verbosity (which a `clide log level` CLI may have
|
||||||
|
/// already set), defaulting to debug (trace is firehose-noise).
|
||||||
|
LogLevel minLevel;
|
||||||
|
|
||||||
/// Source filter; null = all sources.
|
/// Source filter; null = all sources.
|
||||||
String? source;
|
String? source;
|
||||||
@@ -29,6 +37,7 @@ class OutputController extends ChangeNotifier {
|
|||||||
void setMinLevel(LogLevel level) {
|
void setMinLevel(LogLevel level) {
|
||||||
if (minLevel == level) return;
|
if (minLevel == level) return;
|
||||||
minLevel = level;
|
minLevel = level;
|
||||||
|
onMinLevelChanged?.call(level);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,23 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'output_controller.dart';
|
import 'output_controller.dart';
|
||||||
|
|
||||||
class OutputView extends StatefulWidget {
|
class OutputView extends StatefulWidget {
|
||||||
const OutputView({super.key, required this.ring});
|
const OutputView({super.key, required this.ring, this.initialLevel, this.onMinLevelChanged});
|
||||||
|
|
||||||
/// The retained log buffer to render. The view owns a controller over it
|
/// The retained log buffer to render. The view owns a controller over it
|
||||||
/// but never the ring itself (the app owns that).
|
/// but never the ring itself (the app owns that).
|
||||||
final LogRing ring;
|
final LogRing ring;
|
||||||
|
|
||||||
|
/// Initial verbosity (the kernel logger's current level) + the sink that
|
||||||
|
/// applies a chip change to the kernel + persists it (T-433).
|
||||||
|
final LogLevel? initialLevel;
|
||||||
|
final void Function(LogLevel)? onMinLevelChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<OutputView> createState() => _OutputViewState();
|
State<OutputView> createState() => _OutputViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OutputViewState extends State<OutputView> {
|
class _OutputViewState extends State<OutputView> {
|
||||||
late final OutputController _c = OutputController(widget.ring);
|
late final OutputController _c = OutputController(widget.ring, initialLevel: widget.initialLevel, onMinLevelChanged: widget.onMinLevelChanged);
|
||||||
final ScrollController _scroll = ScrollController();
|
final ScrollController _scroll = ScrollController();
|
||||||
|
|
||||||
/// Follow the tail until the user scrolls up; resumes when they return to
|
/// Follow the tail until the user scrolls up; resumes when they return to
|
||||||
|
|||||||
@@ -77,20 +77,18 @@ class _TerminalPaneState extends State<TerminalPane> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final shell = Platform.environment['SHELL'] ?? '/bin/bash';
|
// Windows has no $SHELL convention and no login-shell flag —
|
||||||
|
// PowerShell 7 first, classic PowerShell as the always-there
|
||||||
|
// fallback.
|
||||||
|
final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash');
|
||||||
|
final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo'];
|
||||||
// The open workspace, not Directory.current — a desktop launch starts
|
// The open workspace, not Directory.current — a desktop launch starts
|
||||||
// in $HOME and a project switch doesn't move the process CWD (T-381).
|
// in $HOME and a project switch doesn't move the process CWD (T-381).
|
||||||
final cwd = _kernel?.project.current?.path ?? Directory.current.path;
|
final cwd = _kernel?.project.current?.path ?? Directory.current.path;
|
||||||
|
|
||||||
final response = await ipc.request(
|
final response = await ipc.request(
|
||||||
'pane.spawn',
|
'pane.spawn',
|
||||||
args: {
|
args: {'argv': argv, 'kind': PaneKind.terminal.wire, 'cwd': cwd, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight},
|
||||||
'argv': [shell, '-l'],
|
|
||||||
'kind': PaneKind.terminal.wire,
|
|
||||||
'cwd': cwd,
|
|
||||||
'cols': _terminal.viewWidth,
|
|
||||||
'rows': _terminal.viewHeight,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
/// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal`
|
/// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal`
|
||||||
/// etc. Nothing reaches into this object across the builtin boundary.
|
/// etc. Nothing reaches into this object across the builtin boundary.
|
||||||
///
|
///
|
||||||
/// The whole layer is gated by [enabled], which the Vim extension ties to
|
/// The whole layer is gated by `enabled`, which the Vim extension ties to
|
||||||
/// the active preset: under a non-Vim preset the flags are cleared so they
|
/// the active preset: under a non-Vim preset the flags are cleared so they
|
||||||
/// can never affect another preset's bindings.
|
/// can never affect another preset's bindings.
|
||||||
library;
|
library;
|
||||||
@@ -47,7 +47,7 @@ class VimModeService extends ChangeNotifier {
|
|||||||
/// Whether the Vim layer is live. False under non-Vim presets.
|
/// Whether the Vim layer is live. False under non-Vim presets.
|
||||||
bool get enabled => _enabled;
|
bool get enabled => _enabled;
|
||||||
|
|
||||||
/// The active mode. Meaningful only while [enabled]; defaults to
|
/// The active mode. Meaningful only while `enabled`; defaults to
|
||||||
/// [VimMode.normal] and resets to it whenever the layer is enabled.
|
/// [VimMode.normal] and resets to it whenever the layer is enabled.
|
||||||
VimMode get mode => _mode;
|
VimMode get mode => _mode;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class TabContribution extends ContributionPoint {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.build,
|
required this.build,
|
||||||
this.icon,
|
this.icon,
|
||||||
|
this.iconColor,
|
||||||
this.priority = 0,
|
this.priority = 0,
|
||||||
this.fileGlobs = const [],
|
this.fileGlobs = const [],
|
||||||
this.listenable,
|
this.listenable,
|
||||||
@@ -39,6 +40,9 @@ class TabContribution extends ContributionPoint {
|
|||||||
final String title;
|
final String title;
|
||||||
final WidgetBuilder build;
|
final WidgetBuilder build;
|
||||||
final Object? icon;
|
final Object? icon;
|
||||||
|
|
||||||
|
/// Optional identity tint for the icon-rail glyph (T-418).
|
||||||
|
final Color? iconColor;
|
||||||
final int priority;
|
final int priority;
|
||||||
final List<String> fileGlobs;
|
final List<String> fileGlobs;
|
||||||
final Listenable? listenable;
|
final Listenable? listenable;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/// FFI fd-inheritance probe for the testmode harness (T-438 web fence, D-100).
|
||||||
|
///
|
||||||
|
/// Desktop-only — [test_app.dart] selects [fd_check_stub.dart] on web so the
|
||||||
|
/// `dart:ffi` / `package:ffi` / libc imports stay out of the wasm graph.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:ffi' as ffi;
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||||
|
|
||||||
|
import 'src/pty/ffi/libc.dart' as libc;
|
||||||
|
|
||||||
|
/// Probe whether `Process.start` inherits socket fds (the macOS question the
|
||||||
|
/// testmode harness answers). Returns a human-readable result line.
|
||||||
|
Future<String> fdInheritanceCheck() async {
|
||||||
|
final sv = pkg_ffi.calloc<ffi.Int32>(2);
|
||||||
|
libc.socketpair(1, 1, 0, sv); // AF_UNIX, SOCK_STREAM
|
||||||
|
final parent = sv[0];
|
||||||
|
final child = sv[1];
|
||||||
|
pkg_ffi.calloc.free(sv);
|
||||||
|
final proc = await Process.start('/tmp/checkfd', [], environment: {...Platform.environment, 'PTYC_SOCK_FD': '$child'});
|
||||||
|
final stderr = await proc.stderr.transform(utf8.decoder).join();
|
||||||
|
final exit = await proc.exitCode;
|
||||||
|
libc.close(parent);
|
||||||
|
libc.close(child);
|
||||||
|
return 'exit=$exit stderr=${stderr.trim()}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/// Web stub (T-438 web fence, D-100): no FFI fd-inheritance probe on web.
|
||||||
|
library;
|
||||||
|
|
||||||
|
Future<String> fdInheritanceCheck() async => 'skipped (no FFI on web)';
|
||||||
@@ -17,6 +17,8 @@ export 'src/events/message_bus.dart';
|
|||||||
export 'src/events/types.dart';
|
export 'src/events/types.dart';
|
||||||
export 'src/ipc/client.dart';
|
export 'src/ipc/client.dart';
|
||||||
export 'src/log.dart';
|
export 'src/log.dart';
|
||||||
|
export 'src/file_log_sink.dart';
|
||||||
|
export 'src/watchdog.dart';
|
||||||
export 'src/settings.dart';
|
export 'src/settings.dart';
|
||||||
export 'src/facade.dart';
|
export 'src/facade.dart';
|
||||||
export 'src/clipboard.dart';
|
export 'src/clipboard.dart';
|
||||||
@@ -28,9 +30,11 @@ export 'src/keymap/key_chord.dart';
|
|||||||
export 'src/keymap/keymap.dart';
|
export 'src/keymap/keymap.dart';
|
||||||
export 'src/keymap/keymap_service.dart';
|
export 'src/keymap/keymap_service.dart';
|
||||||
export 'src/keymap/modifier_tap.dart';
|
export 'src/keymap/modifier_tap.dart';
|
||||||
|
export 'src/keymap/pane_key_nav.dart';
|
||||||
export 'src/keymap/sequence_matcher.dart';
|
export 'src/keymap/sequence_matcher.dart';
|
||||||
export 'src/keymap/when_clause.dart';
|
export 'src/keymap/when_clause.dart';
|
||||||
export 'src/dialog.dart';
|
export 'src/dialog.dart';
|
||||||
|
export 'src/ex_line.dart';
|
||||||
export 'src/extensions_manager.dart';
|
export 'src/extensions_manager.dart';
|
||||||
export 'src/file_open.dart';
|
export 'src/file_open.dart';
|
||||||
export 'src/files.dart';
|
export 'src/files.dart';
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ library;
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:clide/src/env/shell_env.dart' show expandToolPath;
|
||||||
|
|
||||||
/// State of the `clide` shell command relative to the running GUI.
|
/// State of the `clide` shell command relative to the running GUI.
|
||||||
enum CliInstallState {
|
enum CliInstallState {
|
||||||
/// No `clide` resolves on PATH.
|
/// No `clide` resolves on PATH.
|
||||||
@@ -127,7 +129,7 @@ class CliInstaller {
|
|||||||
'`make build` so the C client ships inside the app bundle.',
|
'`make build` so the C client ships inside the app bundle.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final dest = '${_normalize(installDir)}/clide';
|
final dest = '${_normalize(installDir)}/clide${Platform.isWindows ? '.exe' : ''}';
|
||||||
try {
|
try {
|
||||||
Directory(installDir).createSync(recursive: true);
|
Directory(installDir).createSync(recursive: true);
|
||||||
// Delete any existing entry first so a stale symlink (e.g. one into
|
// Delete any existing entry first so a stale symlink (e.g. one into
|
||||||
@@ -180,52 +182,57 @@ class CliInstaller {
|
|||||||
|
|
||||||
bool _dirOnPath(String dir) {
|
bool _dirOnPath(String dir) {
|
||||||
final norm = _normalize(dir);
|
final norm = _normalize(dir);
|
||||||
return _expandedPath().split(':').any((d) => d.isNotEmpty && _normalize(d) == norm);
|
return _expandedPath().split(_pathSep).any((d) => d.isNotEmpty && _normalize(d) == norm);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _normalize(String p) => p.length > 1 && p.endsWith('/') ? p.substring(0, p.length - 1) : p;
|
/// Trim a trailing slash; on Windows also fold separators and case so
|
||||||
|
/// `C:\Users\x/.local/bin` and `c:\users\x\.local\bin` compare equal.
|
||||||
|
String _normalize(String p) {
|
||||||
|
var s = p;
|
||||||
|
if (Platform.isWindows) s = s.replaceAll('\\', '/').toLowerCase();
|
||||||
|
return s.length > 1 && s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
||||||
|
}
|
||||||
|
|
||||||
String? _findOnPath(String name) {
|
String? _findOnPath(String name) {
|
||||||
for (final dir in _expandedPath().split(':')) {
|
for (final dir in _expandedPath().split(_pathSep)) {
|
||||||
if (dir.isEmpty) continue;
|
if (dir.isEmpty) continue;
|
||||||
final f = File('$dir/$name');
|
if (Platform.isWindows) {
|
||||||
if (f.existsSync()) return f.path;
|
for (final ext in const ['.exe', '.bat', '.cmd', '']) {
|
||||||
|
final f = File('$dir\\$name$ext');
|
||||||
|
if (f.existsSync()) return f.path;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final f = File('$dir/$name');
|
||||||
|
if (f.existsSync()) return f.path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? '');
|
static String get _pathSep => Platform.isWindows ? ';' : ':';
|
||||||
|
|
||||||
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin';
|
String _expandedPath() => expandToolPath(env['PATH'] ?? '', isMac: Platform.isMacOS, isLinux: Platform.isLinux, home: env['HOME'] ?? '');
|
||||||
|
|
||||||
|
/// `~/.local/bin` on every platform — on Windows that is
|
||||||
|
/// `%USERPROFILE%\.local\bin`, the same convention the claude and
|
||||||
|
/// pql installers use there.
|
||||||
|
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? env['USERPROFILE'] ?? ''}/.local/bin';
|
||||||
|
|
||||||
/// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev
|
/// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev
|
||||||
/// override first, then `<exe-dir>/clide-cli` — where `make build` drops it
|
/// override first, then `<exe-dir>/clide-cli` — where `make build` drops it
|
||||||
/// inside the bundle (next to the GUI runner on Linux, in
|
/// inside the bundle (next to the GUI runner on Linux and Windows, in
|
||||||
/// `Contents/MacOS/` on macOS).
|
/// `Contents/MacOS/` on macOS).
|
||||||
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
|
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
|
||||||
final exeDir = File(resolvedExecutable).parent.path;
|
final exeDir = File(resolvedExecutable).parent.path;
|
||||||
return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, '$exeDir/clide-cli'];
|
return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, if (Platform.isWindows) '$exeDir/clide-cli.exe' else '$exeDir/clide-cli'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos)-(x64|arm64)/clide$');
|
final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos|windows)-(x64|arm64)/clide(\.exe)?$');
|
||||||
|
|
||||||
/// True when [path] is a dev-tree C-client build artifact —
|
/// True when [path] is a dev-tree C-client build artifact —
|
||||||
/// `native/<platform>/clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide
|
/// `native/<platform>/clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide
|
||||||
/// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on
|
/// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on
|
||||||
/// PATH; it's a working client but not a packaged production install, so it's
|
/// PATH; it's a working client but not a packaged production install, so it's
|
||||||
/// classified separately (T-256) rather than as a clean install.
|
/// classified separately (T-256) rather than as a clean install.
|
||||||
bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path);
|
bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path.replaceAll('\\', '/'));
|
||||||
|
|
||||||
/// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps
|
|
||||||
/// launch with a sparse PATH that omits the usual user/homebrew bins, so on
|
|
||||||
/// macOS we prepend those (de-duplicated) before scanning. A top-level,
|
|
||||||
/// platform-parameterized function so both branches are testable off-platform.
|
|
||||||
String expandedPath(String base, {required bool macOS, String home = ''}) {
|
|
||||||
if (!macOS) return base;
|
|
||||||
final extras = <String>[if (home.isNotEmpty) '$home/.local/bin', '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
|
|
||||||
final existing = base.split(':').toSet();
|
|
||||||
final missing = extras.where((p) => !existing.contains(p));
|
|
||||||
if (missing.isEmpty) return base;
|
|
||||||
return [...missing, ...existing].join(':');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/// State + grammar + execution for the Vim ex command-line overlay (T-407).
|
||||||
|
///
|
||||||
|
/// `:` (under `vim.normal`) opens a transient one-line prompt that runs a
|
||||||
|
/// small, fixed table of ex commands (`:w` `:q` `:wq` `:x` `:e <path>` `:N`).
|
||||||
|
/// It is NOT a vim *mode* — it's an overlay with its own `exline.open` scope
|
||||||
|
/// flag, dismissed with Esc, exactly the deferral `vim_mode_service.dart`
|
||||||
|
/// always named. The controller mirrors [QuickOpenController]'s open/close
|
||||||
|
/// shape so the overlay can reuse the quick-open chrome.
|
||||||
|
///
|
||||||
|
/// The command grammar ([parseExCommand]) is a pure switch — no parser, no
|
||||||
|
/// vimscript. Execution ([exWriteActive] etc.) goes through the editor IPC
|
||||||
|
/// verbs (the daemon is the source of truth for buffer state), so every ex
|
||||||
|
/// command is editor-targeted and no-ops when no buffer is active (the
|
||||||
|
/// 2026-06-13 decision on T-407).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:clide/clide.dart' show IpcResponse;
|
||||||
|
import 'package:clide/kernel/src/ipc/client.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
class ExLineController extends ChangeNotifier {
|
||||||
|
bool _open = false;
|
||||||
|
String _input = '';
|
||||||
|
// Bumped each time an unknown command is rejected so the overlay can flash
|
||||||
|
// without closing. A monotonic nonce (not a bool) keeps repeated rejections
|
||||||
|
// individually observable by a listener.
|
||||||
|
int _invalidNonce = 0;
|
||||||
|
|
||||||
|
bool get isOpen => _open;
|
||||||
|
String get input => _input;
|
||||||
|
|
||||||
|
/// Increments whenever a typed command is rejected ([flashInvalid]); the
|
||||||
|
/// overlay watches it to flash the input and stay open.
|
||||||
|
int get invalidNonce => _invalidNonce;
|
||||||
|
|
||||||
|
void open() {
|
||||||
|
if (_open) return;
|
||||||
|
_open = true;
|
||||||
|
_input = '';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
if (!_open) return;
|
||||||
|
_open = false;
|
||||||
|
_input = '';
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setInput(String value) {
|
||||||
|
if (_input == value) return;
|
||||||
|
_input = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal that the submitted command was unknown — the overlay flashes and
|
||||||
|
/// stays open rather than executing or dismissing.
|
||||||
|
void flashInvalid() {
|
||||||
|
_invalidNonce++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grammar ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One parsed ex command. v1 table; anything off it is [ExUnknown].
|
||||||
|
sealed class ExCommand {
|
||||||
|
const ExCommand();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty input (`:` then Enter) — dismiss with no action.
|
||||||
|
class ExNoop extends ExCommand {
|
||||||
|
const ExNoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:w` — write (save) the active buffer.
|
||||||
|
class ExWrite extends ExCommand {
|
||||||
|
const ExWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:q` (and `:q!`) — close the active editor tab.
|
||||||
|
class ExQuit extends ExCommand {
|
||||||
|
const ExQuit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:wq` / `:x` (and bang variants) and `ZZ` — save then close the active tab.
|
||||||
|
class ExWriteQuit extends ExCommand {
|
||||||
|
const ExWriteQuit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:e <path>` — open quick-open seeded with `<path>` (empty seed allowed).
|
||||||
|
class ExEdit extends ExCommand {
|
||||||
|
const ExEdit(this.query);
|
||||||
|
final String query;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is ExEdit && other.query == query;
|
||||||
|
@override
|
||||||
|
int get hashCode => query.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:<n>` — jump the active buffer to 1-based line `<n>`.
|
||||||
|
class ExGoto extends ExCommand {
|
||||||
|
const ExGoto(this.line);
|
||||||
|
final int line;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is ExGoto && other.line == line;
|
||||||
|
@override
|
||||||
|
int get hashCode => line.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything not on the v1 table — the overlay flashes and stays open.
|
||||||
|
class ExUnknown extends ExCommand {
|
||||||
|
const ExUnknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the text typed after `:` into an [ExCommand]. A leading colon is
|
||||||
|
/// tolerated (in case the user types it). v1 grammar only.
|
||||||
|
ExCommand parseExCommand(String raw) {
|
||||||
|
var s = raw.trim();
|
||||||
|
if (s.startsWith(':')) s = s.substring(1).trim();
|
||||||
|
if (s.isEmpty) return const ExNoop();
|
||||||
|
|
||||||
|
// `:e` / `:e <path>` — everything after the first token seeds quick-open.
|
||||||
|
if (s == 'e') return const ExEdit('');
|
||||||
|
if (s.startsWith('e ')) return ExEdit(s.substring(2).trim());
|
||||||
|
|
||||||
|
switch (s) {
|
||||||
|
case 'w':
|
||||||
|
return const ExWrite();
|
||||||
|
case 'q' || 'q!':
|
||||||
|
// No dirty-guard in v1, so `q!` is just `q`.
|
||||||
|
return const ExQuit();
|
||||||
|
case 'wq' || 'wq!' || 'x' || 'x!':
|
||||||
|
return const ExWriteQuit();
|
||||||
|
}
|
||||||
|
|
||||||
|
final line = int.tryParse(s);
|
||||||
|
if (line != null && line >= 1) return ExGoto(line);
|
||||||
|
|
||||||
|
return const ExUnknown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Execution (editor-targeted; no-op when no active buffer) ---------------
|
||||||
|
|
||||||
|
/// The active editor buffer id, or null when no buffer is active.
|
||||||
|
Future<String?> activeEditorBufferId(DaemonClient ipc) async {
|
||||||
|
final IpcResponse r = await ipc.request('editor.active');
|
||||||
|
if (!r.ok) return null;
|
||||||
|
final active = r.data['active'];
|
||||||
|
return active is Map ? active['id'] as String? : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:w` — save the active buffer. `editor.save` resolves the active buffer
|
||||||
|
/// server-side, so a missing buffer is a silent no-op.
|
||||||
|
Future<void> exWriteActive(DaemonClient ipc) async {
|
||||||
|
await ipc.request('editor.save');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:q` — close the active editor tab. The registry promotes the next buffer
|
||||||
|
/// (or collapses the split on the last one) — no separate split-close needed.
|
||||||
|
Future<void> exQuitActive(DaemonClient ipc) async {
|
||||||
|
final id = await activeEditorBufferId(ipc);
|
||||||
|
if (id == null) return;
|
||||||
|
await ipc.request('editor.close', args: {'id': id});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:wq` / `:x` / `ZZ` — save the active buffer then close its tab.
|
||||||
|
Future<void> exWriteQuitActive(DaemonClient ipc) async {
|
||||||
|
final id = await activeEditorBufferId(ipc);
|
||||||
|
if (id == null) return;
|
||||||
|
await ipc.request('editor.save', args: {'id': id});
|
||||||
|
await ipc.request('editor.close', args: {'id': id});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `:<n>` — jump the active buffer to 1-based [line]. `editor.goto-line`
|
||||||
|
/// resolves the active buffer server-side and clamps out-of-range lines.
|
||||||
|
Future<void> exGotoLineActive(DaemonClient ipc, int line) async {
|
||||||
|
await ipc.request('editor.goto-line', args: {'line': line});
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import 'package:clide/kernel/src/notify.dart';
|
|||||||
import 'package:clide/kernel/src/os.dart';
|
import 'package:clide/kernel/src/os.dart';
|
||||||
import 'package:clide/kernel/src/panels/arrangement.dart';
|
import 'package:clide/kernel/src/panels/arrangement.dart';
|
||||||
import 'package:clide/kernel/src/panels/registry.dart';
|
import 'package:clide/kernel/src/panels/registry.dart';
|
||||||
|
import 'package:clide/kernel/src/ex_line.dart';
|
||||||
import 'package:clide/kernel/src/project.dart';
|
import 'package:clide/kernel/src/project.dart';
|
||||||
import 'package:clide/kernel/src/quick_open.dart';
|
import 'package:clide/kernel/src/quick_open.dart';
|
||||||
import 'package:clide/kernel/src/reader_nav.dart';
|
import 'package:clide/kernel/src/reader_nav.dart';
|
||||||
@@ -58,6 +59,7 @@ class KernelServices {
|
|||||||
required this.commands,
|
required this.commands,
|
||||||
required this.palette,
|
required this.palette,
|
||||||
required this.quickOpen,
|
required this.quickOpen,
|
||||||
|
required this.exLine,
|
||||||
required this.recentFiles,
|
required this.recentFiles,
|
||||||
required this.readerNav,
|
required this.readerNav,
|
||||||
required this.keybindings,
|
required this.keybindings,
|
||||||
@@ -97,6 +99,9 @@ class KernelServices {
|
|||||||
final CommandRegistry commands;
|
final CommandRegistry commands;
|
||||||
final PaletteController palette;
|
final PaletteController palette;
|
||||||
final QuickOpenController quickOpen;
|
final QuickOpenController quickOpen;
|
||||||
|
|
||||||
|
/// Transient Vim ex command-line overlay state (T-407).
|
||||||
|
final ExLineController exLine;
|
||||||
final RecentFilesService recentFiles;
|
final RecentFilesService recentFiles;
|
||||||
final ReaderNavRegistry readerNav;
|
final ReaderNavRegistry readerNav;
|
||||||
final KeybindingResolver keybindings;
|
final KeybindingResolver keybindings;
|
||||||
@@ -137,9 +142,13 @@ class KernelServices {
|
|||||||
Future<void> Function(String path)? onProjectOpen,
|
Future<void> Function(String path)? onProjectOpen,
|
||||||
Future<String?> Function(String path)? onValidateProject,
|
Future<String?> Function(String path)? onValidateProject,
|
||||||
DaemonBus? sharedBus,
|
DaemonBus? sharedBus,
|
||||||
|
List<LogSink> additionalSinks = const [],
|
||||||
|
LogLevel? minLogLevel,
|
||||||
}) async {
|
}) async {
|
||||||
final logRing = LogRing();
|
final logRing = LogRing();
|
||||||
final log = Logger(sinks: [stderrSink, logRing.add]);
|
// additionalSinks lead the chain so a crash-survivable sink (FileLogSink,
|
||||||
|
// T-425) records the tail before the volatile stderr/ring sinks run.
|
||||||
|
final log = Logger(minLevel: minLogLevel ?? LogLevel.info, sinks: [...additionalSinks, stderrSink, logRing.add]);
|
||||||
final events = sharedBus ?? DaemonBus();
|
final events = sharedBus ?? DaemonBus();
|
||||||
final messages = MessageBus();
|
final messages = MessageBus();
|
||||||
final filterStates = FilterStateCache(messages: messages);
|
final filterStates = FilterStateCache(messages: messages);
|
||||||
@@ -163,6 +172,7 @@ class KernelServices {
|
|||||||
final palette = PaletteController(commands);
|
final palette = PaletteController(commands);
|
||||||
final recentFiles = RecentFilesService();
|
final recentFiles = RecentFilesService();
|
||||||
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
|
final quickOpen = QuickOpenController(recentPaths: () => recentFiles.paths);
|
||||||
|
final exLine = ExLineController();
|
||||||
final readerNav = ReaderNavRegistry(messages);
|
final readerNav = ReaderNavRegistry(messages);
|
||||||
final clipboard = ClideClipboard();
|
final clipboard = ClideClipboard();
|
||||||
final files = FileServices(events);
|
final files = FileServices(events);
|
||||||
@@ -249,6 +259,7 @@ class KernelServices {
|
|||||||
commands: commands,
|
commands: commands,
|
||||||
palette: palette,
|
palette: palette,
|
||||||
quickOpen: quickOpen,
|
quickOpen: quickOpen,
|
||||||
|
exLine: exLine,
|
||||||
recentFiles: recentFiles,
|
recentFiles: recentFiles,
|
||||||
readerNav: readerNav,
|
readerNav: readerNav,
|
||||||
keybindings: keybindings,
|
keybindings: keybindings,
|
||||||
@@ -282,6 +293,7 @@ class KernelServices {
|
|||||||
commands.dispose();
|
commands.dispose();
|
||||||
palette.dispose();
|
palette.dispose();
|
||||||
quickOpen.dispose();
|
quickOpen.dispose();
|
||||||
|
exLine.dispose();
|
||||||
toast.dispose();
|
toast.dispose();
|
||||||
recentFiles.dispose();
|
recentFiles.dispose();
|
||||||
readerNav.dispose();
|
readerNav.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/// Crash-survivable [LogSink] (T-425).
|
||||||
|
///
|
||||||
|
/// Every other sink in clide is volatile: [stderrSink] dies with the console,
|
||||||
|
/// the [LogRing] dies with the process. The Windows freeze that motivated this
|
||||||
|
/// (a hard power-cycle, no dumps, no logs) left no evidence for exactly that
|
||||||
|
/// reason. [FileLogSink] is the durable tail: it appends each [LogRecord] as
|
||||||
|
/// one JSON line to a size-rotated file under a persistent per-platform log
|
||||||
|
/// dir, and — crucially — fsyncs the records most likely to immediately
|
||||||
|
/// precede a crash, so the last breadcrumb is on disk before the box dies.
|
||||||
|
///
|
||||||
|
/// Design choices that matter for a CRASH logger:
|
||||||
|
/// - Synchronous I/O only. No async buffering / no IOSink — a hard death
|
||||||
|
/// between an `await` and its flush would lose the tail, which is the one
|
||||||
|
/// thing this sink exists to keep.
|
||||||
|
/// - Tiered flush. `warn`/`error` and records from inherently-risky sources
|
||||||
|
/// (pty/ffi/conpty/watchdog) `flushSync` immediately. High-volume
|
||||||
|
/// `info`/`debug` write through to the OS (surviving a process crash) and
|
||||||
|
/// are fsynced on a low-frequency timer — enough to bound power-loss to a
|
||||||
|
/// couple of seconds without an fsync per line.
|
||||||
|
/// - Never throws. A disk-full / permission error must not take logging — or
|
||||||
|
/// the app — down; every operation swallows its own failure.
|
||||||
|
///
|
||||||
|
/// Flutter-free (only `dart:io`/`dart:async`/`dart:convert` + [LogRecord]) so
|
||||||
|
/// it unit-tests under `dart test` against a temp dir, and so the PTY/FFI
|
||||||
|
/// layer (also Flutter-free) can route breadcrumbs through it.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'log.dart';
|
||||||
|
|
||||||
|
class FileLogSink {
|
||||||
|
FileLogSink({
|
||||||
|
required Directory dir,
|
||||||
|
String baseName = 'clide',
|
||||||
|
int maxBytes = 5 * 1024 * 1024,
|
||||||
|
int maxFiles = 5,
|
||||||
|
Set<String> eagerSources = const {'pty', 'ffi', 'conpty', 'watchdog'},
|
||||||
|
LogLevel eagerLevel = LogLevel.warn,
|
||||||
|
Duration flushInterval = const Duration(seconds: 2),
|
||||||
|
bool startFlushTimer = true,
|
||||||
|
}) : _dir = dir,
|
||||||
|
_baseName = baseName,
|
||||||
|
_maxBytes = maxBytes,
|
||||||
|
_maxFiles = maxFiles < 1 ? 1 : maxFiles,
|
||||||
|
_eagerSources = eagerSources,
|
||||||
|
_eagerLevel = eagerLevel {
|
||||||
|
_open();
|
||||||
|
if (startFlushTimer && flushInterval > Duration.zero) {
|
||||||
|
_timer = Timer.periodic(flushInterval, (_) => _flush());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Directory _dir;
|
||||||
|
final String _baseName;
|
||||||
|
final int _maxBytes;
|
||||||
|
final int _maxFiles;
|
||||||
|
final Set<String> _eagerSources;
|
||||||
|
final LogLevel _eagerLevel;
|
||||||
|
|
||||||
|
RandomAccessFile? _raf;
|
||||||
|
int _size = 0;
|
||||||
|
bool _dirty = false;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
String get _sep => Platform.pathSeparator;
|
||||||
|
File get _active => File('${_dir.path}$_sep$_baseName.log');
|
||||||
|
File _archive(int i) => File('${_dir.path}$_sep$_baseName.$i.log');
|
||||||
|
|
||||||
|
/// The active log file's path — handy for the caller to surface (e.g. an
|
||||||
|
/// "open log folder" affordance) or to add to a CI artifact upload.
|
||||||
|
String get activePath => _active.path;
|
||||||
|
|
||||||
|
void _open() {
|
||||||
|
try {
|
||||||
|
_dir.createSync(recursive: true);
|
||||||
|
final f = _active;
|
||||||
|
_size = f.existsSync() ? f.lengthSync() : 0;
|
||||||
|
_raf = f.openSync(mode: FileMode.append);
|
||||||
|
} catch (_) {
|
||||||
|
_raf = null; // a disk problem must never kill logging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The [LogSink] entry point: `logger.addSink(fileSink.call)`.
|
||||||
|
void call(LogRecord r) {
|
||||||
|
if (_raf == null) return;
|
||||||
|
try {
|
||||||
|
final bytes = utf8.encode('${jsonEncode(_encode(r))}\n');
|
||||||
|
// Rotate BEFORE writing when this line would push the file past the cap,
|
||||||
|
// so the newest entries always live in the active file (and a single
|
||||||
|
// oversized line still lands rather than spinning rotations on an empty
|
||||||
|
// file).
|
||||||
|
if (_size > 0 && _size + bytes.length > _maxBytes) _rotate();
|
||||||
|
final raf = _raf;
|
||||||
|
if (raf == null) return;
|
||||||
|
raf.writeFromSync(bytes);
|
||||||
|
_size += bytes.length;
|
||||||
|
_dirty = true;
|
||||||
|
if (r.level.index >= _eagerLevel.index || _eagerSources.contains(r.source)) {
|
||||||
|
raf.flushSync();
|
||||||
|
_dirty = false;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// swallow — a logging failure must never propagate to the app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?> _encode(LogRecord r) => {
|
||||||
|
'ts': r.timestamp.toIso8601String(),
|
||||||
|
'lvl': r.level.name,
|
||||||
|
'src': r.source,
|
||||||
|
'msg': r.message,
|
||||||
|
if (r.error != null) 'err': r.error.toString(),
|
||||||
|
if (r.stackTrace != null) 'stack': r.stackTrace.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
void _flush() {
|
||||||
|
if (!_dirty) return;
|
||||||
|
try {
|
||||||
|
_raf?.flushSync();
|
||||||
|
_dirty = false;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Roll `<base>.log` → `<base>.1.log`, shifting older archives up and
|
||||||
|
/// dropping the oldest past [maxFiles]. With `maxFiles == 1` the active file
|
||||||
|
/// is simply truncated (no archives kept).
|
||||||
|
void _rotate() {
|
||||||
|
try {
|
||||||
|
_raf?.flushSync();
|
||||||
|
_raf?.closeSync();
|
||||||
|
} catch (_) {}
|
||||||
|
_raf = null;
|
||||||
|
try {
|
||||||
|
if (_maxFiles <= 1) {
|
||||||
|
if (_active.existsSync()) _active.deleteSync();
|
||||||
|
} else {
|
||||||
|
final oldest = _archive(_maxFiles - 1);
|
||||||
|
if (oldest.existsSync()) oldest.deleteSync();
|
||||||
|
for (var i = _maxFiles - 2; i >= 1; i--) {
|
||||||
|
final src = _archive(i);
|
||||||
|
if (src.existsSync()) src.renameSync(_archive(i + 1).path);
|
||||||
|
}
|
||||||
|
if (_active.existsSync()) _active.renameSync(_archive(1).path);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
_size = 0;
|
||||||
|
_open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush + close. Call on orderly shutdown; a crash is covered by the eager
|
||||||
|
/// fsync above, not by this.
|
||||||
|
Future<void> close() async {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
try {
|
||||||
|
_raf?.flushSync();
|
||||||
|
_raf?.closeSync();
|
||||||
|
} catch (_) {}
|
||||||
|
_raf = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,22 @@ class QuickOpenAcceptIntent extends Intent {
|
|||||||
const QuickOpenAcceptIntent();
|
const QuickOpenAcceptIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Vim ex command-line (T-407) --------------------------------------------
|
||||||
|
|
||||||
|
/// Open the Vim ex command-line overlay (`:`). A typed intent (not a
|
||||||
|
/// `command:` bridge) so it survives the editor's command-mode matcher and a
|
||||||
|
/// focused pane's nav matcher, both of which bubble unhandled typed intents to
|
||||||
|
/// the app-root Actions where this resolves to `services.exLine.open()`.
|
||||||
|
class ExLineOpenIntent extends Intent {
|
||||||
|
const ExLineOpenIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the active buffer and close its tab (`ZZ`), without opening the
|
||||||
|
/// overlay — shares the `:wq` execution path.
|
||||||
|
class ExLineWriteQuitIntent extends Intent {
|
||||||
|
const ExLineWriteQuitIntent();
|
||||||
|
}
|
||||||
|
|
||||||
// -- Find in files ----------------------------------------------------------
|
// -- Find in files ----------------------------------------------------------
|
||||||
|
|
||||||
/// Reveal the find-in-files search panel in the sidebar.
|
/// Reveal the find-in-files search panel in the sidebar.
|
||||||
@@ -98,6 +114,63 @@ class TextScaleResetIntent extends Intent {
|
|||||||
const TextScaleResetIntent();
|
const TextScaleResetIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Pane navigation (vim normal-mode motions outside the editor) ------------
|
||||||
|
|
||||||
|
/// Base for the preset-neutral navigation intents (T-406). A focused non-editor
|
||||||
|
/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and
|
||||||
|
/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds
|
||||||
|
/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys
|
||||||
|
/// to the same ids. Marker base so a pane's key handler can tell a nav motion
|
||||||
|
/// apart from any other fired intent.
|
||||||
|
sealed class NavIntent extends Intent {
|
||||||
|
const NavIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the selection / scroll down one step (vim `j`).
|
||||||
|
class NavDownIntent extends NavIntent {
|
||||||
|
const NavDownIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the selection / scroll up one step (vim `k`).
|
||||||
|
class NavUpIntent extends NavIntent {
|
||||||
|
const NavUpIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll down half a viewport (vim `ctrl+d`).
|
||||||
|
class NavPageDownIntent extends NavIntent {
|
||||||
|
const NavPageDownIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll up half a viewport (vim `ctrl+u`).
|
||||||
|
class NavPageUpIntent extends NavIntent {
|
||||||
|
const NavPageUpIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump to the first item / top (vim `gg`).
|
||||||
|
class NavTopIntent extends NavIntent {
|
||||||
|
const NavTopIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jump to the last item / bottom (vim `G`).
|
||||||
|
class NavBottomIntent extends NavIntent {
|
||||||
|
const NavBottomIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand the focused node, or step into it / move right (vim `l`).
|
||||||
|
class NavExpandOrRightIntent extends NavIntent {
|
||||||
|
const NavExpandOrRightIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse the focused node, or step out of it / move left (vim `h`).
|
||||||
|
class NavCollapseOrLeftIntent extends NavIntent {
|
||||||
|
const NavCollapseOrLeftIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate the focused item — open the file, run the row (vim `o` / `enter`).
|
||||||
|
class NavActivateIntent extends NavIntent {
|
||||||
|
const NavActivateIntent();
|
||||||
|
}
|
||||||
|
|
||||||
// -- Command bridge ---------------------------------------------------------
|
// -- Command bridge ---------------------------------------------------------
|
||||||
|
|
||||||
/// Generic "invoke this CommandRegistry command id" intent. Used for
|
/// Generic "invoke this CommandRegistry command id" intent. Used for
|
||||||
@@ -136,6 +209,18 @@ final Map<String, Intent Function()> builtinIntents = {
|
|||||||
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
|
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
|
||||||
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
|
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
|
||||||
'findInFiles.open': () => const FindInFilesIntent(),
|
'findInFiles.open': () => const FindInFilesIntent(),
|
||||||
|
'exline.open': () => const ExLineOpenIntent(),
|
||||||
|
'exline.writeQuit': () => const ExLineWriteQuitIntent(),
|
||||||
|
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
|
||||||
|
'nav.down': () => const NavDownIntent(),
|
||||||
|
'nav.up': () => const NavUpIntent(),
|
||||||
|
'nav.pageDown': () => const NavPageDownIntent(),
|
||||||
|
'nav.pageUp': () => const NavPageUpIntent(),
|
||||||
|
'nav.top': () => const NavTopIntent(),
|
||||||
|
'nav.bottom': () => const NavBottomIntent(),
|
||||||
|
'nav.expandOrRight': () => const NavExpandOrRightIntent(),
|
||||||
|
'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(),
|
||||||
|
'nav.activate': () => const NavActivateIntent(),
|
||||||
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
||||||
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
||||||
'text.scaleReset': () => const TextScaleResetIntent(),
|
'text.scaleReset': () => const TextScaleResetIntent(),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
///
|
///
|
||||||
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
|
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
|
||||||
/// `palette.open`, `editor.focused`). Producing services call
|
/// `palette.open`, `editor.focused`). Producing services call
|
||||||
/// [setScopeFlag] when their state changes; consumers reference the
|
/// `setScopeFlag` when their state changes; consumers reference the
|
||||||
/// flag name in when-clauses.
|
/// flag name in when-clauses.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
/// Detects a double-tapped bare modifier (e.g. JetBrains "Search
|
/// Detects a double-tapped bare modifier (e.g. JetBrains "Search
|
||||||
/// Everywhere" = double-Shift). (T-341)
|
/// Everywhere" = double-Shift). (T-341)
|
||||||
///
|
///
|
||||||
/// Headless and clock-injected: the caller (the global key handler) passes
|
/// A "tap" is a clean press-and-release: no other key may go down while the
|
||||||
/// the event time so it neither reads a clock nor consumes events. Feed it
|
/// modifier is held, otherwise the press was a chord (`Shift+;` typing a
|
||||||
/// every [KeyDownEvent]: a bare modifier press via [tap], any other key via
|
/// colon) and must not count (T-409). The gesture therefore completes on the
|
||||||
/// [reset] (an intervening key breaks the gesture, e.g. `Shift a Shift`).
|
/// second clean *release*, never on a key-down — at down time it's unknowable
|
||||||
|
/// whether the press will stay bare.
|
||||||
|
///
|
||||||
|
/// Headless and clock-injected: the caller (the root shell's raw-keyboard
|
||||||
|
/// handler) passes the event time so it neither reads a clock nor consumes
|
||||||
|
/// events. Feed every [KeyDownEvent] to `down` and every [KeyUpEvent] to
|
||||||
|
/// `up`, passing the event's [KeyModifier] (null for non-modifier keys).
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'key_chord.dart';
|
import 'key_chord.dart';
|
||||||
@@ -12,33 +18,50 @@ import 'key_chord.dart';
|
|||||||
class ModifierTapTracker {
|
class ModifierTapTracker {
|
||||||
ModifierTapTracker({this.window = const Duration(milliseconds: 350)});
|
ModifierTapTracker({this.window = const Duration(milliseconds: 350)});
|
||||||
|
|
||||||
/// Max gap between the two taps to count as a double-tap.
|
/// Max gap between the two tap releases to count as a double-tap.
|
||||||
final Duration window;
|
final Duration window;
|
||||||
|
|
||||||
KeyModifier? _last;
|
/// Modifier currently held whose press is still bare (no chorded key yet).
|
||||||
DateTime? _lastAt;
|
KeyModifier? _pressing;
|
||||||
|
|
||||||
/// Record a bare-modifier press at [now]. Returns the modifier when this
|
/// Modifier of the last completed clean tap, arming the double-tap.
|
||||||
/// press completes a double-tap of the *same* modifier within [window];
|
KeyModifier? _armed;
|
||||||
/// otherwise records it as the first tap and returns null.
|
DateTime? _armedAt;
|
||||||
KeyModifier? tap(KeyModifier m, DateTime now) {
|
|
||||||
final last = _last;
|
/// Record a key press. A non-modifier key ([mod] == null) — or any key
|
||||||
final lastAt = _lastAt;
|
/// landing while a modifier is already held — is a chord: it dirties the
|
||||||
if (last == m && lastAt != null) {
|
/// held press and breaks the armed gesture.
|
||||||
final gap = now.difference(lastAt);
|
void down(KeyModifier? mod) {
|
||||||
|
if (mod == null || _pressing != null) {
|
||||||
|
_pressing = null;
|
||||||
|
_disarm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_pressing = mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a key release at [now]. Returns the modifier when this release
|
||||||
|
/// completes a double-tap: the second clean tap of the *same* modifier
|
||||||
|
/// within [window] of the first tap's release.
|
||||||
|
KeyModifier? up(KeyModifier? mod, DateTime now) {
|
||||||
|
if (mod == null) return null;
|
||||||
|
final pressing = _pressing;
|
||||||
|
_pressing = null;
|
||||||
|
if (pressing != mod) return null; // press went dirty (chorded) or stale
|
||||||
|
if (_armed == mod && _armedAt != null) {
|
||||||
|
final gap = now.difference(_armedAt!);
|
||||||
if (gap >= Duration.zero && gap <= window) {
|
if (gap >= Duration.zero && gap <= window) {
|
||||||
reset();
|
_disarm();
|
||||||
return m;
|
return mod;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_last = m;
|
_armed = mod;
|
||||||
_lastAt = now;
|
_armedAt = now;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Break the gesture — any non-modifier key press resets the tracker.
|
void _disarm() {
|
||||||
void reset() {
|
_armed = null;
|
||||||
_last = null;
|
_armedAt = null;
|
||||||
_lastAt = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/// A reusable vim normal-mode navigation key handler for non-editor panes
|
||||||
|
/// (T-406).
|
||||||
|
///
|
||||||
|
/// The passive global key path is single-chord only and can't run sequences or
|
||||||
|
/// consume events (D-82), so — exactly like the editor's command-mode handler —
|
||||||
|
/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a
|
||||||
|
/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file
|
||||||
|
/// tree, conversation, and lists share one implementation.
|
||||||
|
///
|
||||||
|
/// While a `vim.normal` scope flag is set and this region holds focus, bare and
|
||||||
|
/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed
|
||||||
|
/// the matcher against the live keymap; a fired [NavIntent] is handed to
|
||||||
|
/// `onNav` with its repeat count. Everything else under `vim.normal` is
|
||||||
|
/// swallowed (vim normal mode is inert for unbound keys), except other-modifier
|
||||||
|
/// chords (palette, quick-open, …) which bubble to the global handler. Under a
|
||||||
|
/// non-vim preset or in insert mode the region is transparent — keys pass
|
||||||
|
/// straight through.
|
||||||
|
///
|
||||||
|
/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key
|
||||||
|
/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav
|
||||||
|
/// intent here and to the editor motion in the editor — see vim.yaml.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../facade.dart';
|
||||||
|
import 'intents.dart';
|
||||||
|
import 'key_chord.dart';
|
||||||
|
import 'keymap.dart';
|
||||||
|
import 'sequence_matcher.dart';
|
||||||
|
|
||||||
|
/// Signature for a fired navigation motion: the [intent] and its repeat
|
||||||
|
/// [count] (>= 1, from a leading digit prefix like `5j`).
|
||||||
|
typedef NavHandler = void Function(NavIntent intent, int count);
|
||||||
|
|
||||||
|
class PaneKeyNav extends StatefulWidget {
|
||||||
|
const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// Called when a `nav.*` motion resolves while this region has focus.
|
||||||
|
final NavHandler onNav;
|
||||||
|
|
||||||
|
/// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that
|
||||||
|
/// want to move focus here programmatically (a row tap, F6) pass their own.
|
||||||
|
final FocusNode? focusNode;
|
||||||
|
|
||||||
|
final bool autofocus;
|
||||||
|
|
||||||
|
/// Whether the region can take focus at all. False makes it a pure pass-through
|
||||||
|
/// (used when a pane temporarily routes keys elsewhere, e.g. a filter box).
|
||||||
|
final bool canRequestFocus;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PaneKeyNav> createState() => _PaneKeyNavState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaneKeyNavState extends State<PaneKeyNav> {
|
||||||
|
FocusNode? _ownNode;
|
||||||
|
SequenceMatcher? _matcher;
|
||||||
|
|
||||||
|
FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav'));
|
||||||
|
|
||||||
|
/// The half-page scroll chords are the only modified chords this handler
|
||||||
|
/// claims; every other modified chord bubbles to the global shortcut path.
|
||||||
|
static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD);
|
||||||
|
static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_matcher != null) return;
|
||||||
|
final kernel = ClideKernel.of(context);
|
||||||
|
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ownNode?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||||
|
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||||
|
final kernel = ClideKernel.of(context);
|
||||||
|
// Only vim normal mode drives pane navigation. Insert/visual or a non-vim
|
||||||
|
// preset → transparent, keys pass through to whatever's below.
|
||||||
|
if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored;
|
||||||
|
|
||||||
|
final hw = HardwareKeyboard.instance;
|
||||||
|
final chord = KeyChord.fromKeyEvent(event, hw);
|
||||||
|
if (chord == null) return KeyEventResult.ignored;
|
||||||
|
|
||||||
|
// Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only
|
||||||
|
// modified chords we claim (half-page scroll). Any other modified chord is
|
||||||
|
// an app shortcut (palette, quick-open) — let it bubble to the global path.
|
||||||
|
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
|
||||||
|
if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored;
|
||||||
|
|
||||||
|
final r = _matcher!.feed(chord);
|
||||||
|
switch (r.outcome) {
|
||||||
|
case SeqOutcome.fired:
|
||||||
|
// The vim preset binds these keys to several intent kinds. In a pane:
|
||||||
|
// - nav.* drives the pane (onNav);
|
||||||
|
// - editor.vim.* buffer edits are swallowed — never edit from a pane;
|
||||||
|
// - other commands (workspace.tab.* gt/gT, panel.*) execute (T-405);
|
||||||
|
// - a typed app intent (ex-line `:` / ZZ) bubbles to the app-root
|
||||||
|
// Actions for its global handler (T-407).
|
||||||
|
final fired = r.intent;
|
||||||
|
if (fired is NavIntent) {
|
||||||
|
widget.onNav(fired, r.count);
|
||||||
|
} else if (fired is InvokeCommandIntent) {
|
||||||
|
if (!fired.commandId.startsWith('editor.vim.')) {
|
||||||
|
unawaited(kernel.commands.execute(fired.commandId));
|
||||||
|
}
|
||||||
|
} else if (fired != null) {
|
||||||
|
Actions.maybeInvoke(context, fired);
|
||||||
|
}
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
case SeqOutcome.pending:
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
case SeqOutcome.unmatched:
|
||||||
|
// Vim normal mode beeps on unbound keys — swallow so a bare key never
|
||||||
|
// leaks to text input or the global handler.
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
/// and := unary ('&&' unary)*
|
/// and := unary ('&&' unary)*
|
||||||
/// unary := '!' unary | atom
|
/// unary := '!' unary | atom
|
||||||
/// atom := IDENT | '(' expr ')'
|
/// atom := IDENT | '(' expr ')'
|
||||||
/// IDENT := [a-zA-Z_][a-zA-Z0-9._-]*
|
/// IDENT := `[a-zA-Z_][a-zA-Z0-9._-]*`
|
||||||
///
|
///
|
||||||
/// Identifiers resolve against a `Map<String, bool>` context. A missing
|
/// Identifiers resolve against a `Map<String, bool>` context. A missing
|
||||||
/// identifier evaluates to `false` — bindings can assume any required
|
/// identifier evaluates to `false` — bindings can assume any required
|
||||||
|
|||||||
@@ -62,3 +62,32 @@ void stderrSink(LogRecord r) {
|
|||||||
stderr.writeln(r);
|
stderr.writeln(r);
|
||||||
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
|
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a level name (case-insensitive, trimmed) to a [LogLevel], or null if
|
||||||
|
/// it is absent/blank/unknown — so an invalid source falls through to the next
|
||||||
|
/// one in [resolveLogLevel] rather than crashing the boot.
|
||||||
|
LogLevel? parseLogLevel(String? name) {
|
||||||
|
if (name == null) return null;
|
||||||
|
final n = name.trim().toLowerCase();
|
||||||
|
if (n.isEmpty) return null;
|
||||||
|
for (final l in LogLevel.values) {
|
||||||
|
if (l.name == n) return l;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the effective [Logger.minLevel] at boot — the dev/prod verbosity
|
||||||
|
/// toggle (T-425). Highest precedence first:
|
||||||
|
///
|
||||||
|
/// 1. `--dart-define=CLIDE_LOG=<level>` (baked into the build)
|
||||||
|
/// 2. the `CLIDE_LOG` environment variable
|
||||||
|
/// 3. the `app.log.level` setting
|
||||||
|
/// 4. a build-mode default: `warn` in release (a shipped app stays quiet),
|
||||||
|
/// `info` in debug.
|
||||||
|
///
|
||||||
|
/// Each named source is parsed leniently; an unknown name is skipped, not
|
||||||
|
/// fatal. The build-mode flag is passed in (rather than read here) to keep
|
||||||
|
/// this Flutter-free — `main.dart` supplies `kReleaseMode`.
|
||||||
|
LogLevel resolveLogLevel({required bool isRelease, String? dartDefine, String? envVar, String? settingValue}) {
|
||||||
|
return parseLogLevel(dartDefine) ?? parseLogLevel(envVar) ?? parseLogLevel(settingValue) ?? (isRelease ? LogLevel.warn : LogLevel.info);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
/// output dock (T-54 / D-87) reads on open.
|
/// output dock (T-54 / D-87) reads on open.
|
||||||
///
|
///
|
||||||
/// The [Logger] only live-broadcasts to its stream; a panel that opens late
|
/// The [Logger] only live-broadcasts to its stream; a panel that opens late
|
||||||
/// would see nothing. [LogRing] is a sink that keeps the last [capacity]
|
/// would see nothing. [LogRing] is a sink that keeps the last `capacity`
|
||||||
/// records (drop-oldest, same shape as the D-85 event ring) plus enough
|
/// records (drop-oldest, same shape as the D-85 event ring) plus enough
|
||||||
/// bookkeeping to drive the panel's filter dropdown (distinct [sources]) and
|
/// bookkeeping to drive the panel's filter dropdown (distinct `sources`) and
|
||||||
/// the status-bar health badge (level counts).
|
/// the status-bar health badge (level counts).
|
||||||
///
|
///
|
||||||
/// Flutter-free (only `dart:async`/`dart:collection` + the [LogRecord] type)
|
/// Flutter-free (only `dart:async`/`dart:collection` + the [LogRecord] type)
|
||||||
@@ -42,7 +42,7 @@ class LogRing {
|
|||||||
int get length => _records.length;
|
int get length => _records.length;
|
||||||
bool get isEmpty => _records.isEmpty;
|
bool get isEmpty => _records.isEmpty;
|
||||||
|
|
||||||
/// Append a record (the [Logger] sink). Drops the oldest past [capacity].
|
/// Append a record (the [Logger] sink). Drops the oldest past `capacity`.
|
||||||
void add(LogRecord r) {
|
void add(LogRecord r) {
|
||||||
_records.addLast(r);
|
_records.addLast(r);
|
||||||
_sourceCounts.update(r.source, (n) => n + 1, ifAbsent: () => 1);
|
_sourceCounts.update(r.source, (n) => n + 1, ifAbsent: () => 1);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// Snapshots the kernel's live tabs into [ViewPane]s so `pane list` reflects
|
/// Snapshots the kernel's live tabs into [ViewPane]s so `pane list` reflects
|
||||||
/// the panes the user actually sees in the GUI (T-219, D-6 parity / D-83).
|
/// the panes the user actually sees in the GUI (T-219, D-6 parity / D-83).
|
||||||
///
|
///
|
||||||
/// Read-at-request-time: no state is mirrored into the IPC [PaneRegistry], so
|
/// Read-at-request-time: no state is mirrored into the IPC `PaneRegistry`, so
|
||||||
/// nothing can drift from the live UI. Lives in the kernel (not `lib/src/panes/`)
|
/// nothing can drift from the live UI. Lives in the kernel (not `lib/src/panes/`)
|
||||||
/// because it reads Flutter-coupled kernel state; it produces the Flutter-free
|
/// because it reads Flutter-coupled kernel state; it produces the Flutter-free
|
||||||
/// [ViewPane] the pane command serialises.
|
/// [ViewPane] the pane command serialises.
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ class QuickOpenController extends ChangeNotifier {
|
|||||||
return _selectedIndex.clamp(0, n - 1);
|
return _selectedIndex.clamp(0, n - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void open() {
|
/// Open the picker. An optional [seed] pre-fills the filter — used by the
|
||||||
|
/// ex-line `:e <path>` command (T-407) to jump straight to a query.
|
||||||
|
void open({String? seed}) {
|
||||||
if (_open) return;
|
if (_open) return;
|
||||||
_open = true;
|
_open = true;
|
||||||
_filter = '';
|
_filter = seed ?? '';
|
||||||
_selectedIndex = 0;
|
_selectedIndex = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/// Pure syntax-highlight result types + the capture-role→theme-color map
|
||||||
|
/// (T-438 web fence, D-100). No `dart:ffi`, so it is shared by the FFI-backed
|
||||||
|
/// [TreeSitterService] impl and its web stub — both expose identical data types.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' show Color;
|
||||||
|
|
||||||
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
|
|
||||||
|
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
|
||||||
|
/// Throws on missing or unreadable assets.
|
||||||
|
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
|
||||||
|
|
||||||
|
/// Loads the highlight query (`.scm` source) for [language], or returns
|
||||||
|
/// null if no query is bundled for it.
|
||||||
|
typedef GrammarQueryLoader = Future<String?> Function(String language);
|
||||||
|
|
||||||
|
class SyntaxSpan {
|
||||||
|
const SyntaxSpan({required this.start, required this.end, required this.role});
|
||||||
|
|
||||||
|
final int start;
|
||||||
|
final int end;
|
||||||
|
final String role;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyntaxResult {
|
||||||
|
const SyntaxResult(this.spans);
|
||||||
|
final List<SyntaxSpan> spans;
|
||||||
|
|
||||||
|
static const empty = SyntaxResult([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a tree-sitter capture [role] to a theme color.
|
||||||
|
Color syntaxColorForRole(String role, SurfaceTokens tokens) {
|
||||||
|
return switch (role) {
|
||||||
|
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
|
||||||
|
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
|
||||||
|
'string' || 'string.special' => tokens.syntaxString,
|
||||||
|
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
|
||||||
|
'comment' => tokens.syntaxComment,
|
||||||
|
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
|
||||||
|
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
|
||||||
|
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
|
||||||
|
'property' || 'field' => tokens.syntaxMethod,
|
||||||
|
'constant' || 'constant.builtin' => tokens.syntaxNumber,
|
||||||
|
'tag' || 'attribute' => tokens.syntaxKeyword,
|
||||||
|
'namespace' || 'module' => tokens.syntaxType,
|
||||||
|
'text.title' => tokens.syntaxKeyword,
|
||||||
|
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
|
||||||
|
'text.emphasis' || 'text.strong' => tokens.syntaxType,
|
||||||
|
_ => tokens.globalForeground,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/// Desktop tree-sitter bootstrap (T-438 web fence, D-100): dlopen the vendored
|
||||||
|
/// libtree-sitter once at startup. The web build uses [tree_sitter_boot_stub.dart].
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||||
|
|
||||||
|
/// Initialize the tree-sitter library; returns false if it can't be loaded.
|
||||||
|
bool initTreeSitter() => TreeSitterLib.init();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/// Web stub (T-438 web fence, D-100): no tree-sitter FFI to initialize.
|
||||||
|
library;
|
||||||
|
|
||||||
|
bool initTreeSitter() => false;
|
||||||
@@ -1,303 +1,7 @@
|
|||||||
|
/// Platform facade for the tree-sitter highlighter (T-438 web fence, D-100):
|
||||||
|
/// the FFI-backed [TreeSitterService] on desktop, a no-op stub on web. Both
|
||||||
|
/// re-export the shared [SyntaxSpan]/[SyntaxResult] types and `colorForRole`,
|
||||||
|
/// so consumers import this file unchanged.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:convert' show utf8;
|
export 'tree_sitter_service_stub.dart' if (dart.library.ffi) 'tree_sitter_service_ffi.dart';
|
||||||
import 'dart:ffi';
|
|
||||||
import 'dart:ui' show Color;
|
|
||||||
|
|
||||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
|
||||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
|
||||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
|
||||||
import 'package:ffi/ffi.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
|
||||||
|
|
||||||
/// Loads grammar WASM bytes for [language] (e.g. "dart" → `dart.wasm`).
|
|
||||||
/// Throws on missing or unreadable assets.
|
|
||||||
typedef GrammarBytesLoader = Future<Uint8List> Function(String language);
|
|
||||||
|
|
||||||
/// Loads the highlight query (`.scm` source) for [language], or returns
|
|
||||||
/// null if no query is bundled for it.
|
|
||||||
typedef GrammarQueryLoader = Future<String?> Function(String language);
|
|
||||||
|
|
||||||
class SyntaxSpan {
|
|
||||||
const SyntaxSpan({required this.start, required this.end, required this.role});
|
|
||||||
|
|
||||||
final int start;
|
|
||||||
final int end;
|
|
||||||
final String role;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SyntaxResult {
|
|
||||||
const SyntaxResult(this.spans);
|
|
||||||
final List<SyntaxSpan> spans;
|
|
||||||
|
|
||||||
static const empty = SyntaxResult([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoadedGrammar {
|
|
||||||
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
|
|
||||||
|
|
||||||
final Pointer<Void> language;
|
|
||||||
final Pointer<TSQuery> query;
|
|
||||||
final List<String> captureNames;
|
|
||||||
}
|
|
||||||
|
|
||||||
class TreeSitterService {
|
|
||||||
static final TreeSitterService shared = TreeSitterService();
|
|
||||||
|
|
||||||
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
|
|
||||||
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
|
|
||||||
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
|
|
||||||
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
|
|
||||||
: _injectedLib = lib,
|
|
||||||
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
|
|
||||||
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
|
|
||||||
|
|
||||||
final TreeSitterLib? _injectedLib;
|
|
||||||
final GrammarBytesLoader _grammarBytes;
|
|
||||||
final GrammarQueryLoader _grammarQuery;
|
|
||||||
|
|
||||||
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
|
|
||||||
|
|
||||||
static Future<Uint8List> _defaultGrammarBytes(String language) async {
|
|
||||||
final data = await rootBundle.load('assets/grammars/$language.wasm');
|
|
||||||
return data.buffer.asUint8List();
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<String?> _defaultGrammarQuery(String language) async {
|
|
||||||
try {
|
|
||||||
return await rootBundle.loadString('assets/queries/$language.scm');
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, _LoadedGrammar> _grammars = {};
|
|
||||||
final Set<String> _unavailable = {};
|
|
||||||
|
|
||||||
Pointer<TSWasmStore>? _store;
|
|
||||||
Pointer<TSParser>? _parser;
|
|
||||||
Pointer<TSQueryCursor>? _cursor;
|
|
||||||
|
|
||||||
bool _initDone = false;
|
|
||||||
|
|
||||||
bool _init() {
|
|
||||||
if (_initDone) return _parser != null;
|
|
||||||
_initDone = true;
|
|
||||||
|
|
||||||
final lib = _lib;
|
|
||||||
if (lib == null) return false;
|
|
||||||
|
|
||||||
final engine = lib.wasmEngineNew();
|
|
||||||
if (engine == nullptr) return false;
|
|
||||||
|
|
||||||
final error = calloc<TSWasmError>();
|
|
||||||
_store = lib.wasmStoreNew(engine, error);
|
|
||||||
lib.wasmEngineDelete(engine);
|
|
||||||
|
|
||||||
if (_store == null || _store == nullptr) {
|
|
||||||
calloc.free(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
calloc.free(error);
|
|
||||||
|
|
||||||
_parser = lib.parserNew();
|
|
||||||
if (_parser == null || _parser == nullptr) return false;
|
|
||||||
lib.parserSetWasmStore(_parser!, _store!);
|
|
||||||
|
|
||||||
_cursor = lib.queryCursorNew();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<_LoadedGrammar?> _loadGrammar(String language) async {
|
|
||||||
if (_unavailable.contains(language)) return null;
|
|
||||||
final cached = _grammars[language];
|
|
||||||
if (cached != null) return cached;
|
|
||||||
|
|
||||||
if (!_init()) {
|
|
||||||
_unavailable.add(language);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final lib = _lib!;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load grammar WASM bytes.
|
|
||||||
final wasmBytes = await _grammarBytes(language);
|
|
||||||
|
|
||||||
// Load into WASM store.
|
|
||||||
final nameNative = language.toNativeUtf8();
|
|
||||||
final wasmNative = calloc<Uint8>(wasmBytes.length);
|
|
||||||
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
|
|
||||||
final error = calloc<TSWasmError>();
|
|
||||||
|
|
||||||
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
|
|
||||||
|
|
||||||
calloc.free(wasmNative);
|
|
||||||
calloc.free(nameNative);
|
|
||||||
|
|
||||||
if (lang == nullptr) {
|
|
||||||
final msg = error.ref.message;
|
|
||||||
if (msg != nullptr) calloc.free(msg);
|
|
||||||
calloc.free(error);
|
|
||||||
_unavailable.add(language);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
calloc.free(error);
|
|
||||||
|
|
||||||
// Load highlight query.
|
|
||||||
final querySource = await _grammarQuery(language);
|
|
||||||
|
|
||||||
Pointer<TSQuery> query = nullptr;
|
|
||||||
List<String> captureNames = [];
|
|
||||||
|
|
||||||
if (querySource != null) {
|
|
||||||
final queryNative = querySource.toNativeUtf8();
|
|
||||||
final queryLen = utf8.encode(querySource).length;
|
|
||||||
final errorOffset = calloc<Uint32>();
|
|
||||||
final errorType = calloc<Int32>();
|
|
||||||
|
|
||||||
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
|
|
||||||
|
|
||||||
calloc.free(queryNative);
|
|
||||||
calloc.free(errorOffset);
|
|
||||||
calloc.free(errorType);
|
|
||||||
|
|
||||||
if (query != nullptr) {
|
|
||||||
final count = lib.queryCaptureCount(query);
|
|
||||||
final lenOut = calloc<Uint32>();
|
|
||||||
for (var i = 0; i < count; i++) {
|
|
||||||
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
|
|
||||||
final len = lenOut.value;
|
|
||||||
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
|
|
||||||
}
|
|
||||||
calloc.free(lenOut);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
|
|
||||||
_grammars[language] = grammar;
|
|
||||||
return grammar;
|
|
||||||
} catch (_) {
|
|
||||||
_unavailable.add(language);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> hasGrammar(String path) async {
|
|
||||||
final lang = grammarForPath(path);
|
|
||||||
if (lang == null) return false;
|
|
||||||
return (await _loadGrammar(lang)) != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> languageFor(String path) async {
|
|
||||||
final lang = grammarForPath(path);
|
|
||||||
if (lang == null) return null;
|
|
||||||
return (await _loadGrammar(lang)) != null ? lang : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> get loadedLanguages => _grammars.keys.toList();
|
|
||||||
|
|
||||||
Future<SyntaxResult> highlight(String path, String source) async {
|
|
||||||
final lang = grammarForPath(path);
|
|
||||||
if (lang == null) return SyntaxResult.empty;
|
|
||||||
|
|
||||||
final grammar = await _loadGrammar(lang);
|
|
||||||
if (grammar == null || grammar.query == nullptr) {
|
|
||||||
return SyntaxResult.empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
final lib = _lib!;
|
|
||||||
final parser = _parser!;
|
|
||||||
final cursor = _cursor!;
|
|
||||||
|
|
||||||
// Set language on parser for this parse.
|
|
||||||
lib.parserSetLanguage(parser, grammar.language);
|
|
||||||
|
|
||||||
// Parse source.
|
|
||||||
final sourceNative = source.toNativeUtf8();
|
|
||||||
final sourceLen = utf8.encode(source).length;
|
|
||||||
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
|
|
||||||
|
|
||||||
if (tree == nullptr) {
|
|
||||||
calloc.free(sourceNative);
|
|
||||||
return SyntaxResult.empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
final root = lib.treeRootNode(tree);
|
|
||||||
|
|
||||||
// Run highlight query.
|
|
||||||
lib.queryCursorExec(cursor, grammar.query, root);
|
|
||||||
|
|
||||||
final match = calloc<TSQueryMatch>();
|
|
||||||
final spans = <SyntaxSpan>[];
|
|
||||||
|
|
||||||
while (lib.queryCursorNextMatch(cursor, match)) {
|
|
||||||
final m = match.ref;
|
|
||||||
for (var i = 0; i < m.captureCount; i++) {
|
|
||||||
final cap = m.captures[i];
|
|
||||||
final captureIndex = cap.index;
|
|
||||||
if (captureIndex < grammar.captureNames.length) {
|
|
||||||
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
calloc.free(match);
|
|
||||||
lib.treeDelete(tree);
|
|
||||||
calloc.free(sourceNative);
|
|
||||||
|
|
||||||
return SyntaxResult(spans);
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
final lib = _lib;
|
|
||||||
if (lib == null) return;
|
|
||||||
|
|
||||||
for (final grammar in _grammars.values) {
|
|
||||||
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
|
|
||||||
}
|
|
||||||
_grammars.clear();
|
|
||||||
|
|
||||||
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
|
|
||||||
// Parser and WASM store are cleaned up together — deleting the parser
|
|
||||||
// does not delete the store, but the store owns the languages.
|
|
||||||
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
|
|
||||||
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
|
|
||||||
|
|
||||||
_parser = null;
|
|
||||||
_store = null;
|
|
||||||
_cursor = null;
|
|
||||||
_unavailable.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resets the service to a pre-init state. Tests use this to re-exercise
|
|
||||||
/// `_init()` without constructing a new singleton; production code never
|
|
||||||
/// needs it.
|
|
||||||
@visibleForTesting
|
|
||||||
void resetForTests() {
|
|
||||||
dispose();
|
|
||||||
_initDone = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Color colorForRole(String role, SurfaceTokens tokens) {
|
|
||||||
return switch (role) {
|
|
||||||
'keyword' || 'repeat' || 'conditional' || 'include' || 'exception' || 'operator' => tokens.syntaxKeyword,
|
|
||||||
'type' || 'type.builtin' || 'constructor' => tokens.syntaxType,
|
|
||||||
'string' || 'string.special' => tokens.syntaxString,
|
|
||||||
'number' || 'float' || 'boolean' => tokens.syntaxNumber,
|
|
||||||
'comment' => tokens.syntaxComment,
|
|
||||||
'function' || 'function.builtin' || 'function.method' || 'method' => tokens.syntaxMethod,
|
|
||||||
'punctuation.bracket' || 'punctuation.delimiter' || 'punctuation.special' => tokens.syntaxPunct,
|
|
||||||
'variable' || 'variable.builtin' || 'variable.parameter' => tokens.globalForeground,
|
|
||||||
'property' || 'field' => tokens.syntaxMethod,
|
|
||||||
'constant' || 'constant.builtin' => tokens.syntaxNumber,
|
|
||||||
'tag' || 'attribute' => tokens.syntaxKeyword,
|
|
||||||
'namespace' || 'module' => tokens.syntaxType,
|
|
||||||
'text.title' => tokens.syntaxKeyword,
|
|
||||||
'text.literal' || 'text.reference' || 'text.uri' => tokens.syntaxString,
|
|
||||||
'text.emphasis' || 'text.strong' => tokens.syntaxType,
|
|
||||||
_ => tokens.globalForeground,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/// FFI-backed tree-sitter highlighter (T-438 web fence, D-100). Selected by the
|
||||||
|
/// [tree_sitter_service.dart] facade when `dart.library.ffi` is available; the
|
||||||
|
/// web build gets [tree_sitter_service_stub.dart] instead. Pure result types
|
||||||
|
/// live in [syntax_result.dart] (re-exported so consumers import only the
|
||||||
|
/// facade).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert' show utf8;
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:ui' show Color;
|
||||||
|
|
||||||
|
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||||
|
import 'package:clide/kernel/src/syntax/syntax_result.dart';
|
||||||
|
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
||||||
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
|
import 'package:ffi/ffi.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
|
||||||
|
export 'package:clide/kernel/src/syntax/syntax_result.dart';
|
||||||
|
|
||||||
|
class _LoadedGrammar {
|
||||||
|
_LoadedGrammar({required this.language, required this.query, required this.captureNames});
|
||||||
|
|
||||||
|
final Pointer<Void> language;
|
||||||
|
final Pointer<TSQuery> query;
|
||||||
|
final List<String> captureNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TreeSitterService {
|
||||||
|
static final TreeSitterService shared = TreeSitterService();
|
||||||
|
|
||||||
|
/// Production constructor: uses the dlopen'd [TreeSitterLib.instance] and
|
||||||
|
/// the Flutter [rootBundle]. Tests pass [lib] / [grammarBytes] /
|
||||||
|
/// [grammarQuery] to substitute a fake FFI surface and in-memory assets.
|
||||||
|
TreeSitterService({TreeSitterLib? lib, GrammarBytesLoader? grammarBytes, GrammarQueryLoader? grammarQuery})
|
||||||
|
: _injectedLib = lib,
|
||||||
|
_grammarBytes = grammarBytes ?? _defaultGrammarBytes,
|
||||||
|
_grammarQuery = grammarQuery ?? _defaultGrammarQuery;
|
||||||
|
|
||||||
|
final TreeSitterLib? _injectedLib;
|
||||||
|
final GrammarBytesLoader _grammarBytes;
|
||||||
|
final GrammarQueryLoader _grammarQuery;
|
||||||
|
|
||||||
|
TreeSitterLib? get _lib => _injectedLib ?? TreeSitterLib.instance;
|
||||||
|
|
||||||
|
static Future<Uint8List> _defaultGrammarBytes(String language) async {
|
||||||
|
final data = await rootBundle.load('assets/grammars/$language.wasm');
|
||||||
|
return data.buffer.asUint8List();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> _defaultGrammarQuery(String language) async {
|
||||||
|
try {
|
||||||
|
return await rootBundle.loadString('assets/queries/$language.scm');
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, _LoadedGrammar> _grammars = {};
|
||||||
|
final Set<String> _unavailable = {};
|
||||||
|
|
||||||
|
Pointer<TSWasmStore>? _store;
|
||||||
|
Pointer<TSParser>? _parser;
|
||||||
|
Pointer<TSQueryCursor>? _cursor;
|
||||||
|
|
||||||
|
bool _initDone = false;
|
||||||
|
|
||||||
|
bool _init() {
|
||||||
|
if (_initDone) return _parser != null;
|
||||||
|
_initDone = true;
|
||||||
|
|
||||||
|
final lib = _lib;
|
||||||
|
if (lib == null) return false;
|
||||||
|
|
||||||
|
final engine = lib.wasmEngineNew();
|
||||||
|
if (engine == nullptr) return false;
|
||||||
|
|
||||||
|
final error = calloc<TSWasmError>();
|
||||||
|
_store = lib.wasmStoreNew(engine, error);
|
||||||
|
lib.wasmEngineDelete(engine);
|
||||||
|
|
||||||
|
if (_store == null || _store == nullptr) {
|
||||||
|
calloc.free(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
calloc.free(error);
|
||||||
|
|
||||||
|
_parser = lib.parserNew();
|
||||||
|
if (_parser == null || _parser == nullptr) return false;
|
||||||
|
lib.parserSetWasmStore(_parser!, _store!);
|
||||||
|
|
||||||
|
_cursor = lib.queryCursorNew();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_LoadedGrammar?> _loadGrammar(String language) async {
|
||||||
|
if (_unavailable.contains(language)) return null;
|
||||||
|
final cached = _grammars[language];
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
if (!_init()) {
|
||||||
|
_unavailable.add(language);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lib = _lib!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load grammar WASM bytes.
|
||||||
|
final wasmBytes = await _grammarBytes(language);
|
||||||
|
|
||||||
|
// Load into WASM store.
|
||||||
|
final nameNative = language.toNativeUtf8();
|
||||||
|
final wasmNative = calloc<Uint8>(wasmBytes.length);
|
||||||
|
wasmNative.asTypedList(wasmBytes.length).setAll(0, wasmBytes);
|
||||||
|
final error = calloc<TSWasmError>();
|
||||||
|
|
||||||
|
final lang = lib.wasmStoreLoadLanguage(_store!, nameNative.cast(), wasmNative, wasmBytes.length, error);
|
||||||
|
|
||||||
|
calloc.free(wasmNative);
|
||||||
|
calloc.free(nameNative);
|
||||||
|
|
||||||
|
if (lang == nullptr) {
|
||||||
|
final msg = error.ref.message;
|
||||||
|
if (msg != nullptr) calloc.free(msg);
|
||||||
|
calloc.free(error);
|
||||||
|
_unavailable.add(language);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
calloc.free(error);
|
||||||
|
|
||||||
|
// Load highlight query.
|
||||||
|
final querySource = await _grammarQuery(language);
|
||||||
|
|
||||||
|
Pointer<TSQuery> query = nullptr;
|
||||||
|
List<String> captureNames = [];
|
||||||
|
|
||||||
|
if (querySource != null) {
|
||||||
|
final queryNative = querySource.toNativeUtf8();
|
||||||
|
final queryLen = utf8.encode(querySource).length;
|
||||||
|
final errorOffset = calloc<Uint32>();
|
||||||
|
final errorType = calloc<Int32>();
|
||||||
|
|
||||||
|
query = lib.queryNew(lang, queryNative.cast(), queryLen, errorOffset, errorType);
|
||||||
|
|
||||||
|
calloc.free(queryNative);
|
||||||
|
calloc.free(errorOffset);
|
||||||
|
calloc.free(errorType);
|
||||||
|
|
||||||
|
if (query != nullptr) {
|
||||||
|
final count = lib.queryCaptureCount(query);
|
||||||
|
final lenOut = calloc<Uint32>();
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
final namePtr = lib.queryCaptureNameForId(query, i, lenOut);
|
||||||
|
final len = lenOut.value;
|
||||||
|
captureNames.add(namePtr.cast<Utf8>().toDartString(length: len));
|
||||||
|
}
|
||||||
|
calloc.free(lenOut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final grammar = _LoadedGrammar(language: lang, query: query, captureNames: captureNames);
|
||||||
|
_grammars[language] = grammar;
|
||||||
|
return grammar;
|
||||||
|
} catch (_) {
|
||||||
|
_unavailable.add(language);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> hasGrammar(String path) async {
|
||||||
|
final lang = grammarForPath(path);
|
||||||
|
if (lang == null) return false;
|
||||||
|
return (await _loadGrammar(lang)) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> languageFor(String path) async {
|
||||||
|
final lang = grammarForPath(path);
|
||||||
|
if (lang == null) return null;
|
||||||
|
return (await _loadGrammar(lang)) != null ? lang : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> get loadedLanguages => _grammars.keys.toList();
|
||||||
|
|
||||||
|
Future<SyntaxResult> highlight(String path, String source) async {
|
||||||
|
final lang = grammarForPath(path);
|
||||||
|
if (lang == null) return SyntaxResult.empty;
|
||||||
|
|
||||||
|
final grammar = await _loadGrammar(lang);
|
||||||
|
if (grammar == null || grammar.query == nullptr) {
|
||||||
|
return SyntaxResult.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lib = _lib!;
|
||||||
|
final parser = _parser!;
|
||||||
|
final cursor = _cursor!;
|
||||||
|
|
||||||
|
// Set language on parser for this parse.
|
||||||
|
lib.parserSetLanguage(parser, grammar.language);
|
||||||
|
|
||||||
|
// Parse source.
|
||||||
|
final sourceNative = source.toNativeUtf8();
|
||||||
|
final sourceLen = utf8.encode(source).length;
|
||||||
|
final tree = lib.parserParseString(parser, nullptr, sourceNative.cast(), sourceLen);
|
||||||
|
|
||||||
|
if (tree == nullptr) {
|
||||||
|
calloc.free(sourceNative);
|
||||||
|
return SyntaxResult.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
final root = lib.treeRootNode(tree);
|
||||||
|
|
||||||
|
// Run highlight query.
|
||||||
|
lib.queryCursorExec(cursor, grammar.query, root);
|
||||||
|
|
||||||
|
final match = calloc<TSQueryMatch>();
|
||||||
|
final spans = <SyntaxSpan>[];
|
||||||
|
|
||||||
|
while (lib.queryCursorNextMatch(cursor, match)) {
|
||||||
|
final m = match.ref;
|
||||||
|
for (var i = 0; i < m.captureCount; i++) {
|
||||||
|
final cap = m.captures[i];
|
||||||
|
final captureIndex = cap.index;
|
||||||
|
if (captureIndex < grammar.captureNames.length) {
|
||||||
|
spans.add(SyntaxSpan(start: lib.nodeStartByte(cap.node), end: lib.nodeEndByte(cap.node), role: grammar.captureNames[captureIndex]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calloc.free(match);
|
||||||
|
lib.treeDelete(tree);
|
||||||
|
calloc.free(sourceNative);
|
||||||
|
|
||||||
|
return SyntaxResult(spans);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
final lib = _lib;
|
||||||
|
if (lib == null) return;
|
||||||
|
|
||||||
|
for (final grammar in _grammars.values) {
|
||||||
|
if (grammar.query != nullptr) lib.queryDelete(grammar.query);
|
||||||
|
}
|
||||||
|
_grammars.clear();
|
||||||
|
|
||||||
|
if (_cursor != null && _cursor != nullptr) lib.queryCursorDelete(_cursor!);
|
||||||
|
// Parser and WASM store are cleaned up together — deleting the parser
|
||||||
|
// does not delete the store, but the store owns the languages.
|
||||||
|
if (_parser != null && _parser != nullptr) lib.parserDelete(_parser!);
|
||||||
|
if (_store != null && _store != nullptr) lib.wasmStoreDelete(_store!);
|
||||||
|
|
||||||
|
_parser = null;
|
||||||
|
_store = null;
|
||||||
|
_cursor = null;
|
||||||
|
_unavailable.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the service to a pre-init state. Tests use this to re-exercise
|
||||||
|
/// `_init()` without constructing a new singleton; production code never
|
||||||
|
/// needs it.
|
||||||
|
@visibleForTesting
|
||||||
|
void resetForTests() {
|
||||||
|
dispose();
|
||||||
|
_initDone = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// Web stub for [TreeSitterService] (T-438 web fence, D-100): no tree-sitter
|
||||||
|
/// FFI on web, so highlighting is a no-op — every query returns no spans and
|
||||||
|
/// the editor / code block render plain text. Mirrors the FFI impl's public
|
||||||
|
/// API (and re-exports the shared result types) so the facade is transparent.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:ui' show Color;
|
||||||
|
|
||||||
|
import 'package:clide/kernel/src/syntax/syntax_result.dart';
|
||||||
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
|
|
||||||
|
export 'package:clide/kernel/src/syntax/syntax_result.dart';
|
||||||
|
|
||||||
|
class TreeSitterService {
|
||||||
|
static final TreeSitterService shared = TreeSitterService();
|
||||||
|
|
||||||
|
TreeSitterService();
|
||||||
|
|
||||||
|
Future<bool> hasGrammar(String path) async => false;
|
||||||
|
Future<String?> languageFor(String path) async => null;
|
||||||
|
List<String> get loadedLanguages => const [];
|
||||||
|
Future<SyntaxResult> highlight(String path, String source) async => SyntaxResult.empty;
|
||||||
|
void dispose() {}
|
||||||
|
void resetForTests() {}
|
||||||
|
|
||||||
|
static Color colorForRole(String role, SurfaceTokens tokens) => syntaxColorForRole(role, tokens);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
@@ -20,7 +21,6 @@ export 'toolchain_paths.dart';
|
|||||||
class Toolchain extends ChangeNotifier implements ToolchainView {
|
class Toolchain extends ChangeNotifier implements ToolchainView {
|
||||||
String? _git;
|
String? _git;
|
||||||
String? _pql;
|
String? _pql;
|
||||||
String? _tmux;
|
|
||||||
String? _shell;
|
String? _shell;
|
||||||
Map<String, String>? _gitEnv;
|
Map<String, String>? _gitEnv;
|
||||||
bool _resolved = false;
|
bool _resolved = false;
|
||||||
@@ -30,9 +30,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
|
|||||||
@override
|
@override
|
||||||
String get pql => _pql ?? 'pql';
|
String get pql => _pql ?? 'pql';
|
||||||
@override
|
@override
|
||||||
String get tmux => _tmux ?? 'tmux';
|
String get shell => _shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
|
||||||
@override
|
|
||||||
String get shell => _shell ?? '/bin/bash';
|
|
||||||
|
|
||||||
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
|
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
|
||||||
@override
|
@override
|
||||||
@@ -44,7 +42,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
|
|||||||
bool get allOk => _resolved && missing.isEmpty;
|
bool get allOk => _resolved && missing.isEmpty;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql', if (_tmux == null) 'tmux'];
|
List<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql'];
|
||||||
|
|
||||||
/// Returns a Future that completes when resolution finishes.
|
/// Returns a Future that completes when resolution finishes.
|
||||||
Future<void> waitForResolution() {
|
Future<void> waitForResolution() {
|
||||||
@@ -65,7 +63,6 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
|
|||||||
void applyResolved(ResolvedPaths p) {
|
void applyResolved(ResolvedPaths p) {
|
||||||
_git = p.git;
|
_git = p.git;
|
||||||
_pql = p.pql;
|
_pql = p.pql;
|
||||||
_tmux = p.tmux;
|
|
||||||
_shell = p.shell;
|
_shell = p.shell;
|
||||||
_gitEnv = p.gitEnv;
|
_gitEnv = p.gitEnv;
|
||||||
_resolved = true;
|
_resolved = true;
|
||||||
|
|||||||
@@ -10,13 +10,19 @@ library;
|
|||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:clide/src/env/shell_env.dart';
|
||||||
|
|
||||||
|
// The canonical PATH-expansion logic now lives in shell_env (T-439, the single
|
||||||
|
// source of truth shared with git/pql/PTY/claude). Re-exported so existing
|
||||||
|
// importers/tests keep resolving it from here.
|
||||||
|
export 'package:clide/src/env/shell_env.dart' show expandToolPath;
|
||||||
|
|
||||||
/// Serializable result of tool resolution (crosses isolate boundary).
|
/// Serializable result of tool resolution (crosses isolate boundary).
|
||||||
class ResolvedPaths {
|
class ResolvedPaths {
|
||||||
const ResolvedPaths({this.git, this.pql, this.tmux, this.shell, this.gitEnv});
|
const ResolvedPaths({this.git, this.pql, this.shell, this.gitEnv});
|
||||||
|
|
||||||
final String? git;
|
final String? git;
|
||||||
final String? pql;
|
final String? pql;
|
||||||
final String? tmux;
|
|
||||||
final String? shell;
|
final String? shell;
|
||||||
final Map<String, String>? gitEnv;
|
final Map<String, String>? gitEnv;
|
||||||
}
|
}
|
||||||
@@ -32,7 +38,6 @@ abstract class ToolchainView {
|
|||||||
|
|
||||||
String get git;
|
String get git;
|
||||||
String get pql;
|
String get pql;
|
||||||
String get tmux;
|
|
||||||
String get shell;
|
String get shell;
|
||||||
Map<String, String>? get gitEnv;
|
Map<String, String>? get gitEnv;
|
||||||
bool get resolved;
|
bool get resolved;
|
||||||
@@ -50,9 +55,7 @@ class _StaticToolchain implements ToolchainView {
|
|||||||
@override
|
@override
|
||||||
String get pql => _paths.pql ?? 'pql';
|
String get pql => _paths.pql ?? 'pql';
|
||||||
@override
|
@override
|
||||||
String get tmux => _paths.tmux ?? 'tmux';
|
String get shell => _paths.shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
|
||||||
@override
|
|
||||||
String get shell => _paths.shell ?? '/bin/bash';
|
|
||||||
@override
|
@override
|
||||||
Map<String, String>? get gitEnv => _paths.gitEnv;
|
Map<String, String>? get gitEnv => _paths.gitEnv;
|
||||||
@override
|
@override
|
||||||
@@ -60,7 +63,7 @@ class _StaticToolchain implements ToolchainView {
|
|||||||
@override
|
@override
|
||||||
bool get allOk => missing.isEmpty;
|
bool get allOk => missing.isEmpty;
|
||||||
@override
|
@override
|
||||||
List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql', if (_paths.tmux == null) 'tmux'];
|
List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level function for compute/isolate use. Returns a plain-data
|
/// Top-level function for compute/isolate use. Returns a plain-data
|
||||||
@@ -83,13 +86,17 @@ ResolvedPaths resolveToolchainPaths() {
|
|||||||
git = _findOnPath('git');
|
git = _findOnPath('git');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResolvedPaths(
|
return ResolvedPaths(git: git, pql: _findOnPath('pql'), shell: _resolveShell(), gitEnv: gitEnv);
|
||||||
git: git,
|
}
|
||||||
pql: _findOnPath('pql'),
|
|
||||||
tmux: _findOnPath('tmux'),
|
/// The user's interactive shell. POSIX honours `$SHELL`; Windows has
|
||||||
shell: _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
|
/// no such convention — prefer PowerShell 7 (`pwsh`), fall back to
|
||||||
gitEnv: gitEnv,
|
/// Windows PowerShell (present on every supported Windows).
|
||||||
);
|
String? _resolveShell() {
|
||||||
|
if (Platform.isWindows) {
|
||||||
|
return _findOnPath('pwsh') ?? _findOnPath('powershell');
|
||||||
|
}
|
||||||
|
return _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the dugite-bundled git binary in trusted install locations
|
/// Locate the dugite-bundled git binary in trusted install locations
|
||||||
@@ -104,6 +111,10 @@ ResolvedPaths resolveToolchainPaths() {
|
|||||||
///
|
///
|
||||||
/// Returns null if no dugite is found; caller falls back to PATH git.
|
/// Returns null if no dugite is found; caller falls back to PATH git.
|
||||||
String? _resolveDugiteGit() {
|
String? _resolveDugiteGit() {
|
||||||
|
// dugite-native's Windows layout differs (cmd\git.exe, mingw64
|
||||||
|
// libexec) and isn't wired up yet — PATH git serves Windows until
|
||||||
|
// the bundle work lands.
|
||||||
|
if (Platform.isWindows) return null;
|
||||||
final candidates = <String>[];
|
final candidates = <String>[];
|
||||||
final envDir = Platform.environment['CLIDE_DUGITE_DIR'];
|
final envDir = Platform.environment['CLIDE_DUGITE_DIR'];
|
||||||
if (envDir != null && envDir.isNotEmpty) {
|
if (envDir != null && envDir.isNotEmpty) {
|
||||||
@@ -116,10 +127,19 @@ String? _resolveDugiteGit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String? _findOnPath(String name) {
|
String? _findOnPath(String name) {
|
||||||
for (final dir in _expandedPath().split(':')) {
|
final sep = Platform.isWindows ? ';' : ':';
|
||||||
|
for (final dir in _expandedPath().split(sep)) {
|
||||||
if (dir.isEmpty) continue;
|
if (dir.isEmpty) continue;
|
||||||
final f = File('$dir/$name');
|
if (Platform.isWindows) {
|
||||||
if (f.existsSync()) return f.path;
|
// PATHEXT-style probe — a bare `pql` on PATH is really pql.exe.
|
||||||
|
for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) {
|
||||||
|
final f = File('$dir\\$name$ext');
|
||||||
|
if (f.existsSync()) return f.path;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final f = File('$dir/$name');
|
||||||
|
if (f.existsSync()) return f.path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -131,25 +151,8 @@ String? _firstExisting(List<String> candidates) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build expanded PATH inline — must be self-contained for isolate use.
|
/// The full tool search PATH — the login-shell PATH (probed once at startup)
|
||||||
String _expandedPath() =>
|
/// unioned with the well-known user/local bin dirs, shared with every other
|
||||||
expandToolPath(Platform.environment['PATH'] ?? '', isMac: Platform.isMacOS, isLinux: Platform.isLinux, home: Platform.environment['HOME']);
|
/// spawn site via [shell_env] (T-439). In an isolate that never primed the
|
||||||
|
/// probe it degrades to the process PATH + the well-known dirs (T-347).
|
||||||
/// Pure PATH-expansion logic, extracted so it's testable without touching the
|
String _expandedPath() => resolvedToolPath();
|
||||||
/// process environment.
|
|
||||||
///
|
|
||||||
/// A desktop-launched app (macOS or Linux) inherits a minimal PATH that lacks
|
|
||||||
/// the user bin dirs where tools like `pql` install (`~/.local/bin`), so tool
|
|
||||||
/// resolution fails even though a terminal launch would find them. Re-add the
|
|
||||||
/// common user/local bin dirs — that any are missing means they're prepended,
|
|
||||||
/// so they take precedence over a stale system copy (T-347). Homebrew dirs are
|
|
||||||
/// macOS-only. On other platforms the base PATH passes through unchanged.
|
|
||||||
String expandToolPath(String base, {required bool isMac, required bool isLinux, String? home}) {
|
|
||||||
if (!isMac && !isLinux) return base;
|
|
||||||
final h = home ?? '';
|
|
||||||
final extras = <String>[if (h.isNotEmpty) '$h/.local/bin', if (isMac) '/opt/homebrew/bin', if (isMac) '/opt/homebrew/sbin', '/usr/local/bin'];
|
|
||||||
final existing = base.split(':').toSet();
|
|
||||||
final missing = extras.where((p) => !existing.contains(p));
|
|
||||||
if (missing.isEmpty) return base;
|
|
||||||
return [...missing, ...existing].join(':');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/// Crash-diagnostic watchdog (T-435, under the T-425 observability epic).
|
||||||
|
///
|
||||||
|
/// The Windows freeze leaves no evidence partly because it is a *whole-process*
|
||||||
|
/// stall: a main-isolate `Timer` heartbeat would freeze WITH the main isolate
|
||||||
|
/// and tell us nothing. So the watchdog runs in a DEDICATED isolate that:
|
||||||
|
///
|
||||||
|
/// - appends + fsyncs a heartbeat every ~500ms, so the last heartbeat on disk
|
||||||
|
/// bounds a freeze to ~500ms ("it was alive at T, dead by T+0.5s"); and
|
||||||
|
/// - every ~2s samples this process's resource counts — threads, open
|
||||||
|
/// handles/fds, child/ConPTY-host processes, RSS — and appends + fsyncs
|
||||||
|
/// them. A monotonically climbing child/thread/handle count is the leak
|
||||||
|
/// signature the soak couldn't reproduce on CI but a real freeze would show.
|
||||||
|
///
|
||||||
|
/// Output is JSON-lines in `logDirectory()/clide-watchdog.log`, matching
|
||||||
|
/// [FileLogSink] so it greps/parses the same way, bounded by the same
|
||||||
|
/// truncate-on-cap scheme as `IsolateCrumbFile`. Everything is synchronous and
|
||||||
|
/// swallows its own errors — the watchdog must never add a second hang or take
|
||||||
|
/// the app down.
|
||||||
|
///
|
||||||
|
/// Flutter-free (dart:io / dart:isolate / dart:convert + a thin FFI sampler on
|
||||||
|
/// Windows) so the entry point is `Isolate.spawn`-able and it unit-tests under
|
||||||
|
/// `dart test`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
// Web fence (T-438, D-100): the FFI-backed Windows sampler is reachable only
|
||||||
|
// when `dart.library.ffi` is available; the web build gets an all-`-1` stub.
|
||||||
|
import 'watchdog_windows_stub.dart' if (dart.library.ffi) 'watchdog_windows.dart';
|
||||||
|
|
||||||
|
/// One resource sample of the current process. A field of `-1` means "not
|
||||||
|
/// available on this platform or the probe failed" — never an error.
|
||||||
|
class ResourceSample {
|
||||||
|
const ResourceSample({this.threads = -1, this.handles = -1, this.children = -1, this.rssBytes = -1});
|
||||||
|
|
||||||
|
/// Live OS thread count (culprit #2: blocked-FFI isolate threads piling up).
|
||||||
|
final int threads;
|
||||||
|
|
||||||
|
/// Open handle count (Windows) / open fd count (POSIX).
|
||||||
|
final int handles;
|
||||||
|
|
||||||
|
/// Child / ConPTY-host process count (the orphan-accumulation leak signature).
|
||||||
|
final int children;
|
||||||
|
|
||||||
|
/// Resident set size in bytes.
|
||||||
|
final int rssBytes;
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
if (threads >= 0) 'threads': threads,
|
||||||
|
if (handles >= 0) 'handles': handles,
|
||||||
|
if (children >= 0) 'children': children,
|
||||||
|
if (rssBytes >= 0) 'rssMB': (rssBytes / (1024 * 1024)).round(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples the CURRENT process's resource counts. Cheap, synchronous, never
|
||||||
|
/// throws (returns `-1` fields on failure).
|
||||||
|
abstract class ResourceSampler {
|
||||||
|
ResourceSample sample();
|
||||||
|
|
||||||
|
/// The backend for the running OS — `/proc` on POSIX, a thin Win32 FFI
|
||||||
|
/// snapshot on Windows.
|
||||||
|
static ResourceSampler forPlatform() => Platform.isWindows ? WindowsResourceSampler() : PosixResourceSampler();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POSIX sampler — reads `/proc/self`. Runs (and is tested) on the Linux CI box.
|
||||||
|
class PosixResourceSampler implements ResourceSampler {
|
||||||
|
@override
|
||||||
|
ResourceSample sample() => ResourceSample(threads: _threads(), handles: _fdCount(), children: _childCount(), rssBytes: _rss());
|
||||||
|
|
||||||
|
int _threads() {
|
||||||
|
try {
|
||||||
|
for (final line in File('/proc/self/status').readAsLinesSync()) {
|
||||||
|
if (line.startsWith('Threads:')) return int.parse(line.split(RegExp(r'\s+'))[1]);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _fdCount() {
|
||||||
|
try {
|
||||||
|
return Directory('/proc/self/fd').listSync().length;
|
||||||
|
} catch (_) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _childCount() {
|
||||||
|
// Sum each thread's direct-children list (`/proc/<pid>/task/<tid>/children`,
|
||||||
|
// Linux 5.3+). Best-effort: absent file / old kernel → 0 from that thread.
|
||||||
|
try {
|
||||||
|
var n = 0;
|
||||||
|
for (final task in Directory('/proc/self/task').listSync()) {
|
||||||
|
final f = File('${task.path}/children');
|
||||||
|
if (!f.existsSync()) continue;
|
||||||
|
final s = f.readAsStringSync().trim();
|
||||||
|
if (s.isNotEmpty) n += s.split(RegExp(r'\s+')).length;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
} catch (_) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _rss() {
|
||||||
|
try {
|
||||||
|
return ProcessInfo.currentRss;
|
||||||
|
} catch (_) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The watchdog's on-disk writer: bounded, synchronously-fsynced JSON-lines
|
||||||
|
/// (heartbeats + samples). Separated from the loop so it unit-tests in
|
||||||
|
/// isolation. Mirrors `IsolateCrumbFile`'s truncate-on-cap bound.
|
||||||
|
class WatchdogFile {
|
||||||
|
WatchdogFile(String? path, {int capBytes = 256 * 1024}) : _capBytes = capBytes {
|
||||||
|
if (path == null) return;
|
||||||
|
try {
|
||||||
|
final f = File(path);
|
||||||
|
f.parent.createSync(recursive: true);
|
||||||
|
final raf = f.openSync(mode: FileMode.append);
|
||||||
|
_raf = raf;
|
||||||
|
_size = raf.lengthSync();
|
||||||
|
} catch (_) {
|
||||||
|
_raf = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final int _capBytes;
|
||||||
|
RandomAccessFile? _raf;
|
||||||
|
int _size = 0;
|
||||||
|
|
||||||
|
bool get enabled => _raf != null;
|
||||||
|
|
||||||
|
void heartbeat() => _write({'ts': _now(), 'evt': 'hb'});
|
||||||
|
|
||||||
|
void sample(ResourceSample s) => _write({'ts': _now(), 'evt': 'sample', 'pid': pid, ...s.toJson()});
|
||||||
|
|
||||||
|
String _now() => DateTime.now().toUtc().toIso8601String();
|
||||||
|
|
||||||
|
void _write(Map<String, Object?> json) {
|
||||||
|
final raf = _raf;
|
||||||
|
if (raf == null) return;
|
||||||
|
try {
|
||||||
|
if (_size >= _capBytes) {
|
||||||
|
raf.truncateSync(0);
|
||||||
|
raf.setPositionSync(0);
|
||||||
|
_size = 0;
|
||||||
|
}
|
||||||
|
final bytes = utf8.encode('${jsonEncode(json)}\n');
|
||||||
|
raf.writeFromSync(bytes);
|
||||||
|
raf.flushSync();
|
||||||
|
_size += bytes.length;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
try {
|
||||||
|
_raf?.flushSync();
|
||||||
|
_raf?.closeSync();
|
||||||
|
} catch (_) {}
|
||||||
|
_raf = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The watchdog loop. Extracted from [watchdogEntry] so a test can bound it
|
||||||
|
/// with [maxTicks]; production passes null and the loop runs until the isolate
|
||||||
|
/// is killed at shutdown. Heartbeats fire every [hbIntervalMs], samples every
|
||||||
|
/// [sampleIntervalMs]; a short sleep between keeps the cadence without spinning.
|
||||||
|
void runWatchdog(WatchdogFile file, ResourceSampler sampler, {required int hbIntervalMs, required int sampleIntervalMs, int? maxTicks}) {
|
||||||
|
if (!file.enabled) return;
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
// Seed both "last" markers a full interval in the past so the first tick
|
||||||
|
// emits an immediate heartbeat + sample (a baseline at startup).
|
||||||
|
var lastHb = -hbIntervalMs;
|
||||||
|
var lastSample = -sampleIntervalMs;
|
||||||
|
var ticks = 0;
|
||||||
|
while (maxTicks == null || ticks < maxTicks) {
|
||||||
|
final e = sw.elapsedMilliseconds;
|
||||||
|
if (e - lastHb >= hbIntervalMs) {
|
||||||
|
file.heartbeat();
|
||||||
|
lastHb = e;
|
||||||
|
}
|
||||||
|
if (e - lastSample >= sampleIntervalMs) {
|
||||||
|
file.sample(sampler.sample());
|
||||||
|
lastSample = e;
|
||||||
|
}
|
||||||
|
ticks++;
|
||||||
|
if (maxTicks != null && ticks >= maxTicks) break;
|
||||||
|
sleep(const Duration(milliseconds: 25));
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level entry for `Isolate.spawn`. Args are a sendable tuple — the log
|
||||||
|
/// path (not a Logger; isolates can't share one) and the two intervals in ms.
|
||||||
|
void watchdogEntry((String, int, int) msg) {
|
||||||
|
final (logPath, hbMs, sampleMs) = msg;
|
||||||
|
runWatchdog(WatchdogFile(logPath), ResourceSampler.forPlatform(), hbIntervalMs: hbMs, sampleIntervalMs: sampleMs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
//
|
||||||
|
// Windows-only resource sampler for the watchdog (T-435). All of it is Win32
|
||||||
|
// FFI through kernel32/psapi, so it cannot execute on the Linux CI runner that
|
||||||
|
// produces the coverage report (PosixResourceSampler is used there). It is
|
||||||
|
// validated only when the app actually runs on Windows — which is acceptable
|
||||||
|
// because it is a DIAGNOSTIC that reads, never mutates, and is exhaustively
|
||||||
|
// defensive: every probe is wrapped so any failure yields a `-1` field rather
|
||||||
|
// than an exception, and the toolhelp snapshot handle is always closed. A
|
||||||
|
// missing sample is fine; a sampler that throws or leaks would not be.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:ffi' as ffi;
|
||||||
|
import 'dart:io' show ProcessInfo;
|
||||||
|
|
||||||
|
import 'package:ffi/ffi.dart';
|
||||||
|
|
||||||
|
import 'watchdog.dart';
|
||||||
|
|
||||||
|
const int _kTh32csSnapprocess = 0x00000002;
|
||||||
|
|
||||||
|
final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll');
|
||||||
|
final ffi.DynamicLibrary _psapi = ffi.DynamicLibrary.open('psapi.dll');
|
||||||
|
|
||||||
|
final _getCurrentProcess = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(), ffi.Pointer<ffi.Void> Function()>('GetCurrentProcess');
|
||||||
|
final _getCurrentProcessId = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetCurrentProcessId');
|
||||||
|
final _createToolhelp32Snapshot = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(ffi.Uint32, ffi.Uint32), ffi.Pointer<ffi.Void> Function(int, int)>(
|
||||||
|
'CreateToolhelp32Snapshot',
|
||||||
|
);
|
||||||
|
final _process32First = _k32
|
||||||
|
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
|
||||||
|
'Process32First',
|
||||||
|
);
|
||||||
|
final _process32Next = _k32
|
||||||
|
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
|
||||||
|
'Process32Next',
|
||||||
|
);
|
||||||
|
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('CloseHandle');
|
||||||
|
final _getProcessHandleCount = _psapi
|
||||||
|
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>)>(
|
||||||
|
'GetProcessHandleCount',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Win32 `PROCESSENTRY32` (ANSI). szExeFile is `CHAR[MAX_PATH]`.
|
||||||
|
final class _ProcessEntry32 extends ffi.Struct {
|
||||||
|
@ffi.Uint32()
|
||||||
|
external int dwSize;
|
||||||
|
@ffi.Uint32()
|
||||||
|
external int cntUsage;
|
||||||
|
@ffi.Uint32()
|
||||||
|
external int th32ProcessID;
|
||||||
|
@ffi.IntPtr()
|
||||||
|
external int th32DefaultHeapID;
|
||||||
|
@ffi.Uint32()
|
||||||
|
external int th32ModuleID;
|
||||||
|
@ffi.Uint32()
|
||||||
|
external int cntThreads;
|
||||||
|
@ffi.Uint32()
|
||||||
|
external int th32ParentProcessID;
|
||||||
|
@ffi.Int32()
|
||||||
|
external int pcPriClassBase;
|
||||||
|
@ffi.Uint32()
|
||||||
|
external int dwFlags;
|
||||||
|
@ffi.Array<ffi.Uint8>(260)
|
||||||
|
external ffi.Array<ffi.Uint8> szExeFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples the current process via a single toolhelp snapshot (thread count +
|
||||||
|
/// ConPTY-host children) plus GetProcessHandleCount and ProcessInfo.currentRss.
|
||||||
|
class WindowsResourceSampler implements ResourceSampler {
|
||||||
|
@override
|
||||||
|
ResourceSample sample() {
|
||||||
|
final (threads, children) = _snapshotThreadsAndHosts();
|
||||||
|
return ResourceSample(threads: threads, children: children, handles: _handleCount(), rssBytes: _rss());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One toolhelp snapshot → (this process's thread count, count of its direct
|
||||||
|
/// conhost/OpenConsole children). Both `-1`/unavailable on any failure.
|
||||||
|
(int, int) _snapshotThreadsAndHosts() {
|
||||||
|
var threads = -1;
|
||||||
|
var conhosts = 0;
|
||||||
|
var sawAny = false;
|
||||||
|
ffi.Pointer<ffi.Void>? snap;
|
||||||
|
final entry = calloc<_ProcessEntry32>();
|
||||||
|
try {
|
||||||
|
final myPid = _getCurrentProcessId();
|
||||||
|
snap = _createToolhelp32Snapshot(_kTh32csSnapprocess, 0);
|
||||||
|
entry.ref.dwSize = ffi.sizeOf<_ProcessEntry32>();
|
||||||
|
var ok = _process32First(snap, entry);
|
||||||
|
while (ok != 0) {
|
||||||
|
sawAny = true;
|
||||||
|
if (entry.ref.th32ProcessID == myPid) threads = entry.ref.cntThreads;
|
||||||
|
if (entry.ref.th32ParentProcessID == myPid) {
|
||||||
|
final name = _exeName(entry.ref.szExeFile).toLowerCase();
|
||||||
|
if (name == 'conhost.exe' || name == 'openconsole.exe') conhosts++;
|
||||||
|
}
|
||||||
|
ok = _process32Next(snap, entry);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// any FFI failure → unavailable, not a crash
|
||||||
|
} finally {
|
||||||
|
if (snap != null) {
|
||||||
|
try {
|
||||||
|
_closeHandle(snap);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
calloc.free(entry);
|
||||||
|
}
|
||||||
|
return (threads, sawAny ? conhosts : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _handleCount() {
|
||||||
|
final out = calloc<ffi.Uint32>();
|
||||||
|
try {
|
||||||
|
final ok = _getProcessHandleCount(_getCurrentProcess(), out);
|
||||||
|
return ok != 0 ? out.value : -1;
|
||||||
|
} catch (_) {
|
||||||
|
return -1;
|
||||||
|
} finally {
|
||||||
|
calloc.free(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _rss() {
|
||||||
|
try {
|
||||||
|
return ProcessInfo.currentRss;
|
||||||
|
} catch (_) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _exeName(ffi.Array<ffi.Uint8> arr) {
|
||||||
|
final bytes = <int>[];
|
||||||
|
for (var i = 0; i < 260; i++) {
|
||||||
|
final b = arr[i];
|
||||||
|
if (b == 0) break;
|
||||||
|
bytes.add(b);
|
||||||
|
}
|
||||||
|
return String.fromCharCodes(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/// Web/non-FFI stub for the Windows resource sampler (T-438 web fence, D-100).
|
||||||
|
///
|
||||||
|
/// [watchdog.dart] selects this when `dart.library.ffi` is absent, keeping the
|
||||||
|
/// `kernel32`/`psapi` FFI bindings out of the wasm graph. The watchdog isolate
|
||||||
|
/// never spawns on web, and `forPlatform()` never returns the Windows sampler
|
||||||
|
/// there — this exists only to satisfy the import. Returns an all-unavailable
|
||||||
|
/// sample (every field `-1`) if ever called.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'watchdog.dart';
|
||||||
|
|
||||||
|
class WindowsResourceSampler implements ResourceSampler {
|
||||||
|
@override
|
||||||
|
ResourceSample sample() => const ResourceSample();
|
||||||
|
}
|
||||||
+67
-8
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:clide/app.dart';
|
import 'package:clide/app.dart';
|
||||||
import 'package:clide/test_app.dart';
|
import 'package:clide/test_app.dart';
|
||||||
@@ -39,6 +40,7 @@ import 'package:clide/src/daemon/editor_commands.dart';
|
|||||||
import 'package:clide/src/daemon/files_commands.dart';
|
import 'package:clide/src/daemon/files_commands.dart';
|
||||||
import 'package:clide/src/daemon/git_commands.dart';
|
import 'package:clide/src/daemon/git_commands.dart';
|
||||||
import 'package:clide/src/daemon/image_commands.dart';
|
import 'package:clide/src/daemon/image_commands.dart';
|
||||||
|
import 'package:clide/src/daemon/log_commands.dart';
|
||||||
import 'package:clide/src/daemon/pane_commands.dart';
|
import 'package:clide/src/daemon/pane_commands.dart';
|
||||||
import 'package:clide/src/daemon/status_command.dart';
|
import 'package:clide/src/daemon/status_command.dart';
|
||||||
import 'package:clide/src/daemon/ui_command.dart';
|
import 'package:clide/src/daemon/ui_command.dart';
|
||||||
@@ -49,14 +51,18 @@ import 'package:clide/src/daemon/search_commands.dart';
|
|||||||
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
import 'package:clide/src/editor/registry.dart' show EditorRegistry;
|
||||||
import 'package:clide/src/git/client.dart';
|
import 'package:clide/src/git/client.dart';
|
||||||
import 'package:clide/src/cli/argv_dispatch.dart';
|
import 'package:clide/src/cli/argv_dispatch.dart';
|
||||||
|
import 'package:clide/src/env/shell_env.dart' show primeLoginShellPath;
|
||||||
import 'package:clide/src/ipc/envelope.dart';
|
import 'package:clide/src/ipc/envelope.dart';
|
||||||
import 'package:clide/src/ipc/mcp_server.dart';
|
import 'package:clide/src/ipc/mcp_server.dart';
|
||||||
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory;
|
||||||
|
import 'package:clide/src/pty/pty_log.dart';
|
||||||
import 'package:clide/src/ipc/server.dart';
|
import 'package:clide/src/ipc/server.dart';
|
||||||
import 'package:clide/src/panes/event_sink.dart';
|
import 'package:clide/src/panes/event_sink.dart';
|
||||||
import 'package:clide/src/panes/registry.dart';
|
import 'package:clide/src/panes/registry.dart';
|
||||||
import 'package:clide/src/pql/client.dart';
|
import 'package:clide/src/pql/client.dart';
|
||||||
import 'package:clide/kernel/src/syntax/tree_sitter_ffi.dart';
|
// Web fence (T-438, D-100): tree-sitter init is FFI-backed on desktop, a no-op
|
||||||
|
// on web (highlighting degrades to plain text there).
|
||||||
|
import 'package:clide/kernel/src/syntax/tree_sitter_boot_stub.dart' if (dart.library.ffi) 'package:clide/kernel/src/syntax/tree_sitter_boot_io.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart' show rootBundle;
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -78,7 +84,7 @@ Future<void> main() async {
|
|||||||
|
|
||||||
binding.ensureSemantics();
|
binding.ensureSemantics();
|
||||||
|
|
||||||
TreeSitterLib.init();
|
initTreeSitter();
|
||||||
|
|
||||||
final appDir = await _resolveAppDir();
|
final appDir = await _resolveAppDir();
|
||||||
final themes = await _loadBundledThemes();
|
final themes = await _loadBundledThemes();
|
||||||
@@ -89,7 +95,17 @@ Future<void> main() async {
|
|||||||
// at the last project instead so the daemon targets the real repo from the
|
// at the last project instead so the daemon targets the real repo from the
|
||||||
// first request. (T-352)
|
// first request. (T-352)
|
||||||
Directory startupWorkRoot = resolveWorkspaceRoot(Directory.current);
|
Directory startupWorkRoot = resolveWorkspaceRoot(Directory.current);
|
||||||
|
// Crash-survivable logging (T-425): resolve the dev/prod verbosity once and
|
||||||
|
// attach a FileLogSink as the leading sink so a freeze leaves on-disk
|
||||||
|
// breadcrumbs. Desktop-only — the sink uses dart:io.
|
||||||
|
LogLevel bootLogLevel = kReleaseMode ? LogLevel.warn : LogLevel.info;
|
||||||
|
List<LogSink> bootLogSinks = const [];
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
|
// Resolve the user's real login-shell PATH once, before any tool resolution
|
||||||
|
// or spawn — a desktop/dock launch inherits a sparse PATH that misses
|
||||||
|
// ~/.local/bin, brew, nvm, etc. (T-439). Bounded + graceful: a slow/failed
|
||||||
|
// probe just falls back to the process PATH + well-known dirs.
|
||||||
|
await primeLoginShellPath();
|
||||||
final bootSettings = SettingsStore(appDir: appDir);
|
final bootSettings = SettingsStore(appDir: appDir);
|
||||||
await bootSettings.load();
|
await bootSettings.load();
|
||||||
startupWorkRoot = resolveStartupWorkspace(
|
startupWorkRoot = resolveStartupWorkspace(
|
||||||
@@ -97,6 +113,21 @@ Future<void> main() async {
|
|||||||
lastProject: bootSettings.get<String>('app.lastProject'),
|
lastProject: bootSettings.get<String>('app.lastProject'),
|
||||||
isGitRepo: (d) => Directory('${d.path}/.git').existsSync(),
|
isGitRepo: (d) => Directory('${d.path}/.git').existsSync(),
|
||||||
);
|
);
|
||||||
|
bootLogLevel = resolveLogLevel(
|
||||||
|
isRelease: kReleaseMode,
|
||||||
|
dartDefine: const String.fromEnvironment('CLIDE_LOG'),
|
||||||
|
envVar: Platform.environment['CLIDE_LOG'],
|
||||||
|
settingValue: bootSettings.get<String>('app.log.level'),
|
||||||
|
);
|
||||||
|
bootLogSinks = [FileLogSink(dir: Directory(logDirectory())).call];
|
||||||
|
// Crash-diagnostic watchdog in its own isolate (T-435): heartbeats +
|
||||||
|
// resource samples that survive a frozen main isolate. Non-fatal — a
|
||||||
|
// leak-detector that breaks startup is worse than a missing one. The OS
|
||||||
|
// reaps the isolate on exit; every line is fsynced, so abrupt death loses
|
||||||
|
// nothing.
|
||||||
|
try {
|
||||||
|
await Isolate.spawn(watchdogEntry, ('${logDirectory()}/clide-watchdog.log', 500, 2000));
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve toolchain + boot daemon inline — same as Linux.
|
// Resolve toolchain + boot daemon inline — same as Linux.
|
||||||
@@ -119,6 +150,12 @@ Future<void> main() async {
|
|||||||
// The filter-state cache, captured post-boot so `ui.filter` can read a
|
// The filter-state cache, captured post-boot so `ui.filter` can read a
|
||||||
// box's current value back — the observe-half of D-6 (T-270).
|
// box's current value back — the observe-half of D-6 (T-270).
|
||||||
FilterStateCache? kernelFilterStates;
|
FilterStateCache? kernelFilterStates;
|
||||||
|
// The kernel Logger, captured in the factory so a post-boot project switch
|
||||||
|
// can rebuild the dispatcher with PTY breadcrumbs wired (T-434).
|
||||||
|
Logger? kernelLog;
|
||||||
|
// The kernel settings store, captured post-boot so `clide log level` can
|
||||||
|
// persist app.log.level (T-433).
|
||||||
|
SettingsStore? kernelSettings;
|
||||||
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per
|
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per
|
||||||
// workspace; restarted when the active project switches because the
|
// workspace; restarted when the active project switches because the
|
||||||
// socket path is workspace-derived. The local DaemonClient connects
|
// socket path is workspace-derived. The local DaemonClient connects
|
||||||
@@ -228,15 +265,33 @@ Future<void> main() async {
|
|||||||
Toolchain tc,
|
Toolchain tc,
|
||||||
Directory workRoot,
|
Directory workRoot,
|
||||||
LayoutArrangement arrangement,
|
LayoutArrangement arrangement,
|
||||||
PanelRegistry panels,
|
PanelRegistry panels, {
|
||||||
) {
|
Logger? log,
|
||||||
|
}) {
|
||||||
final dispatcher = DaemonDispatcher();
|
final dispatcher = DaemonDispatcher();
|
||||||
final eventSink = _BusEventSink(events);
|
final eventSink = _BusEventSink(events);
|
||||||
final paneRegistry = PaneRegistry(events: eventSink);
|
// FFI breadcrumbs (T-434): route PTY crumbs to the kernel Logger (source
|
||||||
|
// 'conpty', an eager FileLogSink source) and a sendable crumb file the
|
||||||
|
// reader/waiter isolates open themselves. Verbose (per-syscall) crumbs only
|
||||||
|
// when the log level is debug/trace.
|
||||||
|
final ptyLog = (log == null || kIsWeb)
|
||||||
|
? PtyLog.none
|
||||||
|
: PtyLog(
|
||||||
|
onCrumb: (m) => log.trace('conpty', m),
|
||||||
|
crumbPath: '${logDirectory()}/clide-pty.crumbs.log',
|
||||||
|
verbose: log.minLevel.index <= LogLevel.debug.index,
|
||||||
|
);
|
||||||
|
final paneRegistry = PaneRegistry(events: eventSink, ptyLog: ptyLog);
|
||||||
// D-6 parity (T-219, D-83): make the tabs the user sees in the GUI
|
// D-6 parity (T-219, D-83): make the tabs the user sees in the GUI
|
||||||
// visible to `pane list` by snapshotting the kernel PanelRegistry +
|
// visible to `pane list` by snapshotting the kernel PanelRegistry +
|
||||||
// LayoutArrangement at request time — no mirrored state to drift.
|
// LayoutArrangement at request time — no mirrored state to drift.
|
||||||
registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement));
|
registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement));
|
||||||
|
// `clide log level [<level>]` — the live verbosity toggle's CLI half (T-433,
|
||||||
|
// D-6 parity with the output-dock Level chip). Persists via the kernel
|
||||||
|
// settings, captured post-boot.
|
||||||
|
if (log != null) {
|
||||||
|
registerLogCommands(dispatcher, log, (name) async => await kernelSettings?.set<String>('app.log.level', name));
|
||||||
|
}
|
||||||
// Trusted read-only roots beyond the workspace: the global Claude
|
// Trusted read-only roots beyond the workspace: the global Claude
|
||||||
// config dir (~/.claude), so the reader can open user-scope skill /
|
// config dir (~/.claude), so the reader can open user-scope skill /
|
||||||
// agent / command markdown the Config tab surfaces (D-80, T-195).
|
// agent / command markdown the Config tab surfaces (D-80, T-195).
|
||||||
@@ -341,14 +396,17 @@ Future<void> main() async {
|
|||||||
preloadNamespaces: _tier0Namespaces,
|
preloadNamespaces: _tier0Namespaces,
|
||||||
autoStartDaemonClient: false,
|
autoStartDaemonClient: false,
|
||||||
toolchain: toolchain,
|
toolchain: toolchain,
|
||||||
|
minLogLevel: bootLogLevel,
|
||||||
|
additionalSinks: bootLogSinks,
|
||||||
daemonClientFactory: kIsWeb
|
daemonClientFactory: kIsWeb
|
||||||
? null
|
? null
|
||||||
: (log, events, arrangement, panels) {
|
: (log, events, arrangement, panels) {
|
||||||
daemonBus = events;
|
daemonBus = events;
|
||||||
kernelArrangement = arrangement;
|
kernelArrangement = arrangement;
|
||||||
kernelPanels = panels;
|
kernelPanels = panels;
|
||||||
|
kernelLog = log;
|
||||||
final workRoot = startupWorkRoot;
|
final workRoot = startupWorkRoot;
|
||||||
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels);
|
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels, log: log);
|
||||||
// Build the client at the workspace's socket path. The
|
// Build the client at the workspace's socket path. The
|
||||||
// server is started below (swapBackend) which the
|
// server is started below (swapBackend) which the
|
||||||
// client will then auto-connect to via its reconnect
|
// client will then auto-connect to via its reconnect
|
||||||
@@ -373,7 +431,7 @@ Future<void> main() async {
|
|||||||
final arrangement = kernelArrangement;
|
final arrangement = kernelArrangement;
|
||||||
final panels = kernelPanels;
|
final panels = kernelPanels;
|
||||||
if (bus == null || arrangement == null || panels == null) return;
|
if (bus == null || arrangement == null || panels == null) return;
|
||||||
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels);
|
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels, log: kernelLog);
|
||||||
await swapBackend(dispatcher, teardown, Directory(path));
|
await swapBackend(dispatcher, teardown, Directory(path));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -383,6 +441,7 @@ Future<void> main() async {
|
|||||||
kernelReaderNav = services.readerNav;
|
kernelReaderNav = services.readerNav;
|
||||||
kernelMessages = services.messages;
|
kernelMessages = services.messages;
|
||||||
kernelFilterStates = services.filterStates;
|
kernelFilterStates = services.filterStates;
|
||||||
|
kernelSettings = services.settings;
|
||||||
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
|
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
|
||||||
// shows socket-side logs alongside kernel/extension ones.
|
// shows socket-side logs alongside kernel/extension ones.
|
||||||
ipcLog.addSink(services.logRing.add);
|
ipcLog.addSink(services.logRing.add);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
/// Verb list matches CLAUDE.md's tier-2 surface:
|
/// Verb list matches CLAUDE.md's tier-2 surface:
|
||||||
/// editor.open editor.active editor.activate editor.insert
|
/// editor.open editor.active editor.activate editor.insert
|
||||||
/// editor.replace-selection editor.save editor.close editor.list
|
/// editor.replace-selection editor.save editor.close editor.list
|
||||||
/// editor.read editor.set-selection editor.set-content
|
/// editor.read editor.set-selection editor.set-content editor.goto-line
|
||||||
///
|
///
|
||||||
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
|
/// Single-word CLI shortcuts (`clide open`, `clide active`, …) map
|
||||||
/// one-to-one onto these via the IPC dispatch layer.
|
/// one-to-one onto these via the IPC dispatch layer.
|
||||||
@@ -50,6 +50,14 @@ void registerEditorCommands(DaemonDispatcher d, EditorRegistry registry) {
|
|||||||
d.register('editor.set-content', (req) => _setContent(req, registry));
|
d.register('editor.set-content', (req) => _setContent(req, registry));
|
||||||
d.register('editor.save', (req) => _save(req, registry), schema: _idArg);
|
d.register('editor.save', (req) => _save(req, registry), schema: _idArg);
|
||||||
d.register('editor.close', (req) => _close(req, registry), schema: _idArg);
|
d.register('editor.close', (req) => _close(req, registry), schema: _idArg);
|
||||||
|
d.register(
|
||||||
|
'editor.goto-line',
|
||||||
|
(req) => _gotoLine(req, registry),
|
||||||
|
schema: const CommandSchema(
|
||||||
|
positional: ['line'],
|
||||||
|
args: {'line': ArgSpec(type: ArgType.number)},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err(
|
IpcResponse _userErr(String id, String msg, {String? hint}) => IpcResponse.err(
|
||||||
@@ -220,3 +228,19 @@ Future<IpcResponse> _close(IpcRequest req, EditorRegistry r) async {
|
|||||||
r.close(id);
|
r.close(id);
|
||||||
return IpcResponse.ok(id: req.id, data: {'id': id});
|
return IpcResponse.ok(id: req.id, data: {'id': id});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jump the active (or [id]'d) buffer's caret to the start of a 1-based line —
|
||||||
|
/// the ex-line `:N` goto (T-407) and the CLI `clide editor goto-line <n>`.
|
||||||
|
/// Reuses the [_offsetForLine] mapping `editor.open --line` uses; out-of-range
|
||||||
|
/// lines clamp to the buffer end via setSelection.
|
||||||
|
Future<IpcResponse> _gotoLine(IpcRequest req, EditorRegistry r) async {
|
||||||
|
final id = _resolveId(req, r);
|
||||||
|
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||||
|
final buf = r.get(id);
|
||||||
|
if (buf == null) return _notFound(req.id, 'no such buffer: $id');
|
||||||
|
final rawLine = req.args['line'];
|
||||||
|
final line = rawLine is num ? rawLine.toInt() : int.tryParse('$rawLine');
|
||||||
|
if (line == null || line < 1) return _userErr(req.id, 'line must be a positive integer');
|
||||||
|
r.setSelection(id, Selection.collapsed(_offsetForLine(buf.content, line)));
|
||||||
|
return IpcResponse.ok(id: req.id, data: {'id': id, 'line': line});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// Registers `log.level` — the live dev/prod verbosity toggle (T-433).
|
||||||
|
///
|
||||||
|
/// With no arg it reports the running [Logger]'s minimum level; with
|
||||||
|
/// `level=<name>` it sets the Logger AND persists `app.log.level` so the
|
||||||
|
/// choice survives a restart (resolved at boot by `resolveLogLevel`). The UI
|
||||||
|
/// half is the output dock's Level chip — D-6 parity. Flutter-free + trivially
|
||||||
|
/// testable: the handler takes the Logger and a persist callback.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/kernel/src/log.dart';
|
||||||
|
|
||||||
|
import '../ipc/envelope.dart';
|
||||||
|
import 'dispatcher.dart';
|
||||||
|
|
||||||
|
/// Persists the chosen level name (the owner wires this to
|
||||||
|
/// `settings.set('app.log.level', name)`).
|
||||||
|
typedef LogLevelPersist = Future<void> Function(String levelName);
|
||||||
|
|
||||||
|
void registerLogCommands(DaemonDispatcher d, Logger log, LogLevelPersist persist) {
|
||||||
|
d.register('log.level', (req) async {
|
||||||
|
final raw = req.args['level'];
|
||||||
|
if (raw == null) {
|
||||||
|
// Read: report the current level + the vocabulary.
|
||||||
|
return IpcResponse.ok(id: req.id, data: {'level': log.minLevel.name, 'levels': LogLevel.values.map((l) => l.name).toList()});
|
||||||
|
}
|
||||||
|
final level = parseLogLevel(raw is String ? raw : raw.toString());
|
||||||
|
if (level == null) {
|
||||||
|
return IpcResponse.err(
|
||||||
|
id: req.id,
|
||||||
|
error: IpcError(code: 64, kind: 'bad_arg', message: 'unknown log level: $raw', hint: 'one of: ${LogLevel.values.map((l) => l.name).join(', ')}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log.minLevel = level;
|
||||||
|
await persist(level.name);
|
||||||
|
return IpcResponse.ok(id: req.id, data: {'level': level.name});
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user