Compare commits
@@ -59,7 +59,10 @@
|
|||||||
"Bash(git checkout -- *)",
|
"Bash(git checkout -- *)",
|
||||||
"Bash(git restore .*)",
|
"Bash(git restore .*)",
|
||||||
"Bash(chmod -R 777 *)",
|
"Bash(chmod -R 777 *)",
|
||||||
"Bash(chmod 777 *)"
|
"Bash(chmod 777 *)",
|
||||||
|
"Bash(git add -A*)",
|
||||||
|
"Bash(git add --all*)",
|
||||||
|
"Bash(git add .)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ Follow these conventions whenever you create a commit in this repository. These
|
|||||||
|
|
||||||
## Message style
|
## Message style
|
||||||
|
|
||||||
- **First line:** imperative mood, ≤ 70 characters. Examples: `add sidecar PTY scaffold`, `fix IPC reconnect after app reload`, `update CLI exit-code contract`.
|
This repo uses [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) (per [D-37](../../../governance/decisions/process.md#d-37)).
|
||||||
|
|
||||||
|
- **First line:** `type(scope): imperative subject`, ≤ 72 characters **including** the prefix. Examples: `feat(settings): add Appearance font picker (T-460)`, `fix(ipc): reconnect after app reload`, `docs(readme): drop brittle version line`.
|
||||||
|
- **Type:** one of `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`. Use `feat`/`fix` for user-visible behavior; `chore` for bookkeeping (`chore(plan)` is the convention for pql ticket housekeeping). Append `!` after the scope for a breaking change (`feat(ipc)!: …`).
|
||||||
|
- **Scope (optional but preferred):** the subsystem the change lives in — `settings`, `vim`, `pty`, `git`, `plan`, etc. Lower-case, no spaces.
|
||||||
|
- **Ticket ref:** keep a trailing `(T-NNN)` on the subject when the work has a ticket — `feat(settings): category rail + navigation (T-447)`.
|
||||||
- **Body (optional):** wrap at ~72 chars. Explain the *why* — the reason this change exists. The diff already shows the *what*; don't restate it in prose.
|
- **Body (optional):** wrap at ~72 chars. Explain the *why* — the reason this change exists. The diff already shows the *what*; don't restate it in prose.
|
||||||
- **No emojis.** Anywhere.
|
- **No emojis.** Anywhere.
|
||||||
- **Don't prefix with types** like `feat:` or `fix:` — this repo isn't Conventional Commits. (The Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild at the repo root does not.)
|
|
||||||
- **Don't reference the current task or flow** (`for the v2.0 milestone`, `used by the canvas panel`) — that context belongs in the PR description and rots as the repo evolves.
|
- **Don't reference the current task or flow** (`for the v2.0 milestone`, `used by the canvas panel`) — that context belongs in the PR description and rots as the repo evolves.
|
||||||
- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the Go sidecar/CLI binary is `clide`. The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`.
|
- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the `clide` CLI is a thin C client (`native/clide-cli/`). The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`.
|
||||||
|
|
||||||
## Logically-separated commits
|
## Logically-separated commits
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,15 @@ These apply across every reference and every surface:
|
|||||||
- Never use `Material*` or `Cupertino*` widgets or color constants — clide
|
- Never use `Material*` or `Cupertino*` widgets or color constants — clide
|
||||||
is `WidgetsApp` only (D-7).
|
is `WidgetsApp` only (D-7).
|
||||||
- Use `ClideText` for themed text; never bare `Text` in production widgets.
|
- Use `ClideText` for themed text; never bare `Text` in production widgets.
|
||||||
- Typography: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
|
- Typography sizes: `clideFontMono` for code/paths/IDs, `clideFontCaption` for
|
||||||
status/section headers, body inherits from `DefaultTextStyle`.
|
status/section headers, body inherits from `DefaultTextStyle`.
|
||||||
|
- Font *family* comes from the user-selectable facade, not a const: a
|
||||||
|
monospace surface uses `fontFamily: ClideSettings.fonts.monoOf(context)`
|
||||||
|
(and `fontFamilyFallback: clideMonoFamilyFallback`); the UI face is inherited
|
||||||
|
via the root `DefaultTextStyle`, or `ClideSettings.fonts.uiOf(context)` when a
|
||||||
|
widget must set it explicitly. `clideMonoFamily` / `clideUiFamily` are the
|
||||||
|
facade's defaults — don't read them directly in new widgets (D-101). Same
|
||||||
|
facade exposes `ClideSettings.theme.of(context)` and `.i18n.of(context)`.
|
||||||
|
|
||||||
## Conversation-panel cards (T-305)
|
## Conversation-panel cards (T-305)
|
||||||
|
|
||||||
|
|||||||
@@ -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,92 +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.
|
|
||||||
|
|
||||||
name: test
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
unit:
|
|
||||||
name: unit + widget + golden + a11y
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with: { channel: stable, cache: true }
|
|
||||||
- run: dart pub get
|
|
||||||
- run: (cd app && flutter pub get)
|
|
||||||
- run: ci/test.sh
|
|
||||||
- run: ci/test_a11y.sh
|
|
||||||
- run: ci/coverage_gate.sh
|
|
||||||
|
|
||||||
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: dart pub get
|
|
||||||
- run: (cd app && flutter pub get)
|
|
||||||
- uses: coactions/setup-xvfb@v1
|
|
||||||
with: { run: ci/test_integration.sh }
|
|
||||||
|
|
||||||
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: dart pub get
|
|
||||||
- run: (cd app && flutter pub get)
|
|
||||||
- run: ci/smoke_bundle.sh
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
name: daemon subprocess + web WASM smoke
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: unit
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: subosito/flutter-action@v2
|
|
||||||
with: { channel: stable, cache: true }
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with: { node-version: 20 }
|
|
||||||
- run: dart pub get
|
|
||||||
- run: (cd app && flutter pub get)
|
|
||||||
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
|
|
||||||
- run: ci/test_e2e.sh
|
|
||||||
|
|
||||||
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: dart 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
|
||||||
|
|
||||||
|
|||||||
@@ -26,3 +26,12 @@ INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updat
|
|||||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:09', '2026-06-10 13:27:09', NULL, '1d5185ae9c9676bd70d0e02e3a5e79a1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:09', '2026-06-10 13:27:09', NULL, '1d5185ae9c9676bd70d0e02e3a5e79a1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '90dca94aa700c143290b2b1afaca09ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '90dca94aa700c143290b2b1afaca09ed', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '9b9081edc77f0e9a4b689bbc191771db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-10 13:27:10', NULL, '9b9081edc77f0e9a4b689bbc191771db', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DJZDDZ00BSA04B660RS7M', '06FB3DQEMTDHF8SV27AKAB8JHW', '2026-06-10 13:27:05', '2026-06-12 01:11:12', NULL, '17f1c884268a172f803f407a2ad47c8c', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DN94MBCTYJW17ZCYVSXE0', '2026-06-10 13:27:09', '2026-06-12 01:11:17', NULL, 'a67368ff15089dce57838840632fe07e', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DNQZKV20F7YG5PJH8V8SM', '2026-06-10 13:27:10', '2026-06-12 01:11:22', NULL, '0ec8ff6c455136e45fbb1d06a2a690f1', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DMF20SYFDT6WX2RFBQXKW', '06FB3DP48FS33CQGRDF7EB9GT0', '2026-06-10 13:27:10', '2026-06-12 01:11:26', NULL, '3c985828c591c88d492eb261c6d26d33', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3DQEMTDHF8SV27AKAB8JHW', '06FB3DMF20SYFDT6WX2RFBQXKW', '2026-06-12 01:11:31', '2026-06-12 01:11:31', NULL, '81fd318a43138a3bef82e31f928ff0b2', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKMXSVCE98K1H76N00TYCQR', '2026-06-12 03:16:35', '2026-06-12 03:16:35', NULL, '28d9785a1f9696b6b579a1dbb9fdb889', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN09R21H3AWR2Q2ZTSGNSW', '2026-06-12 03:16:40', '2026-06-12 03:16:40', NULL, '7e00348c10432c65b03f9dce36520e48', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMXSVCE98K1H76N00TYCQR', '06FBKN2MP35NPPK1BRDYY2M428', '2026-06-12 03:16:44', '2026-06-12 03:16:44', NULL, 'a6b1c20279ac30f692a30c802cf8f3e5', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
INSERT INTO ticket_deps (blocker_record_id, blocked_record_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKMV7Y13PAYKZC0WB4FQXKC', '06FBKN4QVFVE51MY2N0CWCVXHM', '2026-06-12 03:16:49', '2026-06-12 03:16:49', NULL, '9063328f18ba32c0737997ac9af0911a', 2) ON CONFLICT(blocker_record_id, blocked_record_id) 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_deps.updated_at OR (excluded.updated_at = ticket_deps.updated_at AND excluded.hash > ticket_deps.hash);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -178,3 +178,125 @@ 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 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 ('06FB5HMYDXP62RKH3HP55T6AYG', 'T-351', '2026-06-10 18:23:41', '2026-06-10 18:23:41', NULL, '9d2da44c16c5aa38c0a36e4b00ef5f15', 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 ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 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 ('06FB5M14B76B31654D959XM5AC', 'T-352', '2026-06-10 18:34:05', '2026-06-10 18:34:05', NULL, '3fe3e1d5fb7c0fbd084b45116575ad98', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'T-353', '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, '53374633101d04f94981baaf4f2e0315', 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 ('06FBAWHM1SQ1686ZJ8JQCFQ1ZW', 'T-353', '2026-06-11 06:50:21', '2026-06-11 06:50:21', NULL, '53374633101d04f94981baaf4f2e0315', 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 ('06FBDSJYQFDNKP4KA1JAEDSS8W', 'T-354', '2026-06-11 13:36:51', '2026-06-11 13:36:51', NULL, '32bb5d359401599022a8771031d8a08a', 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 ('06FBDSKGAHYHH2NPZK8B6EV4D4', 'T-355', '2026-06-11 13:36:55', '2026-06-11 13:36:55', NULL, '2eb6809e958613a924b35e080ab17609', 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 ('06FBDSM0PRGYR61R0NWYAT9VDC', 'T-356', '2026-06-11 13:37:00', '2026-06-11 13:37:00', NULL, 'bdb597080218a3e8783f6c5cf74c529a', 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 ('06FBDSPECQ0FPKB9SYTD7KZSBM', 'T-357', '2026-06-11 13:37:19', '2026-06-11 13:37:19', NULL, '96cf88e036dc3a45487cdbffff26cdde', 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 ('06FBDSQ2GBSP0ZH4RHZG2PMR0R', 'T-358', '2026-06-11 13:37:25', '2026-06-11 13:37:25', NULL, '2a656aaec54bf50c34c7e28347b1fb29', 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 ('06FBHBGHNEQTAEPGNJKN42C1E8', 'T-359', '2026-06-11 21:54:36', '2026-06-11 21:54:36', NULL, '1930fad7b18c79cf97d913d8372b77bd', 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 ('06FBHBJ5T7HAQ9CA8XQMX43A2C', 'T-360', '2026-06-11 21:54:49', '2026-06-11 21:54:49', NULL, '1ffef3c00cdca28566ab67a11ec17e53', 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 ('06FBHBKK2TZQK683J8FS0ZH5A4', 'T-361', '2026-06-11 21:55:01', '2026-06-11 21:55:01', NULL, '5f8bdede98496496bf031a40a09dee16', 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 ('06FBHBN5F0F8SDF15P21DNKT1W', 'T-362', '2026-06-11 21:55:13', '2026-06-11 21:55:13', NULL, '610e74eb7b4ff9db3949ed01a0268380', 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 ('06FBHBPQE4J4YBJX92812ZK6DR', 'T-363', '2026-06-11 21:55:26', '2026-06-11 21:55:26', NULL, '5daf7bcd9ccdc37f8aed531ef12d395f', 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 ('06FBHBR4636GSRJBWFJDAZ6ZA0', 'T-364', '2026-06-11 21:55:38', '2026-06-11 21:55:38', NULL, '025de46ba9e8d005fd8b1f74687cd8b6', 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 ('06FBHBSG6356MZJ2DCCCSBMBGM', 'T-365', '2026-06-11 21:55:49', '2026-06-11 21:55:49', NULL, 'b366b41e4737a2761a01284fd7dd44e0', 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 ('06FBHBV0465906BY3QFAY9F1YM', 'T-366', '2026-06-11 21:56:01', '2026-06-11 21:56:01', NULL, '8a75dc7dea83a0e643d1912bda46dd57', 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 ('06FBHBWE2W1226T58CX37E50HC', 'T-367', '2026-06-11 21:56:13', '2026-06-11 21:56:13', NULL, '578b51eafd19044dc0e2720f6e55d633', 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 ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'T-368', '2026-06-11 21:56:33', '2026-06-11 21:56:33', NULL, '0df409c83fe28af2d49a156118ed6ece', 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 ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'T-369', '2026-06-11 21:56:47', '2026-06-11 21:56:47', NULL, '7f47ddc3e84f9fea915200992a5a0baf', 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 ('06FBHC2NM7AYKENZ0ZD49HAX1W', 'T-370', '2026-06-11 21:57:04', '2026-06-11 21:57:04', NULL, '1908b7c0903c389ad5b743fb89ca32e0', 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 ('06FBHC46SEHH8NQY481VMGK66R', 'T-371', '2026-06-11 21:57:17', '2026-06-11 21:57:17', NULL, '2095f10159561a5e81b2a9b990c79fa2', 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 ('06FBHC5ZE4EZEGXK8YY8J86CM0', 'T-372', '2026-06-11 21:57:31', '2026-06-11 21:57:31', NULL, '9d22f370e726af56d47d6c8acd93bc87', 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 ('06FBHC7KDFW07S8WTCC3MD71J0', 'T-373', '2026-06-11 21:57:44', '2026-06-11 21:57:44', NULL, '231c399183a83e569c0645e1d149625f', 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 ('06FBHC90B72A270CAKA7AP1ZX8', 'T-374', '2026-06-11 21:57:56', '2026-06-11 21:57:56', NULL, '9832ac0b3e0bebd85f3271a5ea96d4ed', 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 ('06FBHCAFKK334YNJXZJQG4J6AW', 'T-375', '2026-06-11 21:58:08', '2026-06-11 21:58:08', NULL, 'de3569d098b5c4cf6a3d49ee3d33d1c9', 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 ('06FBHCC6AR37VTF4SY8DR99JHC', 'T-376', '2026-06-11 21:58:22', '2026-06-11 21:58:22', NULL, 'fe23c94d8971d21963a2b2e2739e05a3', 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 ('06FBHCEC25337J2AXXQNST56Y4', 'T-377', '2026-06-11 21:58:40', '2026-06-11 21:58:40', NULL, '1585df7f3826ddbcef8a030ffd1e0890', 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 ('06FBHCG84F4SPFW111CC4K26A8', 'T-378', '2026-06-11 21:58:55', '2026-06-11 21:58:55', NULL, 'c6b0d9d124401f1d2bdc403567cbfdf8', 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 ('06FBHCJEYHC91PMVNVWVHBR2RG', 'T-379', '2026-06-11 21:59:13', '2026-06-11 21:59:13', NULL, '0d9aed402c9840ef6fb75edfcfcbc3f4', 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 ('06FBHCM1RBAF72SCZBKRTXJSYC', 'T-380', '2026-06-11 21:59:26', '2026-06-11 21:59:26', NULL, '1a0d767f30c9bc0e9c96f39d468ca67b', 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 ('06FBHCP03EJ9CDBGZGRPD19N8W', 'T-381', '2026-06-11 21:59:42', '2026-06-11 21:59:42', NULL, 'cfac236e079164f9588d936f5101c71c', 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 ('06FBHCQHZQ0NKY1VRWWPSNZT84', 'T-382', '2026-06-11 21:59:55', '2026-06-11 21:59:55', NULL, 'b54be44ec4cfbbc649324471a5e2141f', 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 ('06FBHCST6CQ449VJGAP6C5KZ5W', 'T-383', '2026-06-11 22:00:14', '2026-06-11 22:00:14', NULL, '0e18de4e94e4fcf36fc40de763522cbd', 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 ('06FBHCVPGCKEGDC54KKQ120SRM', 'T-384', '2026-06-11 22:00:29', '2026-06-11 22:00:29', NULL, '57d7b6a6c05791acadd1cebe611c8991', 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 ('06FBHCXFF5V1RT6QJETS2K4C0G', 'T-385', '2026-06-11 22:00:44', '2026-06-11 22:00:44', NULL, 'ebda2dd9c7dfe92bab2260dc34212ac4', 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 ('06FBHD098CV2N73823KX4Z99P4', 'T-386', '2026-06-11 22:01:07', '2026-06-11 22:01:07', NULL, 'de90501f2d4be80231651d25d04f4649', 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 ('06FBHD2FWTDFYA00W4QXTE41M0', 'T-387', '2026-06-11 22:01:25', '2026-06-11 22:01:25', NULL, '8a247364a872c223b37682c6496d3920', 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 ('06FBHD4QYHYRTK0SGRZCBSHSQ0', 'T-388', '2026-06-11 22:01:43', '2026-06-11 22:01:43', NULL, 'cc238ee850d4d0ed90d05b9a42c8d506', 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 ('06FBHD6FQMXMQQQ877KMNCK6QC', 'T-389', '2026-06-11 22:01:57', '2026-06-11 22:01:57', NULL, 'bb0657e304f4ad1445d756ef7170c62d', 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 ('06FBHD8F2NBEFZNPKSJ9W253J0', 'T-390', '2026-06-11 22:02:14', '2026-06-11 22:02:14', NULL, '35453f0d8c7e0fb89c243481f870de03', 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 ('06FBHDAK04ZA0PBT69ZWNBXPSR', 'T-391', '2026-06-11 22:02:31', '2026-06-11 22:02:31', NULL, '0647245b4da95532bb3abd6f20cd87de', 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 ('06FBHDC7B2MFQZVR1K9E30FXDR', 'T-392', '2026-06-11 22:02:44', '2026-06-11 22:02:44', NULL, 'a1612ff75df3d134b8e05c716d24ebfe', 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 ('06FBHDE5A3965GNRFS5WPZW878', 'T-393', '2026-06-11 22:03:00', '2026-06-11 22:03:00', NULL, 'f8f29fb8b31cc8afdd8af989c3f711f1', 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 ('06FBHDGPXQN31NNRPJ00PFRAG4', 'T-394', '2026-06-11 22:03:21', '2026-06-11 22:03:21', NULL, 'f9cee5dc96cedfabc3eaaf7352e732c8', 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 ('06FBHDH8TE9MJBXZ4GQ5YT10JM', 'T-395', '2026-06-11 22:03:26', '2026-06-11 22:03:26', NULL, '382a5742e32da0f38c1e143715a0b656', 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 ('06FBJAXQHCKHS8ZSDZNM9NH7QM', 'T-396', '2026-06-12 00:11:50', '2026-06-12 00:11:50', NULL, '20577481e56f82bb0df9e56a266303c9', 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 ('06FBJM6XXQZ3EMGRC13XRYVBEM', 'T-397', '2026-06-12 00:52:25', '2026-06-12 00:52:25', NULL, 'bf89e25b384be60faa65e9eb1ec2fab9', 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 ('06FBKMV7Y13PAYKZC0WB4FQXKC', 'T-398', '2026-06-12 03:15:00', '2026-06-12 03:15:00', NULL, 'd0ed46ee279c148f1d76683e86e0d4aa', 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 ('06FBKMXSVCE98K1H76N00TYCQR', 'T-399', '2026-06-12 03:15:21', '2026-06-12 03:15:21', NULL, 'eacf3522aeb2bccbaf006c9703b9794d', 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 ('06FBKN09R21H3AWR2Q2ZTSGNSW', 'T-400', '2026-06-12 03:15:41', '2026-06-12 03:15:41', NULL, 'a7971f428145d04ae82d4cd89f3eeb9d', 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 ('06FBKN2MP35NPPK1BRDYY2M428', 'T-401', '2026-06-12 03:16:00', '2026-06-12 03:16:00', NULL, '29980034db5fe381473b156ece7d8a1a', 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 ('06FBKN4QVFVE51MY2N0CWCVXHM', 'T-402', '2026-06-12 03:16:18', '2026-06-12 03:16:18', NULL, 'c65717bf20ea6886ef7a86f5cb4b2929', 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 ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'T-403', '2026-06-12 03:20:52', '2026-06-12 03:20:52', NULL, '372a686b214820b5d093b11b9f3d57a5', 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 ('06FBKP8KFAF526ZNXBQS98DPPG', 'T-404', '2026-06-12 03:21:11', '2026-06-12 03:21:11', NULL, 'c479a9dea6687b553bc638a9849cc5de', 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 ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'T-407', '2026-06-12 03:22:10', '2026-06-12 03:22:10', NULL, '129d2b3c31022d53025a3e28169a060e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VTK2MYQQ173MSJN6E1DM', 'T-408', '2026-06-12 06:40:25', '2026-06-12 06:40:25', NULL, '0353aaab57a40900b00883fb12e135f7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBN3VYR84023Z5XFEX9DS0S0', 'T-409', '2026-06-12 06:40:26', '2026-06-12 06:40:26', NULL, '69c280319335a8d0ef646998f4531e96', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'T-410', '2026-06-12 08:58:29', '2026-06-12 08:58:29', NULL, '4f726d1a66d38d14018a62c8d24ffe62', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3GM6V0RZBY2PXE9ZQFR88', 'T-411', '2026-06-12 08:58:42', '2026-06-12 08:58:42', NULL, 'bdd3f8b13caf25677ac661ec29599488', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3J7TXMG0F9E2WQDENPVJG', 'T-412', '2026-06-12 08:58:55', '2026-06-12 08:58:55', NULL, 'e56d50bc6fc04f6d646f22c104e63183', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3KRWM65MD3DS251NN9YX0', 'T-413', '2026-06-12 08:59:08', '2026-06-12 08:59:08', NULL, 'b5247ea05c106500682be978a47e61ee', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P8YERJ5R7ENSD675BX00', 'T-414', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, '7d973f16c99441dbba0f8df89a665b32', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3P91QQQDT5J50F52FPCKM', 'T-415', '2026-06-12 08:59:28', '2026-06-12 08:59:28', NULL, 'a2dea9268d44b9e8a1fd746bbb947c92', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'T-416', '2026-06-12 10:25:00', '2026-06-12 10:25:00', NULL, 'f8c2a125e661607d5dd0c73cd2c3f2ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ4BYD4STCKCY8JNKF23Q4W', 'T-417', '2026-06-12 11:22:15', '2026-06-12 11:22:15', NULL, '15aa9b25417162126cbcde174d3537da', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'T-418', '2026-06-12 11:26:14', '2026-06-12 11:26:14', NULL, '000e07ae64b08273a2d2d9f8a77d193f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBTTMGKSYMTF8M1KQWTG774W', 'T-419', '2026-06-12 19:58:58', '2026-06-12 19:58:58', NULL, 'd2b01a2c3d1ce24cc863ac6d9d814d3d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FC2XY1T85A65YY9SG25VVEY4', 'T-420', '2026-06-13 14:51:51', '2026-06-13 14:51:51', NULL, '16ea4c9353a56798b894ab3d85fb7b56', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'T-421', '2026-06-14 15:29:23', '2026-06-14 15:29:23', NULL, 'a28eed4b57104034c5216344a326195c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDKX4CVHWVGDAJC6X09602M', 'T-422', '2026-06-14 15:45:57', '2026-06-14 15:45:57', NULL, '5701c634f5737a2ba1612deab8df7049', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDM61KAA3GV3CVTE8PAZ8N0', 'T-423', '2026-06-14 15:47:10', '2026-06-14 15:47:10', NULL, 'ce7dfb8b9bd088b4c2e8ddfacc8d2124', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'T-424', '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'ecbed75dcd34c39bcbd86e60d6f4a2c2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'T-425', '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'b09fc55465f7de02cf98f69c99e0e6e3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'T-426', '2026-06-14 18:15:42', '2026-06-14 18:15:42', NULL, '91f50c6e38332047f8619db428d4b376', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'T-427', '2026-06-14 18:15:43', '2026-06-14 18:15:43', NULL, '55a937def177025ef6b61a222d88b142', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'T-428', '2026-06-14 18:15:44', '2026-06-14 18:15:44', NULL, '185e7d3ce8529fbe1f4543f4c635f200', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'T-429', '2026-06-14 18:15:45', '2026-06-14 18:15:45', NULL, 'ef268614a716384a7597b3e304a6c176', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'T-430', '2026-06-14 18:15:46', '2026-06-14 18:15:46', NULL, 'd6333df4da5b7ab8958149a9f0d17974', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCGJ30V24BJB001GZCR5QKTC', 'T-431', '2026-06-14 22:37:27', '2026-06-14 22:37:27', NULL, 'b886820e87f5abd329126bf5e9f1a3da', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'T-432', '2026-06-15 07:18:58', '2026-06-15 07:18:58', NULL, '71f3e4c95f66abc5e7e5820548201e41', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9F446MZFXVHH65Q6CKTPM', 'T-433', '2026-06-15 07:19:01', '2026-06-15 07:19:01', NULL, 'b9a361f2c29b286bc808dbea57a05a7e', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'T-434', '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, '403c4c8aa5659bb379cb8add9dff800b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'T-435', '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, '7c2ed604aecea99b742b341166cf2257', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'T-436', '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '3114ab57de9b03aa1e745af01001eee1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'T-437', '2026-06-15 11:12:36', '2026-06-15 11:12:36', NULL, '74dd08c8c42f556959746ee1a47561e6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'T-438', '2026-06-15 14:14:23', '2026-06-15 14:14:23', NULL, 'fa072ae8dd819784440bb44b5fe689d8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'T-439', '2026-06-15 14:55:15', '2026-06-15 14:55:15', NULL, '2427484ebb324d731cb8e099a2ad04ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-440', '2026-06-15 15:53:05', '2026-06-15 15:53:05', NULL, 'f219bb1a70eb1f49e30975ce8c526051', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZDVPBWGM5NHJ9BNQBVKCD0', 'T-441', '2026-06-16 09:16:07', '2026-06-16 09:16:07', NULL, 'fbabc4fc344b34476b38a186c2f44a16', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCZGG38FF8T9ADF945Z0XPMG', 'T-442', '2026-06-16 09:27:39', '2026-06-16 09:27:39', NULL, 'c85d8fd95cd73aec1e83b477612e1131', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCRERT7X5WMZSGKQCA6T0VB4', 'T-440', '2026-06-15 17:01:25', '2026-06-15 17:01:25', NULL, 'b8e152bf87ec8d81d1bddeb023f37883', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'T-443', '2026-06-15 15:53:05', '2026-06-16 10:17:57', NULL, 'a4d4390596d24d7dc28d80861e36d66b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD07ECS3GK2V7Z2WYYPJHJYC', 'T-444', '2026-06-16 11:07:54', '2026-06-16 11:07:54', NULL, 'ec7a0d8d01c984d931855a6e5c4e86ec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09C4A0HPZSP8HP44F7A894', 'T-445', '2026-06-16 11:16:20', '2026-06-16 11:16:20', NULL, '418d561d50d37c3dd9b6e3abe5fc1b1f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09DH88J1659FDDYJ83JH1M', 'T-446', '2026-06-16 11:16:31', '2026-06-16 11:16:31', NULL, '7c0bc42be1a170b5bb2dd8bd7b027d69', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09SHDMGT66C4D1DRZQZ7RR', 'T-447', '2026-06-16 11:18:10', '2026-06-16 11:18:10', NULL, '203dfb0348a94392f2d087b79aee655f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09TVT3E3ZSV09QRNTSQ8J4', 'T-448', '2026-06-16 11:18:20', '2026-06-16 11:18:20', NULL, '765a91fb5b51889861a41f7eea308ea6', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09WB2XSRP0AZ204FDP681M', 'T-449', '2026-06-16 11:18:32', '2026-06-16 11:18:32', NULL, '1dbd17ff345551d3f091a226bb0f7164', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD09XWEXRJ4JS8MFPQGJEY5M', 'T-450', '2026-06-16 11:18:45', '2026-06-16 11:18:45', NULL, '56f97ccefa83681c387ee9cc4ca6a477', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A0ZTS9JZCSQ4W8ZVEH2VR', 'T-451', '2026-06-16 11:19:11', '2026-06-16 11:19:11', NULL, 'c329f6f1a5d205b7bdf5ab682812a6d1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A4MY8DF6NB0KNV96RQADR', 'T-452', '2026-06-16 11:19:41', '2026-06-16 11:19:41', NULL, '71a7729a8cf7ebc4e915a9e2736717bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0A6K9B60ADTFBZA6NBVJFM', 'T-453', '2026-06-16 11:19:57', '2026-06-16 11:19:57', NULL, 'd408d563785166ee1b40e7494c7f3bbc', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD0ABC7QNEC3XCTV73YPSGR4', 'T-454', '2026-06-16 11:20:36', '2026-06-16 11:20:36', NULL, '27e2ae5b0897eb0f589f4617061101a9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15S1A0WRTY9WWFG0R4AAZ4', 'T-455', '2026-06-16 13:20:25', '2026-06-16 13:20:25', NULL, '370e913c62eab739cf39bda08089e033', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15TY946689VZK3MNABSMDW', 'T-456', '2026-06-16 13:20:41', '2026-06-16 13:20:41', NULL, '6041202d66dd93ee3d22206ece3a7db7', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD15W7SBVAD3QR5NVE67PKWR', 'T-457', '2026-06-16 13:20:52', '2026-06-16 13:20:52', NULL, 'c9bef415fb7d9d03444fde53ecac3bb1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1HK7YKJTEK1WV4VHK0RT8R', 'T-458', '2026-06-16 14:12:04.212', '2026-06-16 14:12:04.212', NULL, '070f1def635ee013df639cb9fe81e357', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD1JBRABJHX804CPMZJDC444', 'T-459', '2026-06-16 14:15:25.011', '2026-06-16 14:15:25.011', NULL, '44d5db70ef13ead00c4dcc437c387cec', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD91A7VEW3VCY7QX1END2Z3G', 'T-460', '2026-06-17 07:39:25.019', '2026-06-17 07:39:25.019', NULL, 'e7e749d35dc413a560d3347be5f4a9ee', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FD91RWDWZPHFPJ72FHHHWS08', 'T-461', '2026-06-17 07:41:24.975', '2026-06-17 07:41:24.975', NULL, '4e3fba7aaad762b7b7cd854459cf664a', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0TPYSWEM10RP0Q76XAP58', 'T-462', '2026-06-17 09:57:06.422', '2026-06-17 09:57:06.422', NULL, '4be5f7eb00ca81eb07015af05ed144d3', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0VPB99NGS4J6Z5B2RJM2M', 'T-463', '2026-06-17 09:57:14.458', '2026-06-17 09:57:14.458', NULL, 'd5d02453175411c13d21361b084494bd', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0WCPG458GF5FXTXM9VY98', 'T-464', '2026-06-17 09:57:20.180', '2026-06-17 09:57:20.180', NULL, 'c787ef61befc6698286ebdaf613e2a41', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0X5WFME83GS0DKYG3WVJ8', 'T-465', '2026-06-17 09:57:26.627', '2026-06-17 09:57:26.627', NULL, '116b1a377950dfcef4e5b6db90141faa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0XMA9BQV4RHJ55BFN4JR4', 'T-466', '2026-06-17 09:57:30.322', '2026-06-17 09:57:30.322', NULL, 'd25bc26eb29445fc35cf53f3d54049e5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'T-467', '2026-06-17 09:57:36.624', '2026-06-17 09:57:36.624', NULL, 'a47c675ca67b86ca3c8d4c1a8acc5b80', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'T-468', '2026-06-17 09:57:41.092', '2026-06-17 09:57:41.092', NULL, 'b988d9578b1ca079c040f07265a008b4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA10DRQE3SED10JAW2CZDCR', 'T-469', '2026-06-17 09:57:53.221', '2026-06-17 09:57:53.221', NULL, '2a72e3e647afc1b5b235ab1a97d298f8', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDB1VFADV4QK1YG29T658T8C', 'T-470', '2026-06-17 12:21:23.411', '2026-06-17 12:21:23.411', NULL, '0f95e5a1faa7a7e902c765f449cceb15', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDJ17C3GZWNE98NRVXP189C', 'T-471', '2026-06-17 18:11:42.049', '2026-06-17 18:11:42.049', NULL, '10a9a957d647a63ce69dc4b6b564f6aa', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'T-472', '2026-06-17 18:48:11.649', '2026-06-17 18:48:11.649', NULL, '097120cff851cb2dac9f54937f4c7b17', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5GVH3FTCVDEC1QAFACY4', 'T-473', '2026-06-17 19:00:20.828', '2026-06-17 19:00:20.828', NULL, '647782edc3fbb4e7285e4f82a1fb84be', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'T-474', '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '79576f18553ed885d3a2b745becdf2f5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'multi-window', '2026-06-14 15:29:27', '2026-06-14 15:29:27', NULL, 'ce03f8eb534bc45e2c1c12e9b30a2c30', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||||
|
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'ipc', '2026-06-14 15:29:28', '2026-06-14 15:29:28', NULL, 'e6789b66db5c1de85daf6fc8d3474449', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||||
|
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'regression', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, 'd1aee45df369a9afed1ecc7bc6043255', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||||
|
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCNYXXH5AAHZR7WV0550J3RC', 'claude-cli', '2026-06-15 11:12:41', '2026-06-15 11:12:41', NULL, '2d486f878edbd7a898ece18900e0d9d6', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||||
|
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQ8HB61N3TWVJ8YSMHH2TJ4', 'web', '2026-06-15 14:22:06', '2026-06-15 14:22:06', NULL, '6867c9766259a7f09e81ff515973914e', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||||
|
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'path', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, 'e73b3b85732a2c21be23353a0f7f1493', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||||
|
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQHWQ40AY6SNVRJ86YWA0J8', 'web', '2026-06-15 15:24:11', '2026-06-15 15:24:11', NULL, '8c93337891e74d67d2465ba2c3b901d5', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||||
|
INSERT INTO ticket_labels (ticket_record_id, label, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCQZ47MAN835B215GSSMRV8W', 'web', '2026-06-15 15:53:35', '2026-06-15 15:53:35', NULL, '4cbf5007b12087b88b8acb36f3616bf8', 2) ON CONFLICT(ticket_record_id, label) DO UPDATE SET updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_labels.updated_at OR (excluded.updated_at = ticket_labels.updated_at AND excluded.hash > ticket_labels.hash);
|
||||||
File diff suppressed because it is too large
Load Diff
+382
@@ -16,6 +16,388 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
## [2.7.0] — 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Settings panel shell.** A new `settings.open` command (⌘`,`, plus a File
|
||||||
|
menu and command-palette entry) opens a centered Settings modal over the
|
||||||
|
dimmed app — the foundation of the schema-driven settings UI. It frames the
|
||||||
|
category rail and scrolling carded panel that later work fills in; closes on
|
||||||
|
✕, Esc, or a barrier tap. (T-445)
|
||||||
|
|
||||||
|
- **Schema-driven settings engine.** Subsystems register a `SettingsCategory`
|
||||||
|
(via `SettingsCategoryContribution` → the kernel `SettingsRegistry`); the
|
||||||
|
panel renders it as carded sections of toggle/select/text/number/file rows,
|
||||||
|
each bound to a `SettingsStore` key with help and reset-to-default.
|
||||||
|
Registering a category surfaces a new tab. (T-448)
|
||||||
|
|
||||||
|
- **Settings category rail.** The modal's left rail lists the registered
|
||||||
|
categories (icon + title, data-driven from the registry) with an accent
|
||||||
|
left-stripe + surfaceHi selection; picking one swaps the panel. (T-447)
|
||||||
|
|
||||||
|
- **Per-field scope tags.** Each field shows where its value lives — folder =
|
||||||
|
Project (`.clide`), globe = Always (`~/.clide`), circle-dashed =
|
||||||
|
Default/unset — with a tooltip and a menu to move the value between scopes or
|
||||||
|
reset it. Backed by scope-explicit `SettingsStore` access. (T-449)
|
||||||
|
|
||||||
|
- **Cross-category settings search.** A search box atop the rail filters fields
|
||||||
|
across every category; the panel shows the matches grouped under category
|
||||||
|
subheaders (editable inline), and each rail row shows its match count with
|
||||||
|
zero-match categories dimmed. (T-450)
|
||||||
|
|
||||||
|
- **Settings → Activity category.** The first real settings tab: the
|
||||||
|
conversation fold level (none / tools / thinking / everything) as a schema
|
||||||
|
field; picking a level applies live to the activity stream. (T-453)
|
||||||
|
|
||||||
|
- **Settings → Keymap category.** A preset select (Default / Vim / VS Code /
|
||||||
|
JetBrains); picking one switches the active keymap live via the preset
|
||||||
|
command. (T-451)
|
||||||
|
|
||||||
|
- **Settings → Appearance category.** A theme picker in the panel — base-theme
|
||||||
|
chips + a high-contrast toggle, applied live. Adds the engine's custom-control
|
||||||
|
escape hatch (`SettingsControlContribution` / `SettingsControlRegistry`) for
|
||||||
|
one-off controls the generic field kinds can't express. (T-452)
|
||||||
|
|
||||||
|
- **Settings → Extensions tab.** A "watch this space" notice — installing and
|
||||||
|
toggling extensions arrives with third-party (Lua) support; built-ins stay
|
||||||
|
always-on for now. (T-456)
|
||||||
|
|
||||||
|
- **Settings → Claude category.** New-session defaults — model, effort, and
|
||||||
|
permission mode — seed fresh sessions (effort via `--effort` at spawn;
|
||||||
|
model and permission applied right after start). (T-457)
|
||||||
|
|
||||||
|
- **Inter is the default UI font + a UI-font picker.** Bundled Inter (variable)
|
||||||
|
as the default interface typeface — Josefin Sans stays selectable — and
|
||||||
|
Settings → Appearance gains a UI-font picker that applies live. (T-460)
|
||||||
|
|
||||||
|
- **Monospace font picker.** Settings → Appearance adds a monospace font select
|
||||||
|
(JetBrains Mono / Fira Mono) that applies live to the terminal, diffs, code,
|
||||||
|
and IDs. Bundles Fira Mono. (T-471)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Theme picker relabelled "Theme…".** The ⌘K theme picker's command title
|
||||||
|
changed from "Settings…" to "Theme…" so it no longer collides with the new
|
||||||
|
Settings panel in the palette; behaviour is unchanged. (T-445)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Monospace setting now applies everywhere.** Eleven context-free render
|
||||||
|
helpers (Claude tool bodies + results, inline markdown code/refs, search
|
||||||
|
previews, welcome tips) hard-coded JetBrains Mono and ignored the Monospace
|
||||||
|
font setting; they now honour it live. (T-472)
|
||||||
|
|
||||||
|
## [2.6.0] — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Vim `gt` / `gT` tab motions.** Under the Vim preset, `gt`/`gT` cycle the
|
||||||
|
workspace tab strip (the same `workspace.tab.*` commands as `ctrl+pagedown`/
|
||||||
|
`ctrl+pageup`), resolved by the focused editor or pane and sharing the `g`
|
||||||
|
prefix with `gg`. Completes the T-403 cross-pane vim layer. (T-405)
|
||||||
|
|
||||||
|
- **Vim ex command-line (`:`).** Under the Vim preset, `:` opens a transient
|
||||||
|
one-line overlay running a fixed table — `:w` save, `:q` close the active tab
|
||||||
|
(the split self-collapses on the last one), `:wq`/`:x` and `ZZ` save+close,
|
||||||
|
`:e <path>` jump to quick-open seeded with the path, `:<n>` goto-line. Unknown
|
||||||
|
commands flash and stay open; with no active buffer it no-ops. Opens from the
|
||||||
|
editor or a focused pane; Esc dismisses. Adds the `editor.goto-line` CLI/IPC
|
||||||
|
verb. (T-407)
|
||||||
|
|
||||||
|
- **Crash-survivable logging.** clide writes a durable JSON-lines log to a
|
||||||
|
persistent per-platform dir (Windows `%LOCALAPPDATA%`, macOS `~/Library/Logs`,
|
||||||
|
Linux `$XDG_STATE_HOME`), fsyncing warn/error + pty/ffi records immediately so
|
||||||
|
a freeze leaves on-disk evidence. `CLIDE_LOG` (dart-define / env) or the
|
||||||
|
`app.log.level` setting sets verbosity (warn in release, info in debug);
|
||||||
|
`CLIDE_LOG_DIR` redirects where the logs land. (T-432, T-436)
|
||||||
|
- **PTY FFI breadcrumbs.** Each PTY backend drops a breadcrumb before/after
|
||||||
|
every risky syscall (`CreatePseudoConsole`/`CreateProcessW`/`ReadFile`,
|
||||||
|
`posix_spawn`/`read`); the reader/waiter isolates fsync their OWN file handle
|
||||||
|
so a wedged isolate's last crumb survives a freeze that also froze the main
|
||||||
|
isolate — naming the wedge after the fact. Per-syscall crumbs at debug level.
|
||||||
|
(T-434)
|
||||||
|
- **Crash-diagnostic watchdog.** A dedicated isolate fsyncs a heartbeat every
|
||||||
|
~500ms (bounding a freeze to ~500ms) and every ~2s samples this process's
|
||||||
|
thread / handle / child-host / RSS counts to `clide-watchdog.log` — a climbing
|
||||||
|
child or thread count is the leak signature. Survives a frozen main isolate;
|
||||||
|
spawn failure is non-fatal. (T-435)
|
||||||
|
- **Live log-verbosity toggle.** The output dock's Level chip now sets the
|
||||||
|
running logger's level and persists `app.log.level` (not just a view filter),
|
||||||
|
and `clide log level [<level>]` does the same from the CLI — D-6 parity. The
|
||||||
|
choice survives restart. (T-433)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`/clear` no longer kills the Claude pane.** Clearing the primary pane tore
|
||||||
|
the session down and respawned on the same deterministic `--session-id`
|
||||||
|
*before the old `claude` process had actually exited*, so claude 2.1.177
|
||||||
|
rejected the id as "already in use" and the respawn exited 1. The session
|
||||||
|
teardown now awaits the process's real death (SIGTERM, escalating to SIGKILL)
|
||||||
|
before clearing the transcript and respawning. A dead pane also now shows the
|
||||||
|
CLI's own reason instead of a bare "exited (code 1)". (T-437)
|
||||||
|
- **Web/WASM build compiles again.** `flutter build web --wasm` (and the
|
||||||
|
`test-e2e` / `ui-dev` / `ui-smoke` harness) had been broken since the
|
||||||
|
tree-sitter/PTY `dart:ffi` pivot. Every native binding (PTY, tree-sitter, the
|
||||||
|
Lua host, the Windows watchdog, the ABI/fd-inheritance probes) now sits behind
|
||||||
|
a `dart.library.ffi` conditional import with a graceful web stub, and the FNV
|
||||||
|
hash constants are dart2js-safe. A `flutter build web --wasm` compile gate was
|
||||||
|
added to CI so the fence can't silently rot. Desktop builds are unchanged — the
|
||||||
|
web target degrades (no terminal, no native git, no syntax highlighting), it
|
||||||
|
does not compromise desktop fidelity. (T-438, D-100, Q-50)
|
||||||
|
- **Desktop-launched clide finds your installed tools.** A dock/launcher start
|
||||||
|
inherits a sparse `PATH` (no `~/.local/bin`, brew, nvm, …), so pql/git/claude
|
||||||
|
and terminal tools could go missing. clide now resolves the real login-shell
|
||||||
|
`PATH` once at startup (`$SHELL -l -c`, bounded + graceful fallback) and routes
|
||||||
|
every spawn site — PTY children, git, the toolchain probe, hosted claude —
|
||||||
|
through one shared resolver, replacing three divergent (and partly macOS-only)
|
||||||
|
PATH expanders. (T-439, follows T-347)
|
||||||
|
|
||||||
|
## [2.5.0] — 2026-06-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Experimental Windows desktop support.** clide builds and runs on Windows —
|
||||||
|
ConPTY-backed terminals, an AF_UNIX `clide` CLI client, PowerShell as the
|
||||||
|
default shell, and a `make build-windows` target. Preview quality: ConPTY
|
||||||
|
child-process reaping under sustained use is still being hardened. (T-424)
|
||||||
|
- **Vim `ctrl+w` window commands.** Under the vim preset, `ctrl+w` followed by
|
||||||
|
h/l (focus left/right panel), j (toggle dock), w / ctrl+w (cycle panels),
|
||||||
|
shift+w (cycle back), o (focus mode), or q/c (close editor). A new global
|
||||||
|
multi-chord matcher in the shell resolves these from any focus; bare `ctrl+w`
|
||||||
|
still closes the editor after the ambiguity timeout. (T-404)
|
||||||
|
- **Workspace tab cycling with ctrl+pagedown / ctrl+pageup.** New
|
||||||
|
`workspace.tab.next` / `workspace.tab.previous` commands cycle the workspace
|
||||||
|
tab strip with wraparound, bound across every preset. (T-405)
|
||||||
|
- **Vim normal-mode navigation works outside the editor.** Under the vim preset,
|
||||||
|
a focused file tree or conversation now responds to j/k, ctrl+d/ctrl+u, gg/G,
|
||||||
|
and (tree) h/l/o — a selection cursor in the tree, scrolling in the
|
||||||
|
conversation. Each pane runs its own sequence matcher; an `editor.focused`
|
||||||
|
flag keeps these keys as buffer motions while the editor holds focus. (T-406)
|
||||||
|
- **Claude Code Workflow runs surface in the conversation and sidebar.** A
|
||||||
|
`Workflow` tool-use renders a dedicated run card — phase groups, per-agent
|
||||||
|
rows with live spinner/check status, usage, and the script — driven by the
|
||||||
|
harness's out-of-band progress events. The Activity tab adds a WORKFLOWS
|
||||||
|
section showing each run's done/total agent count. (T-416)
|
||||||
|
- **Session controls and live usage in the Claude sidebar Activity tab.** A
|
||||||
|
SESSION strip offers clear/compact/fork/resume buttons (same code path as the
|
||||||
|
typed commands), and a refresh control fetches `/usage` — plan usage renders
|
||||||
|
as a USAGE block (session and weekly percentages). The runtime row now also
|
||||||
|
shows the session's effort level. (T-415)
|
||||||
|
- **The Claude sidebar Config tab is a live control panel.** Model, effort, and
|
||||||
|
permission mode are popover controls showing the running session's values;
|
||||||
|
picking an option drives the session through the same path as the typed slash
|
||||||
|
command. The sidebar tables also got a visual pass — larger type, accent
|
||||||
|
section headers, more breathing room. (T-414)
|
||||||
|
- **The TUI command family opens clide surfaces.** `/permissions` sets the mode
|
||||||
|
directly or opens a picker; `/status`, `/config`, `/mcp`, `/agents`, `/hooks`
|
||||||
|
jump to the matching Claude sidebar tab; `/memory` opens CLAUDE.md in the
|
||||||
|
editor; `/help` shows clide's own command summary. (T-413)
|
||||||
|
- **`/effort` works in the Claude pane.** With a level (`/effort xhigh`) the
|
||||||
|
session restarts in place carrying `--effort` — resume keeps the
|
||||||
|
conversation; bare `/effort` opens a picker with the five levels and the
|
||||||
|
current one marked. The active effort shows in the session status. (T-412)
|
||||||
|
- **TUI-only slash commands get a helpful notice instead of failing.** A typed
|
||||||
|
`/cost` or `/doctor` no longer errors raw from the CLI or leaks to the model
|
||||||
|
as literal text — known TUI-only commands route to a muted notice card with
|
||||||
|
the clide-native way. CLI-local output (like `/usage`) renders as a "clide"
|
||||||
|
card, never fake Claude prose. (T-411)
|
||||||
|
- **`/model` works in the Claude pane.** With a name (`/model sonnet`) it
|
||||||
|
switches the live session's model over the control channel; bare `/model`
|
||||||
|
opens a picker in the interaction zone with the CLI's model list and the
|
||||||
|
current model marked. A rejected name rolls back and raises a toast. (T-408)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **tmux is no longer a required tool.** clide stopped spawning tmux when Claude
|
||||||
|
session persistence moved to `--resume` (D-77); the toolchain no longer probes
|
||||||
|
for it or warns when it's absent, on any platform.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`ClaudeConfig` no longer crashes on a project switch that races teardown.**
|
||||||
|
`setProjectDir` / `refresh` / `ensureProbe` now skip `notifyListeners()` if the
|
||||||
|
config was disposed during their async load (the guard `load()` already had).
|
||||||
|
|
||||||
|
## [2.4.1] — 2026-06-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`Shift+;` types a colon again — double-Shift no longer fires on chorded
|
||||||
|
Shift.** The double-tap detector counted any Shift press as a tap, even
|
||||||
|
mid-chord, and never saw keys the focused editor consumed; a tap now
|
||||||
|
requires a clean press-and-release, observed at the raw-keyboard level.
|
||||||
|
(T-409)
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-06-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Live tail inside expanded Bash activity cards.** A Bash card that follows a
|
||||||
|
file (`tail -f …`) now shows a live, scrolling read-only tail of that file
|
||||||
|
below the result — connected only while the card is expanded. Commands with no
|
||||||
|
followable file show a muted "nothing to follow" note. (T-325)
|
||||||
|
|
||||||
|
- **Double-tap-modifier shortcuts (e.g. double-Shift "Search Everywhere").**
|
||||||
|
The keymap can now bind a bare modifier and a double-tap sequence
|
||||||
|
(`shift shift`). All four presets (default, vim, vscode, jetbrains) map
|
||||||
|
double-Shift to the quick-open finder — JetBrains' "Search Everywhere"
|
||||||
|
gesture, aliased to clide's existing fuzzy file finder. (T-341)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Each spawned subagent gets its own collapsing activity card.** A fan-out of
|
||||||
|
N agents no longer merges into one "Activity / N steps" cluster — each spawn is
|
||||||
|
its own card with its prompt and nested run. Non-agent tool calls still group
|
||||||
|
as before. (T-342)
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- **Dead-code sweep.** The legacy free-function git API (with its latent
|
||||||
|
pipe deadlock), ToolCheck, the fd-passing-era libc bindings, the GraphView
|
||||||
|
placeholder, the superseded ColumnHat widget, the tmux-era
|
||||||
|
TranscriptPublisher, the committed `ptyc` binary, and the unused
|
||||||
|
`mocktail` dev-dependency (D-25 amended) are gone. (T-385)
|
||||||
|
|
||||||
|
- **Dead welcome-screen tiles.** "Clone from git…" and "Start a Claude
|
||||||
|
session" did nothing on tap and advertised shortcuts that were never
|
||||||
|
registered; the tips card now lists only shortcuts that exist in the
|
||||||
|
default keymap. Each tile returns when its flow ships. (T-383)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Terminal CSI sequences with intermediate bytes no longer mis-dispatch.**
|
||||||
|
The parser dropped intermediates, so e.g. VT420 scroll-left (`CSI 5 SP @`)
|
||||||
|
executed as "insert 5 blank characters"; such sequences are now reported
|
||||||
|
as unknown instead. (T-123)
|
||||||
|
|
||||||
|
- **PTY master fd no longer leaks when a child exits on its own.** Every
|
||||||
|
terminal or Claude pane whose process ended naturally left its pty device
|
||||||
|
open for the life of the app; natural exit now releases the fd. (T-360)
|
||||||
|
|
||||||
|
- **Closing a terminal pane now closes its shell.** Pane disposal looked up
|
||||||
|
the kernel illegally and swallowed the failure, so `pane.close` was never
|
||||||
|
sent — the backend PTY and daemon pane leaked on every closed terminal
|
||||||
|
pane, and Claude panes leaked a settings listener the same way. (T-366)
|
||||||
|
|
||||||
|
- **Scrolling up during a streaming reply no longer fights the auto-scroll.**
|
||||||
|
The conversation followed the tail on every streamed token regardless of
|
||||||
|
scroll position, dragging a reader back to the bottom; it now follows only
|
||||||
|
while already pinned there. (T-368)
|
||||||
|
|
||||||
|
- **The terminal no longer crashes on truncated SGR color sequences.**
|
||||||
|
`ESC[38m` and friends threw a RangeError inside the emulator; incomplete
|
||||||
|
38/48 sequences are now ignored, and colon-form truecolor/256-color
|
||||||
|
sub-parameters (`38:2:r:g:b`, ITU T.416) parse like the semicolon form
|
||||||
|
instead of being mangled. (T-369)
|
||||||
|
|
||||||
|
- **File listing flags symlinks again and the workspace walk no longer
|
||||||
|
follows them.** Symlink detection was dead code, so `files.walk` (and the
|
||||||
|
search engine on top of it) silently descended symlinked directories —
|
||||||
|
including ones pointing outside the workspace. (T-365)
|
||||||
|
|
||||||
|
- **Search-and-replace now honors its include/exclude globs.** The filters
|
||||||
|
were accepted but never applied, so replace could rewrite files outside
|
||||||
|
the scope the user typed; replace now uses the same glob filtering as
|
||||||
|
search. (T-364)
|
||||||
|
|
||||||
|
- **Switching projects releases the previous workspace's services.** The old
|
||||||
|
file watcher, pane PTYs, in-flight searches, and editor buffers were left
|
||||||
|
alive on every project switch, with stale watcher events leaking into the
|
||||||
|
new workspace. (T-367)
|
||||||
|
|
||||||
|
- **Expanded activity cards are readable by screen readers again.** The
|
||||||
|
collapser's summarized button semantics excluded the whole card, so
|
||||||
|
expanding a run announced nothing inside it; the exclusion is now scoped
|
||||||
|
to the header and the inner cards stay in the a11y tree. (T-370)
|
||||||
|
|
||||||
|
- **A crashed Claude process no longer looks like it's still thinking.**
|
||||||
|
stderr is now drained continuously (an undrained pipe could block the
|
||||||
|
child mid-turn) and the exit code is watched: when the process dies the
|
||||||
|
pane stops spinning, clears any unanswerable permission prompt, reports
|
||||||
|
the exit in the status line, and logs the stderr tail. (T-361)
|
||||||
|
|
||||||
|
- **The Claude status bar populates reliably after a session starts.** The
|
||||||
|
session's init event often fired before the pane subscribed and the plain
|
||||||
|
broadcast stream dropped it, leaving the model/mode/context line blank;
|
||||||
|
session state streams now replay their latest value to late subscribers.
|
||||||
|
(T-274, T-386)
|
||||||
|
|
||||||
|
- **New terminal panes open in the project root.** The shell spawned in the
|
||||||
|
app process's working directory — `$HOME` for desktop launches, and the
|
||||||
|
wrong repo after a project switch. (T-381)
|
||||||
|
|
||||||
|
- **Two simultaneous spawns of the same Claude session no longer leak a
|
||||||
|
process.** Concurrent spawn calls for one pane id both passed the registry
|
||||||
|
check and the loser's live process was orphaned; spawns for an id are now
|
||||||
|
coalesced onto one in-flight future. (T-374)
|
||||||
|
|
||||||
|
- **`/clear` in a fork pane clears instead of re-forking.** The fork source
|
||||||
|
took precedence on every respawn, so clearing a fork tab silently branched
|
||||||
|
the original conversation again; the source now seeds only the first
|
||||||
|
bind. (T-375)
|
||||||
|
|
||||||
|
- **Pipelined IPC requests are now truly serial and framing-safe.** The
|
||||||
|
server's read handler could interleave concurrent requests (against
|
||||||
|
D-72's contract), drop or double frames split across reads, and corrupt
|
||||||
|
multi-byte characters split across chunks. (T-372)
|
||||||
|
|
||||||
|
- **Settings survive nested structures, crashes, and corruption.** Maps
|
||||||
|
inside lists (the keymap overlay shape) were corrupted on save; writes
|
||||||
|
are now atomic (temp file + rename), and a file that fails to parse is
|
||||||
|
preserved as `.broken` with a logged warning instead of being silently
|
||||||
|
reset. (T-376)
|
||||||
|
|
||||||
|
- **Markdown hard breaks break lines and images leave a visible trace.**
|
||||||
|
Both rendered as empty text — words on either side of a hard break glued
|
||||||
|
together and images vanished; breaks now emit a newline and images render
|
||||||
|
an italic `[image: alt]` placeholder. (T-379)
|
||||||
|
|
||||||
|
- **Extension notifications actually appear on screen.** Messages pushed
|
||||||
|
through the kernel Notifications service (e.g. the CLI-install dogfood
|
||||||
|
warnings) accumulated in a list no surface rendered; they now raise
|
||||||
|
toasts with matching severity. (T-382)
|
||||||
|
|
||||||
|
- **Failed `clide claude.*` commands now exit non-zero.** Sixteen handlers
|
||||||
|
reported success with an error message buried in the payload, so scripts
|
||||||
|
could not detect failures like an unknown permission mode; they now
|
||||||
|
return proper error envelopes per the D-6 contract. (T-391)
|
||||||
|
|
||||||
|
- **Terminal output no longer garbles multi-byte characters split across
|
||||||
|
reads.** PTY output and live-tail bytes were decoded per chunk, turning a
|
||||||
|
rune split across reads into replacement-character noise; the terminal
|
||||||
|
now ingests bytes through a persistent decoder. (T-373)
|
||||||
|
|
||||||
|
- **Extension lifecycle is transactional.** A throw mid-activation now
|
||||||
|
unwinds every contribution it had mounted (a retry no longer
|
||||||
|
double-applies), deactivating an extension is refused while active
|
||||||
|
extensions depend on it, and duplicate contribution/command ids are
|
||||||
|
rejected instead of silently clobbering. (T-377)
|
||||||
|
|
||||||
|
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
|
||||||
|
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
|
||||||
|
session out of plan mode, but clide's tracked permission mode didn't follow,
|
||||||
|
so the mode indicator and composer stayed stuck on "plan". The approval now
|
||||||
|
syncs the tracked mode to `default`. (T-337)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **The MCP HTTP server now requires a per-start auth token.** The localhost
|
||||||
|
SSE port served the entire clide command surface unauthenticated,
|
||||||
|
bypassing the unix socket's 0600 gate; requests must now present the
|
||||||
|
token published in the 0600 `/ide` lock file. (T-362)
|
||||||
|
|
||||||
|
- **`editor.open` / `editor.save` are now workspace-confined.** Both verbs
|
||||||
|
accepted absolute paths and `..` traversal verbatim — an unconfined read
|
||||||
|
and write primitive over IPC. They now pass the same path-safety guard as
|
||||||
|
`files.read`, including a symlink re-check at save time. (T-363)
|
||||||
|
|
||||||
## [2.3.3] — 2026-06-11
|
## [2.3.3] — 2026-06-11
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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/).
|
||||||
|
|
||||||
@@ -89,6 +89,8 @@ Shell hygiene (keeps commands inside the permission allowlist, so they don't get
|
|||||||
|
|
||||||
Commit and push directly to `main` for routine work — this is a solo-dev repo and does not use a branch-first / feature-branch flow. Do **not** create a working branch just to land a change. (This overrides the generic "branch before committing on the default branch" assistant default.) The usual safety rules still hold: never `--no-verify`, never force-push `main`, and let the pre-push gate run.
|
Commit and push directly to `main` for routine work — this is a solo-dev repo and does not use a branch-first / feature-branch flow. Do **not** create a working branch just to land a change. (This overrides the generic "branch before committing on the default branch" assistant default.) The usual safety rules still hold: never `--no-verify`, never force-push `main`, and let the pre-push gate run.
|
||||||
|
|
||||||
|
**Never `git add -A` or `git add .` — stage explicit paths every time (`git add <file> …`), no exceptions.** This worktree can host concurrent Claude sessions: a blanket add vacuums another session's in-progress files — and your own unrelated edits — into your commit, mislabeling work and entangling history (this has happened). If `git status` shows files you didn't touch this turn, they are not yours to stage. **Always create commits through the [`git-commit` skill](.claude/skills/git-commit/SKILL.md)** — it encodes the message format (Conventional Commits, per [D-37](governance/decisions/process.md#d-37)), the explicit-staging rule, changelog discipline, and the safety reminders. Don't hand-roll a commit that skips it.
|
||||||
|
|
||||||
The pre-commit hook auto-exports and stages `.pql/changelog/` (the pql ticket DB) on every commit — don't hand-stage it. A ticket change only persists if the turn makes at least one commit; with no commit the hook never fires and a later branch switch can drop it.
|
The pre-commit hook auto-exports and stages `.pql/changelog/` (the pql ticket DB) on every commit — don't hand-stage it. A ticket change only persists if the turn makes at least one commit; with no commit the hook never fires and a later branch switch can drop it.
|
||||||
|
|
||||||
## Changelog discipline
|
## Changelog discipline
|
||||||
|
|||||||
+8
-4
@@ -181,14 +181,18 @@ tickets before the diff lands. Trivial typo fixes don't need one.
|
|||||||
See [D-37](governance/decisions/process.md#d-37) and the bundled
|
See [D-37](governance/decisions/process.md#d-37) and the bundled
|
||||||
[`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short:
|
[`git-commit` skill](.claude/skills/git-commit/SKILL.md). In short:
|
||||||
|
|
||||||
- Imperative subject ≤ 70 chars, no Conventional Commits prefix
|
- [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/):
|
||||||
(this isn't a Conventional Commits repo — the archived Python
|
`type(scope): imperative subject`, ≤ 72 chars including the prefix.
|
||||||
predecessor under [`legacy/`](legacy/) is, but the rebuild isn't).
|
Types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`,
|
||||||
|
`test`, `build`, `chore`; scope is the subsystem (`settings`,
|
||||||
|
`vim`, `pty`…); keep a trailing `(T-NNN)` ticket ref where one
|
||||||
|
applies — e.g. `feat(settings): category rail + navigation (T-447)`.
|
||||||
- One logical change per commit. If the subject needs "and", split it.
|
- One logical change per commit. If the subject needs "and", split it.
|
||||||
- Every user-visible commit adds an entry to `CHANGELOG.md` under
|
- Every user-visible commit adds an entry to `CHANGELOG.md` under
|
||||||
`[Unreleased]` in the right subsection (Added, Changed, Deprecated,
|
`[Unreleased]` in the right subsection (Added, Changed, Deprecated,
|
||||||
Removed, Fixed, Security). Keep entries to one or two short
|
Removed, Fixed, Security). Keep entries to one or two short
|
||||||
sentences — the 60-word cap is enforced by `ci/changelog_gate.sh`.
|
sentences — the 60-word cap is enforced by the pre-push gate
|
||||||
|
(`make changelog-gate`).
|
||||||
- Co-author trailer:
|
- Co-author trailer:
|
||||||
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
|
`Co-Authored-By: Claude <noreply@anthropic.com>` when Claude wrote
|
||||||
any of the diff.
|
any of the diff.
|
||||||
|
|||||||
@@ -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
|
||||||
|
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
|
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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document governs what clide is allowed to do at runtime, what it's allowed to depend on, and how contributors — human and agent — introduce code into the project. It is binding on all contributors. When in doubt, stop and ask.
|
This document governs what clide is allowed to do at runtime, what it's allowed to depend on, and how contributors — human and agent — introduce code into the project. It is binding on all contributors. When in doubt, stop and ask.
|
||||||
|
|
||||||
Rationale for specific architectural choices referenced here and in code comments (the D-### markers) lives in `decisions/`. This document sets the rules; `decisions/` records why the rules produced the code they did in a given case. If the two ever disagree, the rule in this document wins until the document itself is changed.
|
Rationale for specific architectural choices referenced here and in code comments (the D-### markers) lives in `governance/decisions/`. This document sets the rules; `governance/decisions/` records why the rules produced the code they did in a given case. If the two ever disagree, the rule in this document wins until the document itself is changed.
|
||||||
|
|
||||||
## Why this document exists
|
## Why this document exists
|
||||||
|
|
||||||
@@ -124,9 +124,9 @@ When removing a dependency:
|
|||||||
|
|
||||||
1. **Grep the entire repository** for references to the package, its exports, and any type names it contributed. `rg '<package>|<PackageType>|<prefix_>'` across the repo. Zero hits outside git history is the goal. A single lingering import will break the build; a single lingering FFI stub or type alias will compile fine and fail at runtime.
|
1. **Grep the entire repository** for references to the package, its exports, and any type names it contributed. `rg '<package>|<PackageType>|<prefix_>'` across the repo. Zero hits outside git history is the goal. A single lingering import will break the build; a single lingering FFI stub or type alias will compile fine and fail at runtime.
|
||||||
2. **Regenerate the lockfile** as part of the same PR. A `pubspec.yaml` with the dep removed but a `pubspec.lock` that still pins it is a partial removal, and CI or a fresh clone will happily continue installing the package.
|
2. **Regenerate the lockfile** as part of the same PR. A `pubspec.yaml` with the dep removed but a `pubspec.lock` that still pins it is a partial removal, and CI or a fresh clone will happily continue installing the package.
|
||||||
3. **Update `app/assets/licenses.yaml`** to drop the removed package and any transitive deps it brought in that aren't pulled by anything else. If the license manifest is auto-generated on release, verify the generation script sees the change; if it's maintained by hand, edit it in the same PR.
|
3. **Update `assets/licenses.yaml`** to drop the removed package and any transitive deps it brought in that aren't pulled by anything else. If the license manifest is auto-generated on release, verify the generation script sees the change; if it's maintained by hand, edit it in the same PR.
|
||||||
4. **Remove any vendored artifacts** tied to the dep — binaries, prebuilt assets, generated bindings — and delete their `BUILD.md` records. An orphaned vendored binary is worse than a removed one because it looks legitimate.
|
4. **Remove any vendored artifacts** tied to the dep — binaries, prebuilt assets, generated bindings — and delete their `BUILD.md` records. An orphaned vendored binary is worse than a removed one because it looks legitimate.
|
||||||
5. **Check for architectural assumptions** that the dep was carrying. If the removed package was the thing that justified a specific data flow, build step, or platform strategy, either the replacement picks up those responsibilities or the architecture has actually changed and the relevant design decision (see `decisions/`) needs updating.
|
5. **Check for architectural assumptions** that the dep was carrying. If the removed package was the thing that justified a specific data flow, build step, or platform strategy, either the replacement picks up those responsibilities or the architecture has actually changed and the relevant design decision (see `governance/decisions/`) needs updating.
|
||||||
|
|
||||||
A dependency is not removed until all five are true. "I deleted the line from pubspec.yaml" is the start of the removal, not the end.
|
A dependency is not removed until all five are true. "I deleted the line from pubspec.yaml" is the start of the removal, not the end.
|
||||||
|
|
||||||
@@ -188,18 +188,18 @@ When in doubt about a license, the dependency does not land until the question i
|
|||||||
|
|
||||||
### Attribution requirements
|
### Attribution requirements
|
||||||
|
|
||||||
- The license manifest at `app/assets/licenses.yaml` lists every dependency with its license, copyright notice, and upstream URL.
|
- The license manifest at `assets/licenses.yaml` lists every dependency with its license, copyright notice, and upstream URL.
|
||||||
- Transitive dependencies are listed, not just direct ones. If `wasm_run` pulls in `wasmtime` which pulls in `cranelift`, all three appear.
|
- Transitive dependencies are listed, not just direct ones. If `wasm_run` pulls in `wasmtime` which pulls in `cranelift`, all three appear.
|
||||||
- Apache-2.0 dependencies get their `NOTICE` file content preserved verbatim, not summarized.
|
- Apache-2.0 dependencies get their `NOTICE` file content preserved verbatim, not summarized.
|
||||||
- Apache-2.0-with-LLVM-exception (e.g., Cranelift, parts of LLVM) requires the LLVM exception text specifically, not just the Apache-2.0 boilerplate.
|
- Apache-2.0-with-LLVM-exception (e.g., Cranelift, parts of LLVM) requires the LLVM exception text specifically, not just the Apache-2.0 boilerplate.
|
||||||
- Fonts and icon sets get attributed even if the license doesn't strictly require it. It's the right thing to do.
|
- Fonts and icon sets get attributed even if the license doesn't strictly require it. It's the right thing to do.
|
||||||
- `app/assets/licenses.yaml` is regenerated as part of the release build, not maintained by hand. A release that ships a stale manifest is a release defect.
|
- `assets/licenses.yaml` is regenerated as part of the release build, not maintained by hand. A release that ships a stale manifest is a release defect.
|
||||||
|
|
||||||
Adding a dependency means updating the license manifest in the same PR. No exceptions.
|
Adding a dependency means updating the license manifest in the same PR. No exceptions.
|
||||||
|
|
||||||
## Changelog and commit conventions
|
## Changelog and commit conventions
|
||||||
|
|
||||||
clide follows [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) for `CHANGELOG.md` and [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages. Enforcement is handled by the project's git skill; this section exists so human contributors know the standard before their first PR, and so the connection between these conventions and the rest of the policy is explicit.
|
clide follows [Keep a Changelog 1.1](https://keepachangelog.com/en/1.1.0/) for `CHANGELOG.md` and [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages (see [D-37](governance/decisions/process.md#d-37)). Enforcement is handled by the project's git skill; this section exists so human contributors know the standard before their first PR, and so the connection between these conventions and the rest of the policy is explicit.
|
||||||
|
|
||||||
Security-relevant changes — CVE responses, dependency-driven vulnerability fixes, the removal of a phoning-home transitive dep, anything where the rules in this document were the reason for the change — go under the `Security` heading of the release's changelog entry, regardless of whether the code change itself looks security-shaped. That heading is the trail future-us follows to reconstruct why a dep was bumped or removed. Lumping security fixes under `Fixed` because the diff looks like a normal bug fix loses that signal and is the wrong choice even when it's technically accurate.
|
Security-relevant changes — CVE responses, dependency-driven vulnerability fixes, the removal of a phoning-home transitive dep, anything where the rules in this document were the reason for the change — go under the `Security` heading of the release's changelog entry, regardless of whether the code change itself looks security-shaped. That heading is the trail future-us follows to reconstruct why a dep was bumped or removed. Lumping security fixes under `Fixed` because the diff looks like a normal bug fix loses that signal and is the wrong choice even when it's technically accurate.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ An IDE for Claude Code CLI. Native rendering, terminal-first interaction, pql-po
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. tmux owns Claude session persistence (D-41).
|
Single Flutter package at the repo root. The app hosts everything in-process: IPC server, subsystem handlers (pane, files, editor, git, pql), and the extension framework. Claude session persistence is `--resume <session-id>` against Claude Code's transcript files (D-77, superseding the tmux-backed D-41).
|
||||||
|
|
||||||
- **`lib/`** — all Dart code. Core subsystems (`lib/src/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), the extension framework (`lib/extension/`).
|
- **`lib/`** — all Dart code. Core subsystems (`lib/src/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), the extension framework (`lib/extension/`).
|
||||||
- **PTY** — `lib/src/pty/` spawns child processes via Dart FFI `posix_openpt()` + `posix_spawn()` directly; no external helper binary.
|
- **PTY** — `lib/src/pty/` spawns child processes via Dart FFI `posix_openpt()` + `posix_spawn()` directly; no external helper binary.
|
||||||
@@ -15,7 +15,7 @@ Claude drives the UI through a `clide` CLI surface (Bash, not MCP). Every CLI su
|
|||||||
|
|
||||||
## Built-in extensions
|
## Built-in extensions
|
||||||
|
|
||||||
canvas, claude, claude_control, decisions, diff, editor, extensions_ui, files, git, graph, grammars_core, ipc_status, keybindings_ui, markdown, pql, problems, settings_ui, terminal, theme_picker, tickets, todos, welcome.
|
canvas, claude, claude_control, cli_install, decisions, deeplink, default_layout, diff, editor, extensions_ui, files, git, grammars_core, graph, ipc_status, keybindings_ui, markdown, menubar, output, pql, problems, search, settings_ui, terminal, theme_picker, tickets, todos, view, vim, welcome.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ make push-check # pre-push gate: decisions + core + fast + a11y + coverage
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Pre-v2.0 (`2.0.0-dev`). Interaction model and panel system landed. The Python Textual v1.2.0 predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
Active development; the interaction model, panel system, and settings engine have landed. The Python Textual predecessor is archived under [`legacy/`](https://github.com/postmeridiem/clide/tree/main/legacy).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright (c) 2012-2013, The Mozilla Corporation and Telefonica S.A.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -58,8 +58,10 @@ bindings:
|
|||||||
# only claims ctrl+p for selectPrevious `when: palette.open`, so this
|
# only claims ctrl+p for selectPrevious `when: palette.open`, so this
|
||||||
# is conflict-free. Arrow/enter/escape inside the overlay are handled
|
# is conflict-free. Arrow/enter/escape inside the overlay are handled
|
||||||
# locally by the widget.
|
# locally by the widget.
|
||||||
|
# `shift shift` (double-tap) is the JetBrains "Search Everywhere" gesture;
|
||||||
|
# clide aliases it to the quick-open finder across all presets (T-341).
|
||||||
- intent: quickOpen.open
|
- intent: quickOpen.open
|
||||||
keys: [ctrl+p, meta+p]
|
keys: [ctrl+p, meta+p, shift shift]
|
||||||
when: "!palette.open"
|
when: "!palette.open"
|
||||||
# Nav stays on arrows/ctrl+n (not ctrl+p — that's the open chord and
|
# Nav stays on arrows/ctrl+n (not ctrl+p — that's the open chord and
|
||||||
# would collide while the overlay is up).
|
# would collide while the overlay is up).
|
||||||
|
|||||||
@@ -12,11 +12,12 @@
|
|||||||
# IntelliJ's editor-scoped contexts have no producer yet, so global chords
|
# IntelliJ's editor-scoped contexts have no producer yet, so global chords
|
||||||
# stay ungated (they're global in IntelliJ too).
|
# stay ungated (they're global in IntelliJ too).
|
||||||
#
|
#
|
||||||
|
# "Search Everywhere" (double-Shift) maps to clide's quick-open finder via
|
||||||
|
# the `shift shift` double-tap gesture (T-341) — see the quick-open binding.
|
||||||
|
#
|
||||||
# Not bound — no clide command analogue (kept out of scope per the ticket):
|
# Not bound — no clide command analogue (kept out of scope per the ticket):
|
||||||
# Run (Shift+F10), Debug (Shift+F9), Rename/Refactor (Shift+F6), Settings
|
# Run (Shift+F10), Debug (Shift+F9), Rename/Refactor (Shift+F6), Settings
|
||||||
# (Ctrl+Alt+S). And "Search Everywhere" (double-Shift) is not expressible by
|
# (Ctrl+Alt+S).
|
||||||
# the current chord matcher (bare/double modifiers unsupported) — tracked by
|
|
||||||
# T-341; Go to File / Find Action below are the practical stand-ins.
|
|
||||||
|
|
||||||
name: jetbrains
|
name: jetbrains
|
||||||
|
|
||||||
@@ -52,8 +53,10 @@ bindings:
|
|||||||
# -- Quick open: Go to File / Go to Class / Recent Files --------------
|
# -- Quick open: Go to File / Go to Class / Recent Files --------------
|
||||||
# win/linux: Ctrl+Shift+N, Ctrl+N, Ctrl+E. mac: Cmd+Shift+O, Cmd+O,
|
# win/linux: Ctrl+Shift+N, Ctrl+N, Ctrl+E. mac: Cmd+Shift+O, Cmd+O,
|
||||||
# Cmd+E. clide has one fuzzy file finder, so all land on quick-open.
|
# Cmd+E. clide has one fuzzy file finder, so all land on quick-open.
|
||||||
|
# `shift shift` (double-tap) is IntelliJ's "Search Everywhere"; clide
|
||||||
|
# aliases it to the quick-open finder (T-341).
|
||||||
- intent: quickOpen.open
|
- intent: quickOpen.open
|
||||||
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e]
|
keys: [ctrl+shift+n, ctrl+n, ctrl+e, meta+shift+o, meta+o, meta+e, shift shift]
|
||||||
when: "!palette.open"
|
when: "!palette.open"
|
||||||
- intent: quickOpen.selectNext
|
- intent: quickOpen.selectNext
|
||||||
keys: down
|
keys: down
|
||||||
|
|||||||
+97
-1
@@ -34,8 +34,9 @@ bindings:
|
|||||||
- intent: dismiss
|
- intent: dismiss
|
||||||
keys: escape
|
keys: escape
|
||||||
when: palette.open
|
when: palette.open
|
||||||
|
# `shift shift` (double-tap) aliases to quick-open across presets (T-341).
|
||||||
- intent: quickOpen.open
|
- intent: quickOpen.open
|
||||||
keys: [ctrl+p, meta+p]
|
keys: [ctrl+p, meta+p, shift shift]
|
||||||
when: "!palette.open"
|
when: "!palette.open"
|
||||||
- intent: quickOpen.selectNext
|
- intent: quickOpen.selectNext
|
||||||
keys: [down, ctrl+n]
|
keys: [down, ctrl+n]
|
||||||
@@ -49,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
|
||||||
@@ -62,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
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ bindings:
|
|||||||
when: palette.open
|
when: palette.open
|
||||||
|
|
||||||
# -- Quick open / Go to File (Ctrl+P) ---------------------------------
|
# -- Quick open / Go to File (Ctrl+P) ---------------------------------
|
||||||
|
# `shift shift` (double-tap) aliases to quick-open across presets (T-341).
|
||||||
- intent: quickOpen.open
|
- intent: quickOpen.open
|
||||||
keys: [ctrl+p, meta+p]
|
keys: [ctrl+p, meta+p, shift shift]
|
||||||
when: "!palette.open"
|
when: "!palette.open"
|
||||||
- intent: quickOpen.selectNext
|
- intent: quickOpen.selectNext
|
||||||
keys: [down, ctrl+n]
|
keys: [down, ctrl+n]
|
||||||
|
|||||||
+26
-13
@@ -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.3.3"
|
version: "2.7.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
|
||||||
@@ -54,10 +54,32 @@ dependencies:
|
|||||||
license: OFL-1.1
|
license: OFL-1.1
|
||||||
license_file: assets/fonts/jetbrains_mono/OFL.txt
|
license_file: assets/fonts/jetbrains_mono/OFL.txt
|
||||||
purpose: >-
|
purpose: >-
|
||||||
Monospace face for terminal panes, diff views, code editors, and
|
Default monospace face for terminal panes, diff views, code editors, and
|
||||||
any other monospace surface.
|
any other monospace surface.
|
||||||
weights_bundled: [Regular, Italic, Bold, BoldItalic]
|
weights_bundled: [Regular, Italic, Bold, BoldItalic]
|
||||||
|
|
||||||
|
- name: Fira Mono
|
||||||
|
kind: font
|
||||||
|
version: "3.206"
|
||||||
|
homepage: https://github.com/mozilla/Fira
|
||||||
|
license: OFL-1.1
|
||||||
|
license_file: assets/fonts/fira_mono/OFL.txt
|
||||||
|
purpose: >-
|
||||||
|
Selectable monospace face (Settings → Appearance, T-471); JetBrains Mono
|
||||||
|
remains the default.
|
||||||
|
weights_bundled: [Regular, Bold]
|
||||||
|
|
||||||
|
- name: Inter
|
||||||
|
kind: font
|
||||||
|
version: "variable"
|
||||||
|
homepage: https://github.com/rsms/inter
|
||||||
|
license: OFL-1.1
|
||||||
|
license_file: assets/fonts/inter/OFL.txt
|
||||||
|
purpose: >-
|
||||||
|
Default application UI face (T-460). Variable font with optical-size
|
||||||
|
and weight axes; Josefin Sans remains bundled as a selectable option.
|
||||||
|
weights_bundled: [VariableFont, Italic-VariableFont]
|
||||||
|
|
||||||
- name: Josefin Sans
|
- name: Josefin Sans
|
||||||
kind: font
|
kind: font
|
||||||
version: "variable"
|
version: "variable"
|
||||||
@@ -65,8 +87,8 @@ dependencies:
|
|||||||
license: OFL-1.1
|
license: OFL-1.1
|
||||||
license_file: assets/fonts/josefin_sans/OFL.txt
|
license_file: assets/fonts/josefin_sans/OFL.txt
|
||||||
purpose: >-
|
purpose: >-
|
||||||
Application UI face. Default weight Light (300); full 100-700
|
Selectable application UI face (was the default before T-460). Default
|
||||||
range available via the variable-font weight axis.
|
weight Light (300); full 100-700 range via the variable-font weight axis.
|
||||||
weights_bundled: [VariableFont, Italic-VariableFont]
|
weights_bundled: [VariableFont, Italic-VariableFont]
|
||||||
|
|
||||||
- name: Phosphor Icons
|
- name: Phosphor Icons
|
||||||
@@ -205,15 +227,6 @@ dependencies:
|
|||||||
# Build-time-only dependencies — test runners, mocks, lints. Tracked
|
# Build-time-only dependencies — test runners, mocks, lints. Tracked
|
||||||
# here for audit completeness; NOT rendered in the About screen.
|
# here for audit completeness; NOT rendered in the About screen.
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
- name: mocktail
|
|
||||||
kind: dart-package
|
|
||||||
version: "1.0.5"
|
|
||||||
homepage: https://pub.dev/packages/mocktail
|
|
||||||
license: MIT
|
|
||||||
purpose: >-
|
|
||||||
Mocks at IO / IPC boundaries. ChangeNotifier facades use
|
|
||||||
hand-rolled fakes instead of mocks (D-025).
|
|
||||||
|
|
||||||
- name: alchemist
|
- name: alchemist
|
||||||
kind: dart-package
|
kind: dart-package
|
||||||
version: "0.12.1"
|
version: "0.12.1"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
-776
@@ -1,776 +0,0 @@
|
|||||||
# clide — External Consultant Review
|
|
||||||
|
|
||||||
**Date:** 2026-05-14
|
|
||||||
**Scope:** Full-repository assessment of clide at `main` (commit `9030e56`).
|
|
||||||
**Method:** Six independent specialist reviewers, each given read-only access and a
|
|
||||||
brief covering best practice, clean code, architecture, usability, stability,
|
|
||||||
expandability, style, consistency, and general quality. Reviewers did not see each
|
|
||||||
other's findings; cross-cutting themes below are genuine independent agreement.
|
|
||||||
|
|
||||||
**Panel:**
|
|
||||||
| Lens | Reviewer |
|
|
||||||
|---|---|
|
|
||||||
| Architecture | Software Architect |
|
|
||||||
| Tests & quality gates | Test / QA Analyst |
|
|
||||||
| UX & accessibility | UX & Accessibility Expert |
|
|
||||||
| Code quality & craft | Senior Dart/Flutter Engineer |
|
|
||||||
| Security & supply chain | Security Engineer |
|
|
||||||
| Docs, governance & DX | TPM / Developer-Experience Consultant |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overall verdict
|
|
||||||
|
|
||||||
clide is, for a solo-dev pre-v2.0 project, **unusually disciplined** — every reviewer
|
|
||||||
said so independently. The governance system is alive, the core subsystems are small
|
|
||||||
and well-typed, the FFI/PTY layer shows real systems-programming care, and the quality
|
|
||||||
gates are genuine rather than ornamental. The codebase is in good shape.
|
|
||||||
|
|
||||||
The weaknesses cluster into a handful of themes, and several are **load-bearing**: a
|
|
||||||
central guardrail (CLI-first IPC) has no runtime implementation, keyboard operability —
|
|
||||||
the core requirement of a power-user dev tool — is largely unbuilt, an untrusted
|
|
||||||
workspace can achieve code execution, and the two primary onboarding documents describe
|
|
||||||
an architecture that no longer exists.
|
|
||||||
|
|
||||||
None of these are fatal; all are fixable; most have quick-win first steps. But they
|
|
||||||
should be addressed before a public v2.0.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-cutting themes (independent agreement)
|
|
||||||
|
|
||||||
These were each flagged by **two or more** reviewers who did not coordinate:
|
|
||||||
|
|
||||||
1. **The IPC layer is mid-migration and contradicts itself.** The Architect found no
|
|
||||||
Unix-socket *server* anywhere in `lib/` — D-56's "app hosts an in-process IPC server"
|
|
||||||
and D-1's "CLI-first, not MCP" have no runtime path; three IPC clients
|
|
||||||
(`DaemonClient`, `InProcessClient`, `IsolateClient`) coexist with two duplicated
|
|
||||||
service-wiring sites. The Security reviewer independently noted `DaemonClient`'s
|
|
||||||
socket code is still live as an unvalidated attack surface. **Pick one IPC model,
|
|
||||||
implement or amend D-56, delete the other two.**
|
|
||||||
|
|
||||||
2. **The "no pre-existing excuse / clean board" guardrail is being violated right now.**
|
|
||||||
`flutter analyze` reports 9 `unnecessary_import` issues in `test/`; `ci/test.sh` runs
|
|
||||||
analyze with `--no-fatal-infos`, which silently tolerates them. Flagged by the
|
|
||||||
Architect, Code Quality, and Test reviewers. The repo's own rules say fix-first.
|
|
||||||
|
|
||||||
3. **`lib/src/terminal/` is in an undeclared middle state.** ~7k LOC forked from
|
|
||||||
xterm.dart, carrying commented-out `print`s, dangling TODOs, a 1137-line `parser.dart`,
|
|
||||||
and the only `invalid_use_of_protected_member` suppression in the repo. MEMORY says
|
|
||||||
"code under `lib/` is owned, not vendored" — so it must either be formally vendored
|
|
||||||
(frozen, documented, decision-recorded) or cleaned to the project bar. Flagged by
|
|
||||||
Code Quality; the Architect's "consistency" deduction points at the same seam.
|
|
||||||
|
|
||||||
4. **Documentation describes a dissolved architecture.** `README.md` and
|
|
||||||
`docs/initial-plan.md` still describe a Go sidecar, `ptyc/` C helper, `app/`
|
|
||||||
subdirectory, and a separate `clide --daemon` process — all removed by D-5, D-56, and
|
|
||||||
the FFI pivot. A new contributor's first read builds a wrong mental model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Consolidated scorecard
|
|
||||||
|
|
||||||
Scores are each reviewer's, 1–5, on their own dimensions.
|
|
||||||
|
|
||||||
| Domain | Dimension | Score |
|
|
||||||
|---|---|---|
|
|
||||||
| **Architecture** | Layering & dependency direction | 4 |
|
|
||||||
| | Separation of concerns | 4 |
|
|
||||||
| | Expandability | 5 |
|
|
||||||
| | Consistency | 4 |
|
|
||||||
| | Guardrail adherence | 3 |
|
|
||||||
| **Tests** | Coverage quality | 4 |
|
|
||||||
| | Test reliability / flakiness | 3 |
|
|
||||||
| | Gate trustworthiness | 3 |
|
|
||||||
| | Test maintainability | 5 |
|
|
||||||
| | Regression-catching power | 4 |
|
|
||||||
| **UX / a11y** | Interaction model | 2 |
|
|
||||||
| | Accessibility | 3 |
|
|
||||||
| | Visual consistency | 4 |
|
|
||||||
| | Discoverability | 2 |
|
|
||||||
| | State coverage (loading/error/empty) | 3 |
|
|
||||||
| **Code quality** | Idiomatic Dart | 4 |
|
|
||||||
| | Error handling | 4 |
|
|
||||||
| | Naming & readability | 4 |
|
|
||||||
| | Consistency across subsystems | 3 |
|
|
||||||
| | Resource / lifecycle safety | 4 |
|
|
||||||
| **Security** | Subprocess safety | 2 |
|
|
||||||
| | IPC input validation | 3 |
|
|
||||||
| | Path / filesystem safety | 3 |
|
|
||||||
| | Dependency / supply-chain hygiene | 3 |
|
|
||||||
| | Secrets & sandboxing | 3 |
|
|
||||||
| **Docs / governance** | Governance discipline | 4 |
|
|
||||||
| | Documentation accuracy | 2 |
|
|
||||||
| | Changelog hygiene | 3 |
|
|
||||||
| | Contributor onboarding | 2 |
|
|
||||||
| | Convention adherence | 4 |
|
|
||||||
|
|
||||||
**Highest marks:** expandability (5), test maintainability (5). The extension contract
|
|
||||||
and test-helper design are genuine standouts.
|
|
||||||
**Lowest marks:** interaction model (2), discoverability (2), subprocess safety (2),
|
|
||||||
documentation accuracy (2), contributor onboarding (2).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prioritized action list
|
|
||||||
|
|
||||||
Synthesized across all six reviews. Severity is the highest any reviewer assigned.
|
|
||||||
|
|
||||||
### Critical — address before public v2.0
|
|
||||||
|
|
||||||
1. **Fix untrusted-workspace code execution.** `toolchain_paths.dart:79` resolves
|
|
||||||
`native/dugite/bin/git` relative to the *workspace root*; a malicious repo can plant
|
|
||||||
an executable there that clide runs on the first auto-fired `git.status`. Resolve
|
|
||||||
`native/dugite` against `Platform.resolvedExecutable`'s directory, never the
|
|
||||||
workspace. *(Security)*
|
|
||||||
2. **Resolve the IPC story.** Either implement the in-process Unix-socket server per
|
|
||||||
D-56 so the `clide` CLI / C client actually works, or amend D-56 to make in-process
|
|
||||||
direct dispatch the design and delete `DaemonClient`'s socket code, `IsolateClient`,
|
|
||||||
`Backend`, and `backend_entry.dart`. Today the code claims three models and runs one,
|
|
||||||
and a load-bearing guardrail (D-1/D-6) is unmet. *(Architecture, Security)*
|
|
||||||
3. **Make the tool keyboard-operable.** `ClideTappable` (base of nearly every
|
|
||||||
interactive widget) is mouse-only — no `Focus`, no Enter/Space. The command palette
|
|
||||||
has no arrow-key navigation and no Escape. For a keyboard-first dev tool this is a
|
|
||||||
functional gap, not a polish item. *(UX)*
|
|
||||||
4. **Fix the onboarding docs.** Rewrite `README.md`'s `ptyc/` / `make ptyc-build`
|
|
||||||
sections, fix its dead `decisions/` link, and banner `docs/initial-plan.md` as
|
|
||||||
historical (or split out a current `docs/architecture.md`). *(Docs)*
|
|
||||||
|
|
||||||
### Major — should land soon
|
|
||||||
|
|
||||||
5. Add symlink re-resolution + containment re-check in `files.read` / `files.ls` — a
|
|
||||||
repo symlink `config -> /etc/shadow` currently passes path-safety. *(Security)*
|
|
||||||
6. Add `test-integration` (and ideally `smoke-bundle`) to `make push-check` — the gate
|
|
||||||
that catches "app won't boot" is currently omitted from the pre-push gate. *(Tests)*
|
|
||||||
7. Schema-validate the IPC argument surface; reject `-`-prefixed `branch`/`remote`/`path`
|
|
||||||
values; add size/count bounds. *(Security)*
|
|
||||||
8. Establish a real focus-traversal model (`FocusTraversalGroup` per slot, a documented
|
|
||||||
"focus next panel" keybinding) and integrate `FocusTracker` with Flutter's focus
|
|
||||||
system instead of paralleling it. *(UX)*
|
|
||||||
9. Fix the `SchedulerService._startTicker` isolate-spawn race — a `_stopTicker()` before
|
|
||||||
the spawn future resolves leaks a forever-ticking isolate. Mirror `NativePty`'s
|
|
||||||
`_readerReady` pattern. *(Code quality)*
|
|
||||||
10. Decide the status of `lib/src/terminal/` — formally vendor (and decision-record) it,
|
|
||||||
or do the cleanup sweep. *(Code quality)*
|
|
||||||
11. Replace fixed wall-clock `Future.delayed` sleeps in `watcher_test.dart` /
|
|
||||||
`session_test.dart` with event-driven waits; make swallowed `onTimeout` callbacks
|
|
||||||
`fail()` loudly. *(Tests)*
|
|
||||||
12. Write a human-facing `CONTRIBUTING.md`; cut an interim release to drain the ~80-commit
|
|
||||||
`[Unreleased]` backlog; merge duplicate changelog subsection headings. *(Docs)*
|
|
||||||
13. Single global `KeyboardListener` → scoped `Shortcuts`/`Actions`; move
|
|
||||||
`KeybindingResolver` off layout-dependent `keyLabel`. *(UX)*
|
|
||||||
|
|
||||||
### Quick wins — hours each
|
|
||||||
|
|
||||||
- Clear the 9 `unnecessary_import` analyzer issues; drop `--no-fatal-infos` from
|
|
||||||
`ci/test.sh`. *(Architecture, Tests, Code quality)*
|
|
||||||
- Run the `forkpty` PTY tests with `--coverage` so `native_pty.dart` — the riskiest file
|
|
||||||
— is honestly measured. *(Tests)*
|
|
||||||
- Add a `Focus` + Enter/Space wrapper and a focus-ring inside `ClideTappable`; this fixes
|
|
||||||
the keyboard gap for every button and list item at once. *(UX)*
|
|
||||||
- Add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
|
||||||
`_ProjectSwitcherDropdown` `onKeyEvent` pattern). *(UX)*
|
|
||||||
- Amend D-66 to reflect the coverage floor's real location (`pubspec.yaml`), mechanism,
|
|
||||||
and value (90%) — it currently disagrees with the changelog and the code. *(Docs)*
|
|
||||||
- Reconcile `licenses.yaml` with `pubspec.yaml` (`test` version drift, phantom `lints`
|
|
||||||
entry); add a `native/SHA256SUMS` manifest. *(Security, Docs)*
|
|
||||||
- Replace silent `catch (_)` in `tree_sitter_ffi.dart` with a logged last-error.
|
|
||||||
*(Code quality)*
|
|
||||||
- Fix the `clide.dart` barrel leak in `file_tree_view.dart:8`; narrow the barrel (drop
|
|
||||||
the `dispatcher.dart` export); move `test_app.dart` out of the production `main.dart`
|
|
||||||
import graph. *(Architecture)*
|
|
||||||
- Expand the contrast gate's `canonicalPairs` to cover `globalTextMuted`, the `status*`
|
|
||||||
colors, and `panelActiveBorder`. *(UX)*
|
|
||||||
- Triage stale governance Q-records (Q-1/2/3/25 overtaken by shipped Tier-1 work).
|
|
||||||
*(Docs)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Full reviews
|
|
||||||
|
|
||||||
## 1. Architecture — Software Architect
|
|
||||||
|
|
||||||
### Executive summary
|
|
||||||
|
|
||||||
clide is an unusually disciplined solo-dev codebase. The governance system (67
|
|
||||||
D-records, tracked Q/R) is real and largely honored in code, the kernel/extension split
|
|
||||||
is coherent, and the feature-first layout with barrel files is consistently applied. The
|
|
||||||
single biggest strength is the **extension contract**: every built-in — including layout
|
|
||||||
itself — passes the same `ClideExtension` + `ContributionPoint` contract, which is the
|
|
||||||
best possible proof the contract is usable. The single biggest risk is **architectural
|
|
||||||
drift in the IPC layer**: D-56 mandates the Flutter app host an in-process IPC server
|
|
||||||
reachable by a thin C client over a unix socket, but no socket server exists anywhere in
|
|
||||||
`lib/` — the "CLI-first, not MCP" guardrail (D-1) has no runtime path today. Compounding
|
|
||||||
this, three parallel IPC client implementations (`DaemonClient` socket,
|
|
||||||
`InProcessClient`, `IsolateClient` + `Backend`) coexist with two competing
|
|
||||||
service-wiring sites (`main.dart` and `backend_entry.dart`), suggesting an unfinished
|
|
||||||
migration.
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
- **Extension contract is clean and scales** — `lib/extension/src/extension.dart` +
|
|
||||||
`contribution.dart`: sealed `ContributionPoint` hierarchy, `ClideExtensionContext`
|
|
||||||
lists services explicitly (deliberately avoiding a `KernelServices` import cycle —
|
|
||||||
`extension.dart:50-52`). `ExtensionManager` does dependency topo-sort,
|
|
||||||
dependency-gated activation, and contribution apply/remove symmetrically
|
|
||||||
(`extensions_manager.dart:164-202`). Adding a pane = new extension file + one
|
|
||||||
`register()` line in `main.dart`.
|
|
||||||
- **Kernel admission rule is enforced, not aspirational** — D-12's "mandatory shared
|
|
||||||
singleton" test visibly shaped `KernelServices` (`facade.dart:38-93`); ~25 services,
|
|
||||||
each defensibly cross-cutting. The two-tier disable model (D-14) is honored:
|
|
||||||
`default_layout` is itself an extension.
|
|
||||||
- **Feature-first layout with barrel discipline** (D-8) is consistent — every
|
|
||||||
`builtin/<name>/` and `kernel/` has a barrel; builtins import
|
|
||||||
`package:clide/kernel/kernel.dart`, not deep paths. Only one leak found.
|
|
||||||
- **Governance-to-code traceability is genuine** — `WidgetsApp` root (D-7) at
|
|
||||||
`app.dart:38`, `ChangeNotifier`/`ListenableBuilder` state (D-10) everywhere, git
|
|
||||||
hardcoded in toolchain/project loader (D-13), terminal correctly tagged
|
|
||||||
`inlined-source` in `licenses.yaml` with modifications documented.
|
|
||||||
- **Git subsystem cohesion** — `lib/src/git/` cleanly split into `client` / `status` /
|
|
||||||
`diff` / `operations` (~250 lines each), each a single responsibility.
|
|
||||||
|
|
||||||
### Findings
|
|
||||||
|
|
||||||
- **[Critical] No IPC socket server exists** — D-56 specifies the app hosts an
|
|
||||||
in-process IPC server with a C client connecting over a unix socket. `grep` for
|
|
||||||
`ServerSocket`/unix-domain `bind` in `lib/` returns nothing. `DaemonClient._connect`
|
|
||||||
(`client.dart:72-94`) *connects* to a socket, but nothing *serves* one. Today the only
|
|
||||||
working path is `InProcessClient` (`in_process.dart`), which calls the dispatcher
|
|
||||||
directly in-process. **Claude cannot drive clide via `clide ...` — the CLI-first
|
|
||||||
guardrail (D-1, D-6) has no implementation.** This is the load-bearing contract of the
|
|
||||||
whole project and it is absent.
|
|
||||||
- **[Major] Three IPC clients + two wiring sites = unfinished migration** —
|
|
||||||
`DaemonClient` (socket), `InProcessClient`, and `IsolateClient`+`Backend`/
|
|
||||||
`backend_entry.dart` all coexist. `main.dart:76-113` wires subsystems via
|
|
||||||
`buildDispatcher`; `backend_entry.dart:40-110` wires the *same* five subsystems again
|
|
||||||
inside an isolate. `Backend.spawn` is referenced only by `facade.dart` but `main.dart`
|
|
||||||
uses `autoStartDaemonClient: false` + `daemonClientFactory` (the in-process path).
|
|
||||||
Dead-or-dormant isolate infrastructure with duplicated registration logic — pick one
|
|
||||||
and delete the others.
|
|
||||||
- **[Major] `main.dart` (production entry) imports `test_app.dart`** — `main.dart:2` and
|
|
||||||
`:51-55`. The production binary carries the test harness and branches on
|
|
||||||
`CLIDE_TESTMODE`. Test scaffolding should not be reachable from the shipping entry
|
|
||||||
point; gate it behind a separate entrypoint or `kDebugMode`.
|
|
||||||
- **[Minor] `flutter analyze` reports 9 issues** — all `unnecessary_import` in `test/`,
|
|
||||||
but CLAUDE.md's "no pre-existing excuse" / "clean board" guardrail makes this a
|
|
||||||
fix-first item.
|
|
||||||
- **[Minor] Barrel leak** — `lib/builtin/files/src/file_tree_view.dart:8` imports
|
|
||||||
`package:clide/src/files/listing.dart` directly instead of via
|
|
||||||
`package:clide/clide.dart` (which already re-exports `FileEntry`).
|
|
||||||
- **[Minor] `clide.dart` barrel exports the daemon dispatcher** — `clide.dart:15`
|
|
||||||
exports `src/daemon/dispatcher.dart`. The barrel is described as "shared types"; the
|
|
||||||
dispatcher is server-side machinery.
|
|
||||||
- **[Minor] `ExtensionManager.activate` swallows exceptions** (`extensions_manager.dart:
|
|
||||||
141-143`) — a failed `activate()` logs and continues, leaving the extension
|
|
||||||
un-activated but `_known`, with no surfaced "degraded" state for the UI.
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
|
|
||||||
**Quick wins:** clear the 9 analyzer issues; fix the `file_tree_view.dart` barrel leak
|
|
||||||
(consider a CI grep gate for `package:clide/src/` imports outside their feature); move
|
|
||||||
`test_app.dart` out of the production import graph; drop the `dispatcher.dart` export
|
|
||||||
from `clide.dart`.
|
|
||||||
|
|
||||||
**Larger efforts:** resolve the IPC story (implement the socket server per D-56, or
|
|
||||||
amend D-56 and delete `DaemonClient`/`IsolateClient`/`Backend`/`backend_entry.dart`);
|
|
||||||
collapse subsystem wiring into one `registerAllSubsystems(...)` function; give
|
|
||||||
`ExtensionManager` a surfaced failure state so the UI can show degraded built-ins.
|
|
||||||
|
|
||||||
### Scorecard
|
|
||||||
|
|
||||||
| Dimension | Score | Justification |
|
|
||||||
|---|---|---|
|
|
||||||
| Layering & dependency direction | 4/5 | Kernel→extension direction clean, context-vs-aggregate split avoids cycles; docked for the `src/`↔`kernel/src/` barrel leak and the dispatcher export. |
|
|
||||||
| Separation of concerns | 4/5 | Feature-first layout, single-responsibility subsystems; duplicated subsystem registration is the blemish. |
|
|
||||||
| Expandability | 5/5 | New pane = one extension file + one `register()` line; sealed contribution hierarchy; layout itself is data and extension-shaped. |
|
|
||||||
| Consistency | 4/5 | Barrels, naming, D-record back-references uniform; three coexisting IPC clients and 9 analyzer issues break the bar. |
|
|
||||||
| Guardrail adherence | 3/5 | `WidgetsApp`, single-process, no-Material, governance, zero-deps all honored — but D-1/D-6/D-56 (CLI-first via socket server) have no runtime implementation. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Tests & quality gates — Test / QA Analyst
|
|
||||||
|
|
||||||
### Executive summary
|
|
||||||
|
|
||||||
The clide test suite is, for a solo-dev pre-2.0 project, in genuinely good shape. ~104
|
|
||||||
test files against 276 lib files, ~92.75% line coverage, and — critically — the coverage
|
|
||||||
was *not* bought with assertion-free filler. Even the alarmingly-named files
|
|
||||||
(`coverage_trivials_test.dart`, `zero_coverage_widgets_test.dart`,
|
|
||||||
`services_stubs_test.dart`, `mop_up_test.dart`) contain real behavioral assertions. The
|
|
||||||
biggest strength is a sensibly layered pyramid with a real boot-path integration gate
|
|
||||||
and a startup smoke test that catches the "tests pass but app won't launch" class. The
|
|
||||||
biggest risk is **flakiness from wall-clock-dependent tests** — fixed `Future.delayed`
|
|
||||||
sleeps in file-watcher and PTY tests will eventually produce intermittent CI failures,
|
|
||||||
and the PTY tests are run via `dart test` so they are **excluded from the coverage
|
|
||||||
measurement entirely**.
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
- **Test pyramid is sound.** Pure-Dart unit, widget tests with a shared harness, golden
|
|
||||||
tests (Alchemist), an a11y contract layer, and 3 real-boot `integration_test/` files —
|
|
||||||
correctly separated by runner (`ci/test.sh` vs `ci/test_core.sh` vs
|
|
||||||
`ci/test_integration.sh`).
|
|
||||||
- **Helpers are well-designed.** `test/helpers/kernel_fixture.dart` boots a real
|
|
||||||
`KernelServices` with in-memory themes/i18n and `autoStartDaemonClient: false` — no
|
|
||||||
real socket, temp-dir scoped, proper `dispose()`. `FakeDaemonClient` is a clean stub.
|
|
||||||
- **Error-branch discipline.** `pql_commands_errors_test.dart` /
|
|
||||||
`git_commands_errors_test.dart` deliberately point the toolchain at a non-existent
|
|
||||||
binary to drive catch-branches the happy path can't reach — table-driven, with
|
|
||||||
`reason:` tags.
|
|
||||||
- **OS-dialog avoidance is handled correctly.** `welcome/dialog_test.dart` mocks the
|
|
||||||
`clide/window` MethodChannel to throw `MissingPluginException`, exercising the fallback
|
|
||||||
path *without* spawning a native file picker.
|
|
||||||
- **Startup gate.** `ci/smoke_bundle.sh` builds the real release bundle and runs it
|
|
||||||
under xvfb for 5s, correctly interpreting `timeout` exit codes (124/143 = healthy).
|
|
||||||
- **Coverage gate is honest.** `ci/coverage_gate.sh` is a self-contained awk parser (no
|
|
||||||
`lcov` dependency), ratchets only upward, and `exit 2` distinguishes "missing data"
|
|
||||||
from "below floor."
|
|
||||||
|
|
||||||
### Findings
|
|
||||||
|
|
||||||
- **[Major] PTY tests are excluded from coverage.** `ci/test.sh:13` runs
|
|
||||||
`flutter test --coverage --exclude-tags forkpty`; the `forkpty` tests run separately
|
|
||||||
via `dart test` with no `--coverage`. So `lib/src/pty/native_pty.dart` — the
|
|
||||||
highest-risk native code in the repo — is barely in the measured denominator. The
|
|
||||||
92.75% number overstates coverage of the riskiest file.
|
|
||||||
- **[Major] Wall-clock sleeps will flake.** `test/files/watcher_test.dart:67-82` uses
|
|
||||||
fixed `Future.delayed`; `test/pty/session_test.dart:71` polls 50×100ms and `:65` uses a
|
|
||||||
bare `500ms` settle. `session_test.dart`'s `timeout(5s, onTimeout: () {})` (`:48`)
|
|
||||||
*swallows* the timeout — a never-producing PTY proceeds to a confusing assertion
|
|
||||||
failure rather than a clear timeout.
|
|
||||||
- **[Major] `make push-check` does not run integration tests.** `push-check:
|
|
||||||
decisions-validate test-core test test-a11y coverage-gate` — `test-integration` and
|
|
||||||
`smoke-bundle` are omitted. A boot-order regression sails through.
|
|
||||||
- **[Minor] `flutter analyze --no-fatal-infos` in `ci/test.sh:9`** contradicts the
|
|
||||||
stated "fail-on-warning, clean board" discipline.
|
|
||||||
- **[Minor] Integration tests run one-file-at-a-time** to dodge a batch "Unable to start
|
|
||||||
the app" error — each invocation re-boots the engine (slow), and the workaround masks
|
|
||||||
whether the batch failure is environmental or a real teardown leak.
|
|
||||||
- **[Minor] Golden CI config disabled.** Only platform goldens run; a Linux-only CI
|
|
||||||
never validates the macOS goldens, and stale `test/goldens/failures/*.png` artifacts
|
|
||||||
are committed to the repo.
|
|
||||||
- **[Minor] `test_core.sh` timeout kill is best-effort** — the `pkill -9 -f` pattern
|
|
||||||
match is redundant noise next to the real `setsid` + `timeout --kill-after` safety net.
|
|
||||||
- **[Minor] `git/client_test.dart` depends on the ambient `git` binary**, not the
|
|
||||||
vendored dugite — the suite passes/fails on the host git version.
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
|
|
||||||
**Quick wins:** add `test-integration` (and `smoke-bundle`) to `push-check` — the single
|
|
||||||
highest-value change; run the `forkpty` tests with `--coverage`; drop `--no-fatal-infos`;
|
|
||||||
gitignore `test/goldens/failures/`; make `onTimeout` callbacks `fail()`.
|
|
||||||
|
|
||||||
**Larger efforts:** replace fixed sleeps with event-driven waits
|
|
||||||
(`expectLater(stream, emits(...))`); add a macOS golden CI matrix entry or document
|
|
||||||
goldens as advisory; consider a coverage-exclusion allowlist for genuinely-unreachable
|
|
||||||
defensive branches rather than chasing the last lines with filler tests.
|
|
||||||
|
|
||||||
### Scorecard
|
|
||||||
|
|
||||||
| Dimension | Score | Justification |
|
|
||||||
|---|---|---|
|
|
||||||
| Coverage quality | 4/5 | Tests are meaningful even in "mop-up" files; docked because PTY/FFI is outside the measured number. |
|
|
||||||
| Test reliability / flakiness | 3/5 | Fixed wall-clock sleeps and a swallowed timeout are latent intermittent failures. |
|
|
||||||
| Gate trustworthiness | 3/5 | Coverage gate and smoke bundle are well-built, but `push-check` omits integration tests. |
|
|
||||||
| Test maintainability | 5/5 | Shared fixtures, consistent structure, table-driven error suites, clear doc comments. |
|
|
||||||
| Regression-catching power | 4/5 | Real boot-path integration + smoke + a11y + goldens; weakened by single-OS goldens and PTY coverage gaps. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. UX & accessibility — UX & Accessibility Expert
|
|
||||||
|
|
||||||
### Executive summary
|
|
||||||
|
|
||||||
clide has an unusually disciplined *foundation* for a solo pre-v2.0 project: a coherent
|
|
||||||
semantic design-token system, a WCAG-AA contrast gate wired into CI, and i18n/semantic
|
|
||||||
contract tests. That foundation is the biggest strength. The biggest risk is that
|
|
||||||
**keyboard operability is largely unimplemented below the foundation** — the project's
|
|
||||||
own core interaction primitive (`ClideTappable`) is mouse-only, the command palette has
|
|
||||||
no arrow-key navigation or Escape, and there is no focus-traversal wiring across panels.
|
|
||||||
For a keyboard-first power-user dev tool, this is a critical gap that the a11y test suite
|
|
||||||
does not catch because the tests assert *structural* presence (Semantics nodes exist)
|
|
||||||
rather than *operability* (can you actually drive it from the keyboard).
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
- **Semantic token system is real and enforced.** `lib/kernel/src/theme/tokens.dart`
|
|
||||||
defines ~65 named surface tokens; widgets consume `ClideTheme.of(context).surface`
|
|
||||||
rather than raw colors. The resolver provides defaults so partial themes still produce
|
|
||||||
a complete `SurfaceTokens`.
|
|
||||||
- **Contrast gate is genuine WCAG math, run per-theme.** `lib/kernel/src/theme/
|
|
||||||
contrast.dart` implements real relative-luminance ratio with alpha pre-compositing
|
|
||||||
against neutral grey (`contrast.dart:31-37`) — semi-transparent tokens can't spuriously
|
|
||||||
pass.
|
|
||||||
- **Semantics are present on composed widgets.** `ClideButton` wraps
|
|
||||||
`Semantics(button: true, enabled:, label:, hint:, onTap:)`; panels set
|
|
||||||
`container: true, explicitChildNodes: true` with landmark labels.
|
|
||||||
- **State coverage exists in data panels.** `git_panel_view.dart:86-104` handles error,
|
|
||||||
loading, and empty ("working tree clean") states distinctly; `file_tree_view.dart`
|
|
||||||
handles error + loading.
|
|
||||||
- **Manual a11y discipline is documented.** `docs/testing/a11y-manual.md` prescribes a
|
|
||||||
per-tier Orca/VoiceOver pass and is honest about why prose quality can't be automated.
|
|
||||||
- **Disabled state is handled at the cursor level.** `clide_button.dart:41` switches to
|
|
||||||
`SystemMouseCursors.forbidden` and drops the semantic `onTap` when `onPressed == null`.
|
|
||||||
|
|
||||||
### Findings
|
|
||||||
|
|
||||||
- **[Critical] `ClideTappable` is mouse-only — no `Focus`, no keyboard activation.**
|
|
||||||
`lib/widgets/src/clide_tappable.dart:37-54` is `MouseRegion` + `GestureDetector` only.
|
|
||||||
It is the base for `ClideButton`, `_WinBtn`, `_RecentProjectRow`, `_ActionRow`, and
|
|
||||||
most builtin list items. None can receive Tab focus or be activated with Enter/Space.
|
|
||||||
The keyboard-traversal test only passes because it manually wraps the button in an
|
|
||||||
external `Focus` node — it tests that the widget doesn't *block* focus, not that it
|
|
||||||
*accepts* it.
|
|
||||||
- **[Critical] Command palette is not keyboard-navigable.** `clide_palette.dart` —
|
|
||||||
`onSubmitted` only ever invokes `filtered.first` (`:77-80`); no up/down handling, no
|
|
||||||
selected index, no selection highlight, no Escape handler.
|
|
||||||
- **[Major] No focus-traversal wiring between panels.** `FocusTracker`
|
|
||||||
(`lib/kernel/src/focus.dart`) tracks an active *contribution id* for the `clide active`
|
|
||||||
CLI, but is not Flutter `FocusScope`/`FocusTraversalGroup` integration. Nothing
|
|
||||||
establishes Tab order across sidebar → workspace → context.
|
|
||||||
- **[Major] Drag-resize handles have no keyboard equivalent — parity gap.**
|
|
||||||
`drag_resize.dart` and `app.dart:870-912` are pure `Listener` pointer handlers, with no
|
|
||||||
Semantics node at all. Per "User/Claude parity", panel sizing should have a CLI
|
|
||||||
affordance; none is evident.
|
|
||||||
- **[Major] Single global `KeyboardListener` is a fragile keybinding architecture.**
|
|
||||||
`app.dart:90-148` routes all shortcuts through one root `KeyboardListener` — no
|
|
||||||
per-context scoping, will conflict with text-input fields.
|
|
||||||
`KeybindingResolver.fromKeyEvent` keys off layout-dependent `logicalKey.keyLabel`.
|
|
||||||
- **[Major] Text scale is the *only* in-app a11y accommodation, and it's hidden.**
|
|
||||||
`app.dart:122-138` implements Ctrl +/-/0 text scaling but it's undiscoverable. No
|
|
||||||
high-contrast toggle, no reduced-motion handling, no focus-ring rendering anywhere.
|
|
||||||
- **[Minor] Contrast gate covers only 11 token pairs** — omits `globalTextMuted` (muted
|
|
||||||
text is everywhere), the `status*` foregrounds, syntax tokens on `panelBackground`, and
|
|
||||||
`panelActiveBorder`.
|
|
||||||
- **[Minor] ~43 hardcoded-color sites bypass the token system** — some defensible (ANSI
|
|
||||||
palette), but the modal/palette shadow and window-control colors won't adapt to the
|
|
||||||
`paper` light theme.
|
|
||||||
- **[Minor] Hover state is inconsistent and not paired with focus** — every interactive
|
|
||||||
widget reimplements its own `_hover` bool; none render a focus indicator.
|
|
||||||
- **[Minor] `_LeftHatContent` is dead code** — `app.dart:281-292` always returns
|
|
||||||
`SizedBox.shrink()`.
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
|
|
||||||
**Quick wins:** add a `Focus` + `Actions`/`Shortcuts` (Enter/Space → onTap) wrapper and
|
|
||||||
a focus-ring inside `ClideTappable` — fixes the [Critical] for every button/list-item at
|
|
||||||
once; add arrow-key + Escape + selected-index to `ClidePalette` (copy the existing
|
|
||||||
`_ProjectSwitcherDropdown` `onKeyEvent` pattern at `app.dart:446-452`); expand
|
|
||||||
`canonicalPairs`; surface text-zoom and theme switching in the palette; tokenize the
|
|
||||||
modal shadow and window-control colors.
|
|
||||||
|
|
||||||
**Larger efforts:** establish a real focus-traversal model and integrate `FocusTracker`
|
|
||||||
with Flutter's focus system; replace the root `KeyboardListener` with scoped
|
|
||||||
`Shortcuts`/`Actions` and move off `keyLabel`; add keyboard operability + Semantics to
|
|
||||||
drag-resize handles plus a `clide panel resize` CLI; add an a11y test tier that asserts
|
|
||||||
*operability*, not just Semantics presence.
|
|
||||||
|
|
||||||
### Scorecard
|
|
||||||
|
|
||||||
| Dimension | Score | Justification |
|
|
||||||
|---|---|---|
|
|
||||||
| Interaction model | 2/5 | Coherent slot/panel structure and good drag-resize *with a mouse*, but keyboard operability is largely unbuilt. |
|
|
||||||
| Accessibility | 3/5 | Genuine contrast gate, Semantics on composed widgets, i18n contract tests — but keyboard operability and focus order are not implemented. |
|
|
||||||
| Visual consistency | 4/5 | Strong semantic token system consumed consistently; a few hardcoded-color sites are real theme-adaptation bugs. |
|
|
||||||
| Discoverability | 2/5 | Command palette isn't keyboard-navigable; accommodations are undiscoverable; no in-app keybinding reference. |
|
|
||||||
| State coverage | 3/5 | Data panels and dialogs handle loading/error/empty; but no focus states anywhere and no reduced-motion handling. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Code quality & craft — Senior Dart/Flutter Engineer
|
|
||||||
|
|
||||||
### Executive summary
|
|
||||||
|
|
||||||
clide is, for a solo pre-v2.0 project, in genuinely good shape. The core subsystems (IPC
|
|
||||||
envelope, daemon dispatch, git client, PTY) are small, single-responsibility,
|
|
||||||
well-typed, and consistent. `flutter analyze` is clean for `lib/` — the 9 reported issues
|
|
||||||
are all in `test/`, none are suppressions. The biggest strength is the FFI/PTY layer:
|
|
||||||
`lib/src/pty/native_pty.dart` shows real systems-programming discipline (pre-fork
|
|
||||||
allocation, errno captured before `free`, isolate-teardown ordering documented and
|
|
||||||
correct). The biggest risk is concentrated in two places: a genuine isolate-leak race in
|
|
||||||
`SchedulerService`, and the large vendored-but-owned `lib/src/terminal/` xterm.dart fork
|
|
||||||
(~7k LOC) which carries a different style, commented-out `print`s, and dangling TODOs
|
|
||||||
that the project's own "lib is owned, not vendored" rule says must be held to the same
|
|
||||||
bar.
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
- **PTY/FFI layer is excellent.** `native_pty.dart:129-145` force-resolves FFI
|
|
||||||
trampolines and pre-allocates *all* native memory before `forkpty()`.
|
|
||||||
`native_pty.dart:171-177` captures `errno` before `_freeAll` because `free()` can
|
|
||||||
clobber it. `close()` (367-397) documents and implements the kill→EOF→close ordering
|
|
||||||
to avoid fd-reuse races. The child branch touches no Dart heap.
|
|
||||||
- **IPC envelope is clean and idiomatic** — `lib/src/ipc/envelope.dart` uses a `sealed`
|
|
||||||
class hierarchy, named constructors, a private unifying constructor, and conditional
|
|
||||||
map keys. Decode is total over the type discriminant.
|
|
||||||
- **Typed, meaningful errors.** `PtyException` carries `op` + optional `errno`;
|
|
||||||
`GitException` carries `stderr`; `errnoToIpcError` maps POSIX errno to actionable IPC
|
|
||||||
error kinds. Errors are values, not strings.
|
|
||||||
- **Resource lifecycle is taken seriously across most subsystems.** `FileWatcher.stop()`
|
|
||||||
cancels the subscription *and* closes the controller; `withBuffer`/`setWinsize` in
|
|
||||||
`libc.dart` use `try/finally` around every native allocation. 45 files define
|
|
||||||
`dispose`/`close`.
|
|
||||||
- **The `DaemonEventSink` interface** keeps the dependency graph pointing the right way
|
|
||||||
(server→subsystems) and is documented as such.
|
|
||||||
- **The one `ignore_for_file` (`libc.dart:11-27`) is exemplary** — textbook FFI case,
|
|
||||||
multi-paragraph justification exactly as CLAUDE.md requires.
|
|
||||||
|
|
||||||
### Findings
|
|
||||||
|
|
||||||
- **[Major] Isolate-leak race in `SchedulerService._startTicker`** — `scheduler.dart:71`:
|
|
||||||
`Isolate.spawn(...).then((iso) => _isolate = iso)`. If `_stopTicker()` runs before the
|
|
||||||
spawn future completes, `_isolate` is still null, nothing is killed, and the
|
|
||||||
just-spawned isolate (with its `Timer.periodic`) leaks. `native_pty.dart` solved
|
|
||||||
exactly this with `_readerReady`.
|
|
||||||
- **[Major] `lib/src/terminal/` held below the project's own bar.** Carries
|
|
||||||
commented-out `print()` debugging (`custom_text_edit.dart:244-275`), dangling TODOs
|
|
||||||
(`parser.dart:110-113`, `keytab.dart:91`), a 1137-line `parser.dart`, and the only
|
|
||||||
`// ignore: invalid_use_of_protected_member` in the repo (`terminal_view.dart:363`).
|
|
||||||
Either it's genuinely vendored (belongs in `native/` or documented as frozen) or it's
|
|
||||||
owned (needs the cleanup pass).
|
|
||||||
- **[Minor] Empty `catch (_) {}` swallows in `tree_sitter_ffi.dart:197,206`** —
|
|
||||||
`DynamicLibrary.open` failures silently discarded; caller gets a bare `null` with no
|
|
||||||
diagnostic about *why*. Syntax highlighting silently not working is a support
|
|
||||||
headache.
|
|
||||||
- **[Minor] Empty `catch (_) {}` in `test_app.dart:271,311`** — `:271` swallows a
|
|
||||||
theme-load failure the harness exists to detect.
|
|
||||||
- **[Minor] Dead alias in `libc.dart:201-202`** — `typedef Cmsghdr = CmsghdrLinux;`
|
|
||||||
flagged "backward compatibility"; CLAUDE.md forbids backwards-compat hacks in a solo
|
|
||||||
repo.
|
|
||||||
- **[Minor] `// ignore: unused_field` in `editor_controller.dart:25`** "kept for future
|
|
||||||
subscription changes" — speculative retention; the no-suppression rule wants it fixed,
|
|
||||||
not silenced.
|
|
||||||
- **[Minor] Magic numbers in hot FFI paths.** `native_pty.dart` inlines `0x0001
|
|
||||||
// POLLIN`, `28 /* SIGWINCH */`, `4 /* EINTR */`, `9 /* EBADF */` — but `libc.dart`
|
|
||||||
already has a constants section and `errno_mapping.dart` has `PosixErrno.ebadf`.
|
|
||||||
- **[Minor] `git_commands.dart` has ~16 near-identical handler bodies** — a
|
|
||||||
`_guarded(req, () async {...})` helper would remove ~60 lines of structural
|
|
||||||
duplication. Borderline.
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
|
|
||||||
**Quick wins:** fix the `SchedulerService` spawn race (track the spawn future like
|
|
||||||
`NativePty._readerReady`); replace the three silent `catch (_)` in `tree_sitter_ffi.dart`
|
|
||||||
with a logged last-error; delete the `Cmsghdr` alias and the `unused_field` suppression;
|
|
||||||
have the PTY layer consume `libc.dart` constants / `PosixErrno` instead of inline hex.
|
|
||||||
|
|
||||||
**Larger efforts:** decide the status of `lib/src/terminal/` — formally vendor it
|
|
||||||
(freeze, document, decision-record) or do the cleanup sweep; optionally a `_guarded`
|
|
||||||
helper for `git_commands.dart` (check whether `files_commands` / `editor_commands` share
|
|
||||||
the shape).
|
|
||||||
|
|
||||||
### Scorecard
|
|
||||||
|
|
||||||
| Dimension | Score | Justification |
|
|
||||||
|---|---|---|
|
|
||||||
| Idiomatic Dart | 4/5 | Sealed classes, named ctors, records, `const`, immutability used well; the vendored terminal tree pulls the average down. |
|
|
||||||
| Error handling | 4/5 | Typed errors with context everywhere in core; a few silent `catch (_)` in the FFI loader and test harness cost the 5th point. |
|
|
||||||
| Naming & readability | 4/5 | Clear, intention-revealing names; comments earn their place; inline magic numbers are the main blemish. |
|
|
||||||
| Consistency across subsystems | 3/5 | IPC/git/files/pty are uniform; `lib/src/terminal/` is a different codebase in style; PTY duplicates constants `libc.dart` owns. |
|
|
||||||
| Resource/lifecycle safety | 4/5 | `try/finally` around native allocs, controllers closed, subscriptions cancelled; the one real defect is the `SchedulerService` race. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Security & supply chain — Security Engineer
|
|
||||||
|
|
||||||
### Executive summary
|
|
||||||
|
|
||||||
clide's security posture is **above average for a solo pre-v2.0 project**. All
|
|
||||||
subprocess calls use `Process.run`/`Process.start` with argument *lists* (no shell
|
|
||||||
interpolation), the IPC transport is a per-user Unix socket (not a TCP port), and there
|
|
||||||
is an explicit `path_safety` module with a containment check. The single biggest strength
|
|
||||||
is the disciplined no-shell subprocess layer. The single biggest risk is
|
|
||||||
**untrusted-workspace code execution via toolchain resolution**
|
|
||||||
(`toolchain_paths.dart:79`): a malicious repo can ship a `native/dugite/bin/git`
|
|
||||||
executable that clide will resolve and run. Secondary real issues: path-safety does not
|
|
||||||
defend against symlink escape, and IPC command args are largely unvalidated/un-bounded.
|
|
||||||
Supply-chain hygiene is mostly good but `licenses.yaml` has drifted from `pubspec.yaml`
|
|
||||||
and native binaries are committed without SHA pinning.
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
- **No-shell subprocess execution.** `GitClient._run` (`client.dart:210`),
|
|
||||||
`PqlClient._run` (`client.dart:165`), and the PTY layer all pass `List<String>` args
|
|
||||||
directly. Classic command injection is structurally prevented.
|
|
||||||
- **Toolchain uses resolved absolute paths** — git/pql/tmux resolved once to absolute
|
|
||||||
paths and reused.
|
|
||||||
- **Path containment check exists and is used.** `resolveUnderRoot`
|
|
||||||
(`path_safety.dart:21`) collapses `..`/`.` without touching the filesystem and enforces
|
|
||||||
a prefix check with a separator guard. `files.read`/`files.ls` both call it.
|
|
||||||
- **IPC is a per-user Unix socket, not a network listener.** No `ServerSocket` over TCP
|
|
||||||
anywhere; the default runtime path is in-process, eliminating the socket attack
|
|
||||||
surface in the shipped app.
|
|
||||||
- **PTY FFI memory discipline** — all native memory allocated before `forkpty()`, `errno`
|
|
||||||
captured before `free()`, freed on every path.
|
|
||||||
- **`pubspec.lock` is committed**, deps use exact pins (no carets), `licenses.yaml`
|
|
||||||
exists with per-dep purpose/license.
|
|
||||||
|
|
||||||
### Findings
|
|
||||||
|
|
||||||
- **[Critical] Malicious workspace can plant a git binary that clide executes.**
|
|
||||||
`toolchain_paths.dart:79-84` builds `'$workspaceRoot/native/dugite/bin'` and runs
|
|
||||||
`_firstExisting(['$dugite/git'])`; if that file exists it becomes the git binary for
|
|
||||||
all `GitClient` calls, **before** falling back to PATH. An attacker commits an
|
|
||||||
executable at `native/dugite/bin/git`; clide runs it on the first `git.status` (which
|
|
||||||
fires automatically on workspace open). Arbitrary code execution from merely opening a
|
|
||||||
repo. The `native/dugite` convention should resolve relative to the *clide install
|
|
||||||
dir*, never the workspace root.
|
|
||||||
- **[Major] Path-safety does not defend against symlink escape.** `path_safety.dart:
|
|
||||||
35-51` explicitly does not resolve symlinks, and the filesystem layer
|
|
||||||
(`files_commands.dart:81-85`) never does either. A repo symlink `config -> /etc/shadow`
|
|
||||||
passes the containment check (the *link path* is under root) and clide reads the
|
|
||||||
target. Fix: after `resolveUnderRoot`, `resolveSymbolicLinksSync()` and re-verify
|
|
||||||
containment.
|
|
||||||
- **[Major] IPC command arguments are unvalidated and unbounded.**
|
|
||||||
`DaemonDispatcher.dispatch` (`dispatcher.dart:26`) and `IpcRequest.fromJson`
|
|
||||||
(`envelope.dart:49`) do no schema validation. No size limit on `files.read`, no count
|
|
||||||
cap on `git.log`, no check that `git.checkout`'s `branch` (`git_commands.dart:240`)
|
|
||||||
isn't a `-`-prefixed flag. `git diff`/`stage` use `--` separators (good), but
|
|
||||||
`checkout(branch)` and `push(remote, branch)` do not — argument injection
|
|
||||||
(`git checkout --upload-pack=...`) is possible.
|
|
||||||
- **[Minor] macOS entitlements disable library validation.**
|
|
||||||
`macos/Runner/Release.entitlements` sets `disable-library-validation` = true with no
|
|
||||||
App Sandbox entitlement. Arguably needed for the `dlopen` of `libtree-sitter.so`, but
|
|
||||||
combined with no sandbox a compromised process has full user-level filesystem access.
|
|
||||||
- **[Minor] `licenses.yaml` has drifted from `pubspec.yaml`.** Lists dev-dep `test` at
|
|
||||||
`1.25.8` but `pubspec.yaml:60` pins `1.30.0`; lists a `lints 5.0.0` not in
|
|
||||||
`pubspec.yaml` at all. The two-step-commit guardrail is being violated.
|
|
||||||
- **[Minor] Native binaries committed without SHA pinning.** `native/linux-x64/` has
|
|
||||||
`libtree-sitter.so` (24 MB) and `ptyc` (22 KB) committed with no `SHA256SUMS` manifest.
|
|
||||||
CLAUDE.md says native deps are "pinned by SHA"; that pinning is not evidenced.
|
|
||||||
- **[Informational] No secrets service** — clide stores no tokens; git auth is delegated
|
|
||||||
to the system credential helper. The right call; noted so the absence reads as
|
|
||||||
deliberate.
|
|
||||||
- **[Informational] Lua runtime is a stub** — `lib/lua/src/host.dart` is Tier-0. Design
|
|
||||||
intent (strip `io`/`os.execute`/`package.loadlib`/`debug`) is sound; re-assess at Tier
|
|
||||||
6 — sandbox-escape via FFI re-entry will be the concern.
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
|
|
||||||
**Quick wins:** fix toolchain resolution to resolve `native/dugite` against
|
|
||||||
`Platform.resolvedExecutable`'s directory, never `workspaceRoot` (closes the Critical);
|
|
||||||
add symlink re-check in `files.read`/`files.ls`; reconcile `licenses.yaml` with
|
|
||||||
`pubspec.yaml`; reject `-`-prefixed values for `branch`/`remote`/`path` args (or use
|
|
||||||
`--` everywhere, including `checkout`).
|
|
||||||
|
|
||||||
**Larger efforts:** schema-validate the IPC surface with typed arg schemas + size/count
|
|
||||||
bounds; add a committed `native/SHA256SUMS` verified by `make` and CI; revisit macOS
|
|
||||||
sandboxing (App Sandbox with explicit exceptions); security-review the Lua FFI boundary
|
|
||||||
and capability table before Tier 6 ships.
|
|
||||||
|
|
||||||
### Scorecard
|
|
||||||
|
|
||||||
| Area | Rating | Justification |
|
|
||||||
|---|---|---|
|
|
||||||
| Subprocess safety | 2/5 | No-shell arg lists are excellent, but the workspace-relative dugite path is a real RCE; argument-injection on `checkout`/`push` unmitigated. |
|
|
||||||
| IPC input validation | 3/5 | Per-user Unix socket + in-process default sharply limits exposure, but zero arg-schema validation and no size/count bounds. |
|
|
||||||
| Path/filesystem safety | 3/5 | Real containment check that's actually wired in, undermined by the unhandled symlink-escape gap. |
|
|
||||||
| Dependency/supply-chain hygiene | 3/5 | Exact pins, committed lockfile, documented deps — but `licenses.yaml` drift and missing SHA manifest for committed native binaries. |
|
|
||||||
| Secrets & sandboxing | 3/5 | Correctly delegates secrets; Lua sandbox is only a stub; macOS runs with library validation off and no App Sandbox. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Docs, governance & DX — TPM / Developer-Experience Consultant
|
|
||||||
|
|
||||||
### Executive summary
|
|
||||||
|
|
||||||
clide runs an unusually disciplined governance system for a solo-dev pre-v2.0 project:
|
|
||||||
67 decision records across six domains, with a parser-validated DQR structure, anchored
|
|
||||||
cross-references, and a `make decisions-validate` gate wired into pre-push. The biggest
|
|
||||||
strength is that the DQR system is genuinely *alive* — questions get resolved with dated
|
|
||||||
amendments, superseded decisions are marked, and decisions cite the commits that
|
|
||||||
implement them. The biggest risk is **documentation drift in the narrative docs**:
|
|
||||||
`README.md` and `docs/initial-plan.md` describe an architecture (Go sidecar, `ptyc/` C
|
|
||||||
helper, `app/` subdirectory, separate daemon) that three major decisions (D-5, D-56, the
|
|
||||||
FFI pivot) have since dissolved. A new contributor reading the README first would build
|
|
||||||
a wrong mental model.
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
|
|
||||||
- **DQR system is maintained, not ornamental.** Resolved questions carry dated
|
|
||||||
resolution lines pointing to the deciding D-record (`questions/architecture.md:39`
|
|
||||||
Q-6→D-57). D-40 carries a `[SUPERSEDED]` tag and an amendment line.
|
|
||||||
- **Decisions are linked to code and commits.** D-67 (`decisions/process.md:61`) cites
|
|
||||||
implementing commits `01a99ed`, `d162ba2`. D-66 references `ci/test.sh` by path.
|
|
||||||
- **Governance migration was done cleanly** — the `decisions/` → `governance/`
|
|
||||||
restructure updated cross-references and the auto-generated index.
|
|
||||||
- **Commit discipline is real.** `git log` shows imperative subjects, no Conventional
|
|
||||||
Commits prefixes, ticket refs, logical scoping — exactly what `git-commit/SKILL.md`
|
|
||||||
prescribes.
|
|
||||||
- **`licenses.yaml` is thorough** — all six runtime Dart deps present, plus fonts/native
|
|
||||||
libs, with purpose justifications. *(Note: the Security reviewer found version drift
|
|
||||||
in this file — see Finding above; the two reviewers examined different rows.)*
|
|
||||||
- **Makefile is self-documenting** (`##` help annotations) and matches `CLAUDE.md`.
|
|
||||||
|
|
||||||
### Findings
|
|
||||||
|
|
||||||
- **[Critical] `docs/initial-plan.md` is badly stale.** The "north-star" doc (linked
|
|
||||||
from `CLAUDE.md:14` and `README.md:44`) still describes a Go sidecar
|
|
||||||
(`initial-plan.md:4,55,189`), `clide --daemon` long-running process (`:162-164`),
|
|
||||||
`app/` subdirectory layout (`:184-204`), and `project.yaml` (`:172`) — all contradicted
|
|
||||||
by D-5, D-56, and the single-package-at-root reality. Nothing flags it as historical.
|
|
||||||
- **[Critical] `README.md` describes a dissolved architecture.** `README.md:10`
|
|
||||||
documents `ptyc/` as a live component; `README.md:36` lists `make ptyc-build`. The
|
|
||||||
`ptyc/` directory does not exist, the Makefile has no such target, and the CHANGELOG's
|
|
||||||
own Unreleased section records ptyc's removal.
|
|
||||||
- **[Major] `README.md:44` links to `decisions/`** — a directory that no longer exists
|
|
||||||
(migrated to `governance/`). Dead link in the primary onboarding doc.
|
|
||||||
- **[Major] CHANGELOG has duplicate subsection headings in `[Unreleased]`.** Three
|
|
||||||
`### Changed` blocks (`CHANGELOG.md:100, 114, 168`), two `### Fixed`, two `### Removed`
|
|
||||||
in the 2.0.0 section. Keep a Changelog 1.1.0 expects one of each per release.
|
|
||||||
- **[Major] No `CONTRIBUTING.md` or onboarding doc.** For a project "intended to ship
|
|
||||||
publicly to other developers," there is no contributor guide; the build/test story is
|
|
||||||
scattered across `CLAUDE.md` (Claude-oriented), `README.md` (partly wrong), and
|
|
||||||
Makefile help.
|
|
||||||
- **[Major] Coverage-floor governance contradicts itself.** D-66 (`testing.md:65`) says
|
|
||||||
the floor lives at `coverage/floor.txt` starting "≈35%"; `CHANGELOG.md:44-46` says it's
|
|
||||||
in `pubspec.yaml` `coverage_floor:` starting at 34%; the latest commit is `9030e56
|
|
||||||
hold coverage_floor fixed at 90`. Three sources, three mechanisms/values. D-66 was
|
|
||||||
never amended.
|
|
||||||
- **[Minor] `ci/release.sh` is a stub that still references goreleaser/sidecar** — Go
|
|
||||||
tooling for a project with no Go.
|
|
||||||
- **[Minor] CHANGELOG `[2.0.0] — 2026-05-03` dating** — the v2.0.0 tag is dated
|
|
||||||
2026-05-03 but the enormous Unreleased section represents ~80 commits of post-tag work
|
|
||||||
with no interim version.
|
|
||||||
- **[Minor] Stale-ish open questions** — Q-25 (body text face) is de facto resolved by
|
|
||||||
D-43/D-44 and the shipped impl; Q-1/Q-2/Q-3 ("defer until Tier 1 is in real use") are
|
|
||||||
due for triage now that Tier 1 has shipped.
|
|
||||||
- **[Minor] Skills system is coherent but undocumented as a set** — eight skills under
|
|
||||||
`.claude/skills/`, no index.
|
|
||||||
|
|
||||||
### Recommendations
|
|
||||||
|
|
||||||
**Quick wins:** rewrite `README.md`'s `ptyc/` sections and fix the `decisions/` link;
|
|
||||||
banner `docs/initial-plan.md` as historical (or split out a current
|
|
||||||
`docs/architecture.md`); merge the duplicate changelog subsection headings; amend D-66 to
|
|
||||||
reflect the floor's actual location/mechanism/value with a dated amendment line; triage
|
|
||||||
Q-1/2/3/25.
|
|
||||||
|
|
||||||
**Larger efforts:** write a human-facing `CONTRIBUTING.md` (clone →
|
|
||||||
`make hooks && flutter pub get` → `make test` → DQR workflow → commit conventions); cut
|
|
||||||
an interim release to drain the ~80-commit Unreleased backlog; add a
|
|
||||||
`.claude/skills/README.md` inventory; establish a periodic governance sweep (the repo
|
|
||||||
even has a `clean-house` skill for exactly this).
|
|
||||||
|
|
||||||
### Scorecard
|
|
||||||
|
|
||||||
| Area | Score | Justification |
|
|
||||||
|---|---|---|
|
|
||||||
| Governance discipline | 4/5 | DQR system genuinely maintained — but D-66 drift and untriaged Tier-1-era questions show the sweep cadence lags the code. |
|
|
||||||
| Documentation accuracy | 2/5 | Both primary onboarding docs describe a dissolved Go-sidecar/ptyc/daemon architecture; `CLAUDE.md` is accurate by contrast. |
|
|
||||||
| Changelog hygiene | 3/5 | Per-commit discipline is followed, but duplicate subsection headings violate the standard and an 80-commit Unreleased backlog undermines the format. |
|
|
||||||
| Contributor onboarding | 2/5 | No `CONTRIBUTING.md`; build story split across three docs, one wrong; `CLAUDE.md` is Claude-addressed, not human-addressed. |
|
|
||||||
| Convention adherence | 4/5 | Commit style, DQR claiming, `licenses.yaml` two-step rule demonstrably followed; docked for the changelog defects and the README gap. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Closing note
|
|
||||||
|
|
||||||
The recurring pattern across all six reviews: **clide's foundations are excellent and
|
|
||||||
its finishing is incomplete.** The extension contract, test helpers, FFI discipline,
|
|
||||||
governance system, and token system are all things most projects never get right. The
|
|
||||||
gaps — IPC not wired, keyboard not operable, docs describing a dead architecture, a
|
|
||||||
workspace-relative binary path — are all the kind of thing that happens when a fast-moving
|
|
||||||
solo project's implementation outruns its connective tissue. They are concentrated, not
|
|
||||||
diffuse, and the quick-win column above would close most of the critical ones in a few
|
|
||||||
focused days.
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# dartdoc configuration.
|
||||||
|
#
|
||||||
|
# clide is an application, not a published library — its lib/ API docs are not
|
||||||
|
# a consumed surface. The dart-doc CI gate (.github/workflows/test.yml) exists
|
||||||
|
# to catch real doc-comment defects: unresolved [symbol] references. That check
|
||||||
|
# stays strict (unresolved-doc-reference is NOT ignored).
|
||||||
|
#
|
||||||
|
# The "broken-link" category, by contrast, is pure rendering noise here:
|
||||||
|
# - The README is dartdoc's landing page; its repo-relative links
|
||||||
|
# ([CLAUDE.md], [docs/…], [LICENSE]) are correct on GitHub but don't resolve
|
||||||
|
# once copied into the generated API site.
|
||||||
|
# - The generated Phosphor icon font (lib/widgets/src/icons/phosphor_glyphs.g.dart,
|
||||||
|
# 1512 glyphs) produces broken cross-links to its own library page.
|
||||||
|
# Neither is a fixable doc-quality problem, so the category is ignored.
|
||||||
|
dartdoc:
|
||||||
|
ignore:
|
||||||
|
- broken-link
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"name": "Settings — Appearance",
|
||||||
|
"shapes": {
|
||||||
|
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
|
||||||
|
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||||
|
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||||
|
|
||||||
|
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
|
||||||
|
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
|
||||||
|
|
||||||
|
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||||
|
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
|
||||||
|
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
|
||||||
|
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
|
||||||
|
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
|
||||||
|
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
|
||||||
|
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
|
||||||
|
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
|
||||||
|
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-appearance-sel": { "type": "Rectangle", "left": 130, "top": 228, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
|
||||||
|
"cat-appearance-stripe": { "type": "Rectangle", "left": 130, "top": 228, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||||
|
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
|
||||||
|
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Appearance", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||||
|
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Theme, contrast, and text size.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-theme": { "type": "Text", "left": 382, "top": 186, "text": "THEME", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"theme-scope-body": { "type": "Rectangle", "left": 1004, "top": 188, "width": 15, "height": 10, "fillColor": "#20202C", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||||
|
"theme-scope-tab": { "type": "Rectangle", "left": 1004, "top": 185, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||||
|
|
||||||
|
"s1-card": { "type": "Rectangle", "left": 382, "top": 210, "width": 200, "height": 92, "fillColor": "#20202C", "strokeColor": "#78A0F8", "corners": [6, 6, 6, 6] },
|
||||||
|
"s1-side": { "type": "Rectangle", "left": 390, "top": 218, "width": 22, "height": 76, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [3, 3, 3, 3] },
|
||||||
|
"s1-bar": { "type": "Rectangle", "left": 418, "top": 218, "width": 156, "height": 14, "fillColor": "#242838", "strokeColor": "#242838", "corners": [3, 3, 3, 3] },
|
||||||
|
"s1-accent": { "type": "Rectangle", "left": 418, "top": 240, "width": 40, "height": 4, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||||
|
"s1-l1": { "type": "Rectangle", "left": 418, "top": 254, "width": 120, "height": 4, "fillColor": "#E6E8F2", "strokeColor": "#E6E8F2" },
|
||||||
|
"s1-l2": { "type": "Rectangle", "left": 418, "top": 266, "width": 90, "height": 4, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
|
||||||
|
"s1-l3": { "type": "Rectangle", "left": 418, "top": 278, "width": 110, "height": 4, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
|
||||||
|
"s1-check-bg": { "type": "Ellipse", "left": 558, "top": 218, "width": 16, "height": 16, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||||
|
"s1-check": { "type": "Text", "left": 561, "top": 219, "text": "✓", "fontColor": "#20202C", "fontSize": 11 },
|
||||||
|
"s1-name": { "type": "Text", "left": 390, "top": 306, "text": "clide", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
|
||||||
|
"s2-card": { "type": "Rectangle", "left": 598, "top": 210, "width": 200, "height": 92, "fillColor": "#1E1E1E", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"s2-side": { "type": "Rectangle", "left": 606, "top": 218, "width": 22, "height": 76, "fillColor": "#181818", "strokeColor": "#181818", "corners": [3, 3, 3, 3] },
|
||||||
|
"s2-bar": { "type": "Rectangle", "left": 634, "top": 218, "width": 156, "height": 14, "fillColor": "#252526", "strokeColor": "#252526", "corners": [3, 3, 3, 3] },
|
||||||
|
"s2-accent": { "type": "Rectangle", "left": 634, "top": 240, "width": 40, "height": 4, "fillColor": "#569CD6", "strokeColor": "#569CD6" },
|
||||||
|
"s2-l1": { "type": "Rectangle", "left": 634, "top": 254, "width": 120, "height": 4, "fillColor": "#D4D4D4", "strokeColor": "#D4D4D4" },
|
||||||
|
"s2-l2": { "type": "Rectangle", "left": 634, "top": 266, "width": 90, "height": 4, "fillColor": "#6A6A6A", "strokeColor": "#6A6A6A" },
|
||||||
|
"s2-l3": { "type": "Rectangle", "left": 634, "top": 278, "width": 110, "height": 4, "fillColor": "#6A6A6A", "strokeColor": "#6A6A6A" },
|
||||||
|
"s2-name": { "type": "Text", "left": 606, "top": 306, "text": "midnight", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||||
|
|
||||||
|
"s3-card": { "type": "Rectangle", "left": 814, "top": 210, "width": 200, "height": 92, "fillColor": "#F4F1EA", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"s3-side": { "type": "Rectangle", "left": 822, "top": 218, "width": 22, "height": 76, "fillColor": "#ECE7DB", "strokeColor": "#ECE7DB", "corners": [3, 3, 3, 3] },
|
||||||
|
"s3-bar": { "type": "Rectangle", "left": 850, "top": 218, "width": 156, "height": 14, "fillColor": "#FBF8F1", "strokeColor": "#FBF8F1", "corners": [3, 3, 3, 3] },
|
||||||
|
"s3-accent": { "type": "Rectangle", "left": 850, "top": 240, "width": 40, "height": 4, "fillColor": "#C14B2A", "strokeColor": "#C14B2A" },
|
||||||
|
"s3-l1": { "type": "Rectangle", "left": 850, "top": 254, "width": 120, "height": 4, "fillColor": "#1A1A1A", "strokeColor": "#1A1A1A" },
|
||||||
|
"s3-l2": { "type": "Rectangle", "left": 850, "top": 266, "width": 90, "height": 4, "fillColor": "#7A7468", "strokeColor": "#7A7468" },
|
||||||
|
"s3-l3": { "type": "Rectangle", "left": 850, "top": 278, "width": 110, "height": 4, "fillColor": "#7A7468", "strokeColor": "#7A7468" },
|
||||||
|
"s3-name": { "type": "Text", "left": 822, "top": 306, "text": "paper", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||||
|
|
||||||
|
"s4-card": { "type": "Rectangle", "left": 382, "top": 330, "width": 200, "height": 92, "fillColor": "#0C0C0C", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"s4-side": { "type": "Rectangle", "left": 390, "top": 338, "width": 22, "height": 76, "fillColor": "#000000", "strokeColor": "#000000", "corners": [3, 3, 3, 3] },
|
||||||
|
"s4-bar": { "type": "Rectangle", "left": 418, "top": 338, "width": 156, "height": 14, "fillColor": "#141414", "strokeColor": "#141414", "corners": [3, 3, 3, 3] },
|
||||||
|
"s4-accent": { "type": "Rectangle", "left": 418, "top": 360, "width": 40, "height": 4, "fillColor": "#FFC868", "strokeColor": "#FFC868" },
|
||||||
|
"s4-l1": { "type": "Rectangle", "left": 418, "top": 374, "width": 120, "height": 4, "fillColor": "#C8C8C8", "strokeColor": "#C8C8C8" },
|
||||||
|
"s4-l2": { "type": "Rectangle", "left": 418, "top": 386, "width": 90, "height": 4, "fillColor": "#5A5A5A", "strokeColor": "#5A5A5A" },
|
||||||
|
"s4-l3": { "type": "Rectangle", "left": 418, "top": 398, "width": 110, "height": 4, "fillColor": "#5A5A5A", "strokeColor": "#5A5A5A" },
|
||||||
|
"s4-name": { "type": "Text", "left": 390, "top": 426, "text": "terminal", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||||
|
|
||||||
|
"s5-card": { "type": "Rectangle", "left": 598, "top": 330, "width": 200, "height": 92, "fillColor": "#1E1E2E", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"s5-side": { "type": "Rectangle", "left": 606, "top": 338, "width": 22, "height": 76, "fillColor": "#181825", "strokeColor": "#181825", "corners": [3, 3, 3, 3] },
|
||||||
|
"s5-bar": { "type": "Rectangle", "left": 634, "top": 338, "width": 156, "height": 14, "fillColor": "#313244", "strokeColor": "#313244", "corners": [3, 3, 3, 3] },
|
||||||
|
"s5-accent": { "type": "Rectangle", "left": 634, "top": 360, "width": 40, "height": 4, "fillColor": "#CBA6F7", "strokeColor": "#CBA6F7" },
|
||||||
|
"s5-l1": { "type": "Rectangle", "left": 634, "top": 374, "width": 120, "height": 4, "fillColor": "#CDD6F4", "strokeColor": "#CDD6F4" },
|
||||||
|
"s5-l2": { "type": "Rectangle", "left": 634, "top": 386, "width": 90, "height": 4, "fillColor": "#7F849C", "strokeColor": "#7F849C" },
|
||||||
|
"s5-l3": { "type": "Rectangle", "left": 634, "top": 398, "width": 110, "height": 4, "fillColor": "#7F849C", "strokeColor": "#7F849C" },
|
||||||
|
"s5-name": { "type": "Text", "left": 606, "top": 426, "text": "catppuccin-mocha", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||||
|
|
||||||
|
"s6-card": { "type": "Rectangle", "left": 814, "top": 330, "width": 200, "height": 92, "fillColor": "#21262F", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"s6-side": { "type": "Rectangle", "left": 822, "top": 338, "width": 22, "height": 76, "fillColor": "#292E38", "strokeColor": "#292E38", "corners": [3, 3, 3, 3] },
|
||||||
|
"s6-bar": { "type": "Rectangle", "left": 850, "top": 338, "width": 156, "height": 14, "fillColor": "#393E48", "strokeColor": "#393E48", "corners": [3, 3, 3, 3] },
|
||||||
|
"s6-accent": { "type": "Rectangle", "left": 850, "top": 360, "width": 40, "height": 4, "fillColor": "#FA5F8B", "strokeColor": "#FA5F8B" },
|
||||||
|
"s6-l1": { "type": "Rectangle", "left": 850, "top": 374, "width": 120, "height": 4, "fillColor": "#E2E8F5", "strokeColor": "#E2E8F5" },
|
||||||
|
"s6-l2": { "type": "Rectangle", "left": 850, "top": 386, "width": 90, "height": 4, "fillColor": "#7A8296", "strokeColor": "#7A8296" },
|
||||||
|
"s6-l3": { "type": "Rectangle", "left": 850, "top": 398, "width": 110, "height": 4, "fillColor": "#7A8296", "strokeColor": "#7A8296" },
|
||||||
|
"s6-name": { "type": "Text", "left": 822, "top": 426, "text": "summer-night", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-options": { "type": "Text", "left": 382, "top": 462, "text": "OPTIONS", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"card-options": { "type": "Rectangle", "left": 382, "top": 482, "width": 648, "height": 150, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
|
||||||
|
"hc-label": { "type": "Text", "left": 398, "top": 500, "text": "High contrast", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"hc-track": { "type": "Rectangle", "left": 950, "top": 498, "width": 40, "height": 22, "fillColor": "#343850", "strokeColor": "#343850", "corners": [11, 11, 11, 11] },
|
||||||
|
"hc-knob": { "type": "Ellipse", "left": 952, "top": 500, "width": 18, "height": 18, "fillColor": "#8890AC", "strokeColor": "#8890AC" },
|
||||||
|
"hc-scope-circle": { "type": "Ellipse", "left": 1004, "top": 502, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"hc-scope-eq": { "type": "Rectangle", "left": 1004, "top": 508, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"hc-scope-mer": { "type": "Rectangle", "left": 1010, "top": 502, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
|
||||||
|
"ts-label": { "type": "Text", "left": 398, "top": 544, "text": "Text size", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"ts-box": { "type": "Rectangle", "left": 926, "top": 542, "width": 64, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"ts-text": { "type": "Text", "left": 940, "top": 547, "text": "100%", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"ts-scope-circle": { "type": "Ellipse", "left": 1004, "top": 546, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"ts-scope-eq": { "type": "Rectangle", "left": 1004, "top": 552, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"ts-scope-mer": { "type": "Rectangle", "left": 1010, "top": 546, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
|
||||||
|
"ct-label": { "type": "Text", "left": 398, "top": 588, "text": "Custom theme", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"ct-button": { "type": "Rectangle", "left": 846, "top": 588, "width": 144, "height": 28, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [4, 4, 4, 4] },
|
||||||
|
"ct-button-text": { "type": "Text", "left": 858, "top": 595, "text": "Import theme YAML ↗", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||||
|
"ct-scope-circle": { "type": "Ellipse", "left": 1004, "top": 592, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"ct-scope-eq": { "type": "Rectangle", "left": 1004, "top": 598, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"ct-scope-mer": { "type": "Rectangle", "left": 1010, "top": 592, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" }
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"name": "Settings — Claude",
|
||||||
|
"shapes": {
|
||||||
|
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 1250, "fillColor": "#17171E", "strokeColor": "#17171E" },
|
||||||
|
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||||
|
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 1222, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||||
|
|
||||||
|
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 1160, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
|
||||||
|
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 1160, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
|
||||||
|
|
||||||
|
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||||
|
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
|
||||||
|
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
|
||||||
|
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 1114, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
|
||||||
|
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 1114, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
|
||||||
|
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
|
||||||
|
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
|
||||||
|
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
|
||||||
|
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-claude-sel": { "type": "Rectangle", "left": 130, "top": 264, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
|
||||||
|
"cat-claude-stripe": { "type": "Rectangle", "left": 130, "top": 264, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||||
|
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
|
||||||
|
"scrollbar-track": { "type": "Rectangle", "left": 1038, "top": 110, "width": 4, "height": 1104, "fillColor": "#242838", "strokeColor": "#242838", "corners": [2, 2, 2, 2] },
|
||||||
|
"scrollbar-thumb": { "type": "Rectangle", "left": 1038, "top": 110, "width": 4, "height": 250, "fillColor": "#3C445C", "strokeColor": "#3C445C", "corners": [2, 2, 2, 2] },
|
||||||
|
|
||||||
|
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Claude", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||||
|
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Model, effort, permissions, and the workspace's loaded skills, agents & MCP.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-settings": { "type": "Text", "left": 382, "top": 186, "text": "SETTINGS", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"card-settings": { "type": "Rectangle", "left": 382, "top": 206, "width": 648, "height": 166, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"model-label": { "type": "Text", "left": 398, "top": 222, "text": "Model", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"model-select": { "type": "Rectangle", "left": 870, "top": 218, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"model-text": { "type": "Text", "left": 882, "top": 223, "text": "opus-4.8", "fontColor": "#78A0F8", "fontSize": 14 },
|
||||||
|
"model-chevron": { "type": "Text", "left": 974, "top": 223, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"model-scope-circle": { "type": "Ellipse", "left": 1004, "top": 224, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"model-scope-eq": { "type": "Rectangle", "left": 1004, "top": 230, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"model-scope-mer": { "type": "Rectangle", "left": 1010, "top": 224, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
|
||||||
|
"effort-label": { "type": "Text", "left": 398, "top": 254, "text": "Effort", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"effort-select": { "type": "Rectangle", "left": 870, "top": 250, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"effort-text": { "type": "Text", "left": 882, "top": 255, "text": "high", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"effort-chevron": { "type": "Text", "left": 974, "top": 255, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"effort-scope-circle": { "type": "Ellipse", "left": 1004, "top": 256, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"effort-scope-eq": { "type": "Rectangle", "left": 1004, "top": 262, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"effort-scope-mer": { "type": "Rectangle", "left": 1010, "top": 256, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
|
||||||
|
"perm-label": { "type": "Text", "left": 398, "top": 286, "text": "Permission mode", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"perm-select": { "type": "Rectangle", "left": 870, "top": 282, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"perm-text": { "type": "Text", "left": 882, "top": 287, "text": "default", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"perm-chevron": { "type": "Text", "left": 974, "top": 287, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"perm-scope-body": { "type": "Rectangle", "left": 1004, "top": 290, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||||
|
"perm-scope-tab": { "type": "Rectangle", "left": 1004, "top": 287, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||||
|
|
||||||
|
"style-label": { "type": "Text", "left": 398, "top": 318, "text": "Output style", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"style-select": { "type": "Rectangle", "left": 870, "top": 314, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"style-text": { "type": "Text", "left": 882, "top": 319, "text": "default", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"style-chevron": { "type": "Text", "left": 974, "top": 319, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"style-scope-circle": { "type": "Ellipse", "left": 1004, "top": 320, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"style-scope-eq": { "type": "Rectangle", "left": 1004, "top": 326, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"style-scope-mer": { "type": "Rectangle", "left": 1010, "top": 320, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
|
||||||
|
"source-label": { "type": "Text", "left": 398, "top": 350, "text": "Source", "fontColor": "#8890AC", "fontSize": 15 },
|
||||||
|
"source-value": { "type": "Text", "left": 870, "top": 350, "text": "~/.claude · .clide", "fontColor": "#545C84", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-skills": { "type": "Text", "left": 382, "top": 392, "text": "SKILLS", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"grp-skills-n": { "type": "Text", "left": 1014, "top": 392, "text": "6", "fontColor": "#545C84", "fontSize": 12 },
|
||||||
|
"card-skills": { "type": "Rectangle", "left": 382, "top": 410, "width": 648, "height": 150, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"sk1": { "type": "Text", "left": 398, "top": 426, "text": "ui-design", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"sk2": { "type": "Text", "left": 398, "top": 448, "text": "frame0-wireframe", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"sk3": { "type": "Text", "left": 398, "top": 470, "text": "pql", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"sk4": { "type": "Text", "left": 398, "top": 492, "text": "clide", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"sk5": { "type": "Text", "left": 398, "top": 514, "text": "git-commit", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"sk6": { "type": "Text", "left": 398, "top": 536, "text": "testmode", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-agents": { "type": "Text", "left": 382, "top": 580, "text": "AGENTS", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"grp-agents-n": { "type": "Text", "left": 1020, "top": 580, "text": "3", "fontColor": "#545C84", "fontSize": 12 },
|
||||||
|
"card-agents": { "type": "Rectangle", "left": 382, "top": 598, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"ag1": { "type": "Text", "left": 398, "top": 614, "text": "Explore", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"ag2": { "type": "Text", "left": 398, "top": 636, "text": "Plan", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"ag3": { "type": "Text", "left": 398, "top": 658, "text": "general-purpose", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-commands": { "type": "Text", "left": 382, "top": 702, "text": "COMMANDS", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"grp-commands-n": { "type": "Text", "left": 1014, "top": 702, "text": "5", "fontColor": "#545C84", "fontSize": 12 },
|
||||||
|
"card-commands": { "type": "Rectangle", "left": 382, "top": 720, "width": 648, "height": 128, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"cm1": { "type": "Text", "left": 398, "top": 736, "text": "whats-next", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"cm2": { "type": "Text", "left": 398, "top": 758, "text": "code-review", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"cm3": { "type": "Text", "left": 398, "top": 780, "text": "simplify", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"cm4": { "type": "Text", "left": 398, "top": 802, "text": "verify", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
"cm5": { "type": "Text", "left": 398, "top": 824, "text": "run", "fontColor": "#78A0F8", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-hooks": { "type": "Text", "left": 382, "top": 868, "text": "HOOKS", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"grp-hooks-n": { "type": "Text", "left": 1020, "top": 868, "text": "3", "fontColor": "#545C84", "fontSize": 12 },
|
||||||
|
"card-hooks": { "type": "Rectangle", "left": 382, "top": 886, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"hk1-ev": { "type": "Text", "left": 398, "top": 902, "text": "SessionStart", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||||
|
"hk1-cmd": { "type": "Text", "left": 540, "top": 902, "text": "peon-ping ready", "fontColor": "#8890AC", "fontSize": 12 },
|
||||||
|
"hk2-ev": { "type": "Text", "left": 398, "top": 924, "text": "UserPromptSubmit", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||||
|
"hk2-cmd": { "type": "Text", "left": 540, "top": 924, "text": "peon-ping working", "fontColor": "#8890AC", "fontSize": 12 },
|
||||||
|
"hk3-ev": { "type": "Text", "left": 398, "top": 946, "text": "Stop", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||||
|
"hk3-cmd": { "type": "Text", "left": 540, "top": 946, "text": "peon-ping done", "fontColor": "#8890AC", "fontSize": 12 },
|
||||||
|
|
||||||
|
"grp-perms": { "type": "Text", "left": 382, "top": 990, "text": "PERMISSIONS", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"grp-perms-n": { "type": "Text", "left": 1020, "top": 990, "text": "6", "fontColor": "#545C84", "fontSize": 12 },
|
||||||
|
"card-perms": { "type": "Rectangle", "left": 382, "top": 1008, "width": 648, "height": 84, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"pm-allow-k": { "type": "Text", "left": 398, "top": 1024, "text": "allow", "fontColor": "#7DD3A8", "fontSize": 12 },
|
||||||
|
"pm-allow-v": { "type": "Text", "left": 452, "top": 1024, "text": "Bash(git *) · Read · Edit · Write", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||||
|
"pm-ask-k": { "type": "Text", "left": 398, "top": 1046, "text": "ask", "fontColor": "#E6C370", "fontSize": 12 },
|
||||||
|
"pm-ask-v": { "type": "Text", "left": 452, "top": 1046, "text": "Bash(rm *)", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||||
|
"pm-deny-k": { "type": "Text", "left": 398, "top": 1068, "text": "deny", "fontColor": "#E87D7D", "fontSize": 12 },
|
||||||
|
"pm-deny-v": { "type": "Text", "left": 452, "top": 1068, "text": "Bash(curl *)", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-mcp": { "type": "Text", "left": 382, "top": 1112, "text": "MCP SERVERS", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"grp-mcp-n": { "type": "Text", "left": 1020, "top": 1112, "text": "2", "fontColor": "#545C84", "fontSize": 12 },
|
||||||
|
"card-mcp": { "type": "Rectangle", "left": 382, "top": 1130, "width": 648, "height": 62, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"mcp1": { "type": "Text", "left": 398, "top": 1146, "text": "claude.ai", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||||
|
"mcp2": { "type": "Text", "left": 398, "top": 1168, "text": "context7", "fontColor": "#E6E8F2", "fontSize": 13 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "Settings — Editor",
|
||||||
|
"shapes": {
|
||||||
|
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
|
||||||
|
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||||
|
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||||
|
|
||||||
|
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
|
||||||
|
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
|
||||||
|
|
||||||
|
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||||
|
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
|
||||||
|
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
|
||||||
|
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
|
||||||
|
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
|
||||||
|
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#78809C" },
|
||||||
|
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#78809C", "strokeColor": "#78809C" },
|
||||||
|
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "Search all settings…", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
|
||||||
|
"cat-editor-sel": { "type": "Rectangle", "left": 130, "top": 156, "width": 228, "height": 32, "fillColor": "#2C3046", "strokeColor": "#2C3046" },
|
||||||
|
"cat-editor-stripe": { "type": "Rectangle", "left": 130, "top": 156, "width": 3, "height": 32, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||||
|
"cat-editor": { "type": "Text", "left": 152, "top": 164, "text": "Editor", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"cat-keymap": { "type": "Text", "left": 152, "top": 200, "text": "Keymap", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-appearance": { "type": "Text", "left": 152, "top": 236, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-claude": { "type": "Text", "left": 152, "top": 272, "text": "Claude", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-activity": { "type": "Text", "left": 152, "top": 308, "text": "Activity", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-terminal": { "type": "Text", "left": 152, "top": 344, "text": "Terminal", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-extensions": { "type": "Text", "left": 152, "top": 380, "text": "Extensions", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
|
||||||
|
"panel-title": { "type": "Text", "left": 382, "top": 120, "text": "Editor", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||||
|
"panel-sub": { "type": "Text", "left": 382, "top": 148, "text": "Buffer formatting, indentation, and per-folder overrides.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
|
||||||
|
"grp-fmt": { "type": "Text", "left": 382, "top": 186, "text": "FORMATTING", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"card-fmt": { "type": "Rectangle", "left": 382, "top": 206, "width": 648, "height": 268, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
|
||||||
|
"r1-label": { "type": "Text", "left": 398, "top": 222, "text": "Format on save", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"r1-help": { "type": "Text", "left": 398, "top": 246, "text": "Run the formatter on every buffer save.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"r1-toggle": { "type": "Rectangle", "left": 950, "top": 224, "width": 40, "height": 22, "fillColor": "#78A0F8", "strokeColor": "#78A0F8", "corners": [11, 11, 11, 11] },
|
||||||
|
"r1-knob": { "type": "Ellipse", "left": 970, "top": 226, "width": 18, "height": 18, "fillColor": "#E6E8F2", "strokeColor": "#E6E8F2" },
|
||||||
|
"r1-scope-body": { "type": "Rectangle", "left": 1004, "top": 228, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||||
|
"r1-scope-tab": { "type": "Rectangle", "left": 1004, "top": 225, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||||
|
|
||||||
|
"r2-label": { "type": "Text", "left": 398, "top": 288, "text": "Indent style", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"r2-help": { "type": "Text", "left": 398, "top": 312, "text": "Spaces or tabs for new indentation.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"r2-select": { "type": "Rectangle", "left": 870, "top": 288, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"r2-select-text": { "type": "Text", "left": 882, "top": 293, "text": "Spaces", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"r2-select-chevron": { "type": "Text", "left": 974, "top": 293, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"r2-scope-circle": { "type": "Ellipse", "left": 1004, "top": 292, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"r2-scope-eq": { "type": "Rectangle", "left": 1004, "top": 298, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"r2-scope-mer": { "type": "Rectangle", "left": 1010, "top": 292, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
|
||||||
|
"r3-label": { "type": "Text", "left": 398, "top": 354, "text": "Tab width", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"r3-help": { "type": "Text", "left": 398, "top": 378, "text": "Columns per indent level.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"r3-number": { "type": "Rectangle", "left": 934, "top": 354, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"r3-number-text": { "type": "Text", "left": 956, "top": 359, "text": "2", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"r3-scope-body": { "type": "Rectangle", "left": 1004, "top": 358, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||||
|
"r3-scope-tab": { "type": "Rectangle", "left": 1004, "top": 355, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||||
|
|
||||||
|
"r4-label": { "type": "Text", "left": 398, "top": 420, "text": "Ruler column", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"r4-help": { "type": "Text", "left": 398, "top": 444, "text": "Wrap-guide position. Unset — inherits the default (120).", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"r4-number": { "type": "Rectangle", "left": 934, "top": 420, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"r4-number-text": { "type": "Text", "left": 950, "top": 425, "text": "120", "fontColor": "#545C84", "fontSize": 14 },
|
||||||
|
"r4-scope-circle": { "type": "Ellipse", "left": 1004, "top": 424, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#78809C" },
|
||||||
|
|
||||||
|
"grp-files": { "type": "Text", "left": 382, "top": 494, "text": "FILE OVERRIDES", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"card-files": { "type": "Rectangle", "left": 382, "top": 514, "width": 648, "height": 66, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"r5-label": { "type": "Text", "left": 398, "top": 530, "text": "Project .editorconfig", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"r5-help": { "type": "Text", "left": 398, "top": 554, "text": "Per-folder overrides. Opens the file in the editor.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"r5-button": { "type": "Rectangle", "left": 846, "top": 530, "width": 144, "height": 28, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [4, 4, 4, 4] },
|
||||||
|
"r5-button-text": { "type": "Text", "left": 858, "top": 537, "text": "Open .editorconfig ↗", "fontColor": "#B1BBE3", "fontSize": 13 },
|
||||||
|
"r5-scope-body": { "type": "Rectangle", "left": 1004, "top": 534, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||||
|
"r5-scope-tab": { "type": "Rectangle", "left": 1004, "top": 531, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||||
|
|
||||||
|
"legend-rule": { "type": "Rectangle", "left": 382, "top": 600, "width": 648, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
"legend-title": { "type": "Text", "left": 382, "top": 616, "text": "Scope", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"lg-proj-body": { "type": "Rectangle", "left": 438, "top": 618, "width": 15, "height": 10, "fillColor": "#20202C", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||||
|
"lg-proj-tab": { "type": "Rectangle", "left": 438, "top": 615, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||||
|
"lg-proj-text": { "type": "Text", "left": 462, "top": 614, "text": "folder · Project", "fontColor": "#8890AC", "fontSize": 12 },
|
||||||
|
"lg-always-circle": { "type": "Ellipse", "left": 600, "top": 615, "width": 14, "height": 14, "fillColor": "#20202C", "strokeColor": "#E6C370" },
|
||||||
|
"lg-always-eq": { "type": "Rectangle", "left": 600, "top": 621, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"lg-always-mer": { "type": "Rectangle", "left": 606, "top": 615, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"lg-always-text": { "type": "Text", "left": 622, "top": 614, "text": "globe · Always", "fontColor": "#8890AC", "fontSize": 12 },
|
||||||
|
"lg-default-circle": { "type": "Ellipse", "left": 752, "top": 615, "width": 14, "height": 14, "fillColor": "#20202C", "strokeColor": "#78809C" },
|
||||||
|
"lg-default-text": { "type": "Text", "left": 774, "top": 614, "text": "circle-dashed · Default", "fontColor": "#8890AC", "fontSize": 12 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "Settings — Search",
|
||||||
|
"shapes": {
|
||||||
|
"app-bg": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 720, "fillColor": "#17171E", "strokeColor": "#17171E" },
|
||||||
|
"app-hat": { "type": "Rectangle", "left": 0, "top": 0, "width": 1180, "height": 28, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||||
|
"app-sidebar": { "type": "Rectangle", "left": 0, "top": 28, "width": 54, "height": 692, "fillColor": "#1B1B22", "strokeColor": "#1B1B22" },
|
||||||
|
|
||||||
|
"modal-shadow": { "type": "Rectangle", "left": 134, "top": 66, "width": 920, "height": 600, "fillColor": "#101015", "strokeColor": "#101015", "corners": [12, 12, 12, 12] },
|
||||||
|
"modal": { "type": "Rectangle", "left": 130, "top": 60, "width": 920, "height": 600, "fillColor": "#20202C", "strokeColor": "#3C445C", "corners": [10, 10, 10, 10] },
|
||||||
|
|
||||||
|
"modal-title": { "type": "Text", "left": 154, "top": 76, "text": "Settings", "fontColor": "#E6E8F2", "fontSize": 18 },
|
||||||
|
"modal-close": { "type": "Text", "left": 1020, "top": 74, "text": "✕", "fontColor": "#8890AC", "fontSize": 16 },
|
||||||
|
"header-divider": { "type": "Rectangle", "left": 130, "top": 104, "width": 920, "height": 1, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
|
||||||
|
"rail": { "type": "Rectangle", "left": 130, "top": 105, "width": 228, "height": 554, "fillColor": "#1A1A24", "strokeColor": "#1A1A24", "corners": [0, 0, 0, 10] },
|
||||||
|
"rail-divider": { "type": "Rectangle", "left": 358, "top": 105, "width": 1, "height": 554, "fillColor": "#343850", "strokeColor": "#343850" },
|
||||||
|
|
||||||
|
"search-box": { "type": "Rectangle", "left": 144, "top": 118, "width": 200, "height": 28, "fillColor": "#242838", "strokeColor": "#78A0F8", "corners": [4, 4, 4, 4] },
|
||||||
|
"search-icon": { "type": "Ellipse", "left": 154, "top": 124, "width": 11, "height": 11, "fillColor": "#242838", "strokeColor": "#B1BBE3" },
|
||||||
|
"search-icon-handle": { "type": "Rectangle", "left": 163, "top": 133, "width": 4, "height": 1, "fillColor": "#B1BBE3", "strokeColor": "#B1BBE3" },
|
||||||
|
"search-text": { "type": "Text", "left": 176, "top": 124, "text": "tab", "fontColor": "#E6E8F2", "fontSize": 13 },
|
||||||
|
"search-caret": { "type": "Rectangle", "left": 198, "top": 123, "width": 1, "height": 16, "fillColor": "#78A0F8", "strokeColor": "#78A0F8" },
|
||||||
|
"search-clear": { "type": "Text", "left": 328, "top": 124, "text": "✕", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
|
||||||
|
"rail-hint": { "type": "Text", "left": 152, "top": 164, "text": "Searching all categories", "fontColor": "#545C84", "fontSize": 12 },
|
||||||
|
"cat-editor": { "type": "Text", "left": 152, "top": 196, "text": "Editor", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-editor-n": { "type": "Text", "left": 326, "top": 196, "text": "2", "fontColor": "#545C84", "fontSize": 13 },
|
||||||
|
"cat-keymap": { "type": "Text", "left": 152, "top": 228, "text": "Keymap", "fontColor": "#545C84", "fontSize": 14 },
|
||||||
|
"cat-appearance": { "type": "Text", "left": 152, "top": 260, "text": "Appearance", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"cat-appearance-n": { "type": "Text", "left": 326, "top": 260, "text": "1", "fontColor": "#545C84", "fontSize": 13 },
|
||||||
|
"cat-claude": { "type": "Text", "left": 152, "top": 292, "text": "Claude", "fontColor": "#545C84", "fontSize": 14 },
|
||||||
|
"cat-activity": { "type": "Text", "left": 152, "top": 324, "text": "Activity", "fontColor": "#545C84", "fontSize": 14 },
|
||||||
|
"cat-terminal": { "type": "Text", "left": 152, "top": 356, "text": "Terminal", "fontColor": "#545C84", "fontSize": 14 },
|
||||||
|
"cat-extensions": { "type": "Text", "left": 152, "top": 388, "text": "Extensions", "fontColor": "#545C84", "fontSize": 14 },
|
||||||
|
|
||||||
|
"results-head": { "type": "Text", "left": 382, "top": 120, "text": "3 settings match “tab”", "fontColor": "#B1BBE3", "fontSize": 14 },
|
||||||
|
|
||||||
|
"grp-editor": { "type": "Text", "left": 382, "top": 160, "text": "EDITOR", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"card-editor": { "type": "Rectangle", "left": 382, "top": 178, "width": 648, "height": 132, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
|
||||||
|
"r1-label": { "type": "Text", "left": 398, "top": 194, "text": "Tab width", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"r1-help": { "type": "Text", "left": 398, "top": 218, "text": "Columns per indent level.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"r1-number": { "type": "Rectangle", "left": 934, "top": 194, "width": 56, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"r1-number-text": { "type": "Text", "left": 956, "top": 199, "text": "2", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"r1-scope-body": { "type": "Rectangle", "left": 1004, "top": 198, "width": 15, "height": 10, "fillColor": "#242838", "strokeColor": "#7DD3A8", "corners": [2, 2, 2, 2] },
|
||||||
|
"r1-scope-tab": { "type": "Rectangle", "left": 1004, "top": 195, "width": 7, "height": 3, "fillColor": "#7DD3A8", "strokeColor": "#7DD3A8" },
|
||||||
|
|
||||||
|
"r2-label": { "type": "Text", "left": 398, "top": 260, "text": "Indent style", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"r2-help": { "type": "Text", "left": 398, "top": 284, "text": "Spaces or tabs for new indentation.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"r2-select": { "type": "Rectangle", "left": 870, "top": 260, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"r2-select-text": { "type": "Text", "left": 882, "top": 265, "text": "Spaces", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"r2-select-chevron": { "type": "Text", "left": 974, "top": 265, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"r2-scope-circle": { "type": "Ellipse", "left": 1004, "top": 264, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"r2-scope-eq": { "type": "Rectangle", "left": 1004, "top": 270, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"r2-scope-mer": { "type": "Rectangle", "left": 1010, "top": 264, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
|
||||||
|
"grp-appearance": { "type": "Text", "left": 382, "top": 330, "text": "APPEARANCE", "fontColor": "#78809C", "fontSize": 12 },
|
||||||
|
"card-appearance": { "type": "Rectangle", "left": 382, "top": 348, "width": 648, "height": 66, "fillColor": "#242838", "strokeColor": "#343850", "corners": [6, 6, 6, 6] },
|
||||||
|
"r3-label": { "type": "Text", "left": 398, "top": 364, "text": "Workspace tab density", "fontColor": "#E6E8F2", "fontSize": 15 },
|
||||||
|
"r3-help": { "type": "Text", "left": 398, "top": 388, "text": "Spacing of the workspace tab strip.", "fontColor": "#78809C", "fontSize": 13 },
|
||||||
|
"r3-select": { "type": "Rectangle", "left": 870, "top": 364, "width": 120, "height": 26, "fillColor": "#20202C", "strokeColor": "#343850", "corners": [4, 4, 4, 4] },
|
||||||
|
"r3-select-text": { "type": "Text", "left": 882, "top": 369, "text": "Compact", "fontColor": "#E6E8F2", "fontSize": 14 },
|
||||||
|
"r3-select-chevron": { "type": "Text", "left": 974, "top": 369, "text": "▾", "fontColor": "#8890AC", "fontSize": 14 },
|
||||||
|
"r3-scope-circle": { "type": "Ellipse", "left": 1004, "top": 368, "width": 14, "height": 14, "fillColor": "#242838", "strokeColor": "#E6C370" },
|
||||||
|
"r3-scope-eq": { "type": "Rectangle", "left": 1004, "top": 374, "width": 14, "height": 1, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
"r3-scope-mer": { "type": "Rectangle", "left": 1010, "top": 368, "width": 1, "height": 14, "fillColor": "#E6C370", "strokeColor": "#E6C370" },
|
||||||
|
|
||||||
|
"footer-note": { "type": "Text", "left": 382, "top": 444, "text": "Results span every category — edit a field inline, or open its category for the full list.", "fontColor": "#545C84", "fontSize": 12 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
+428
@@ -0,0 +1,428 @@
|
|||||||
|
# fable-ous.md
|
||||||
|
|
||||||
|
*A fable about clide, as told by Fable.*
|
||||||
|
|
||||||
|
*Produced by 13 parallel subsystem reviewers reading ~58k LOC of Dart, ~100 raw
|
||||||
|
findings put through 70 adversarial verification passes (exactly one finding was
|
||||||
|
refuted — it had missed D-14), 5 feature-ideation lenses, and a full read of the
|
||||||
|
pql planning vault: 94 confirmed decisions, 24 open questions, 11 rejected
|
||||||
|
alternatives, 358 tickets. Everything below cites real file:line evidence that a
|
||||||
|
skeptical second agent re-read and failed to knock down.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR scoreboard
|
||||||
|
|
||||||
|
| Dimension | Verdict |
|
||||||
|
|---|---|
|
||||||
|
| Build health | **Green.** `make analyze` 0 issues, 1383 tests pass, format clean, coverage 95.30% over the 95 floor |
|
||||||
|
| Documentation discipline | **Best-in-class.** Near-every file cites its D-NNN/T-NNN; zero TODO/FIXME debt in 58k LOC |
|
||||||
|
| Honesty | **High.** CHANGELOG claims verified against code; stubs self-describe as stubs |
|
||||||
|
| Real bugs found | ~14 distinct high-severity, ~35 medium — mostly in process lifecycle, a11y, and terminal conformance |
|
||||||
|
| Systemic risks | Broadcast-streams-without-replay, silent `catch (_)`, kernel lookup in `dispose()`, copy-paste drift |
|
||||||
|
| Release process | **Stalled.** No git tag since v2.1.0 despite five CHANGELOG releases; `ci/release.sh` is a stub |
|
||||||
|
| Killer-feature headroom | Enormous — the moats (owned agent loop, owned renderer, pql vault) are real and barely exploited |
|
||||||
|
|
||||||
|
The fable in one sentence: **a castle with exceptional masonry, a few unlocked
|
||||||
|
side doors, and a dragon hoard of features in the basement nobody has spent yet.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part I — The state of the realm (what's genuinely excellent)
|
||||||
|
|
||||||
|
Credit where due, because this codebase does several things better than most
|
||||||
|
production repos:
|
||||||
|
|
||||||
|
- **Governance traceability is real, not ceremonial.** Nearly every non-trivial
|
||||||
|
declaration carries its ticket/decision id. Multiple reviewers independently
|
||||||
|
called it "the best I've seen at this scale." Code-to-decision drift is
|
||||||
|
*auditable from the code itself* — and indeed most drift findings below were
|
||||||
|
found exactly that way.
|
||||||
|
- **Schema-validated IPC dispatch (D-74)** is beautifully executed: schemas
|
||||||
|
co-registered with handlers, the MCP tool surface and `clide capabilities`
|
||||||
|
both generated from the same registry (`lib/src/daemon/dispatcher.dart:90-137`)
|
||||||
|
so three surfaces cannot drift apart.
|
||||||
|
- **The keymap subsystem (D-82)** — layered precedence, headless
|
||||||
|
`SequenceMatcher`, clock-injected `ModifierTapTracker` — is exemplary,
|
||||||
|
near-fully test-mirrored design.
|
||||||
|
- **`ClideTappable`, the anchored-overlay/menu family (D-88), reduced-motion
|
||||||
|
honoring in every animated primitive** — the owned widget layer is coherent
|
||||||
|
and disciplined.
|
||||||
|
- **Battle scars are encoded where they happened.** `pumpAsync` documents why
|
||||||
|
`pumpAndSettle` wedges; `KernelFixture` encodes the T-280 teardown-hang fix;
|
||||||
|
flake postmortems live as comments in the test that almost shipped them.
|
||||||
|
- **Security instincts**: 0600 socket + live-listener probe before unlink, git
|
||||||
|
argv hardening with `--` terminators, `path_safety.dart` documenting its
|
||||||
|
threat models inline, workspace-relative binary resolution forbidden (T-98).
|
||||||
|
- **Zero TODO/FIXME/HACK** across the tree. The clean-board policy is lived.
|
||||||
|
|
||||||
|
Patterns worth *extending* (the reviewers kept wishing other code did this):
|
||||||
|
the coalesced-notify `Timer(Duration.zero)` trick in `ConversationController`,
|
||||||
|
the pure-Dart-core/thin-Flutter-shell split, and `RecordingEventSink`-style
|
||||||
|
event-driven test waits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part II — The bestiary (bugs I would fix, in order)
|
||||||
|
|
||||||
|
### 🐉 Dragons (high severity, verified, fix this week)
|
||||||
|
|
||||||
|
1. **Every naturally-exited PTY leaks its master fd — forever.**
|
||||||
|
`lib/src/pty/native_pty.dart:444-450` — on child EOF, `_reap()` sets
|
||||||
|
`_dead = true` but never closes `_fd`; a later `close()` short-circuits at
|
||||||
|
`if (_dead) return;` (line 460) so `_nativeClose(_fd)` (line 477) never runs.
|
||||||
|
Two reviewers found this independently. Every terminal/Claude pane whose
|
||||||
|
child exits on its own leaks an fd and a pty device for the life of the app.
|
||||||
|
|
||||||
|
2. **The Claude child process is observed only via stdout.** Three reviewers
|
||||||
|
converged here. `lib/builtin/claude/src/stream_json_session.dart:43-78` —
|
||||||
|
stderr is *never drained* (≥64KB of `--verbose` spew = pipe fills = child
|
||||||
|
blocks mid-turn = the flagship pane wedges with zero diagnostics), and
|
||||||
|
nothing watches `exitCode` or `onDone` (line 303), so a crashed/dead
|
||||||
|
session just looks… thoughtful. Drain stderr into a ring buffer, surface a
|
||||||
|
terminal `SessionEnded` state. This also intersects T-283 (no resume
|
||||||
|
timeout/fallback).
|
||||||
|
|
||||||
|
3. **The MCP HTTP server exposes the entire dispatcher with zero auth.**
|
||||||
|
`lib/src/ipc/mcp_server.dart:138-195`, started unconditionally at boot
|
||||||
|
(`lib/main.dart:174-180`). D-71's threat model ("another user on the same
|
||||||
|
host should not drive my IDE") is enforced with 0600 on the unix socket —
|
||||||
|
and then bypassed wholesale by an unauthenticated localhost HTTP port that,
|
||||||
|
since D-86, serves *every* clide verb as a tool. Generate a token in the
|
||||||
|
lock file (Claude Code's own `/ide` lock format has a slot for it), require
|
||||||
|
the header.
|
||||||
|
|
||||||
|
4. **`editor.open`/`editor.save` skip path confinement entirely.**
|
||||||
|
`lib/src/editor/registry.dart:215-219` returns absolute paths verbatim, no
|
||||||
|
`..` normalization, no `path_safety` call — an unconfined read *and write*
|
||||||
|
primitive over IPC while `files.read` is carefully guarded. Same family:
|
||||||
|
**`search.replace` silently ignores its include/exclude globs**
|
||||||
|
(`lib/src/search/replace_engine.dart:124-143`) and will happily rewrite
|
||||||
|
files outside the filter the user typed; and **`listDir`'s symlink
|
||||||
|
detection is dead code** (`lib/src/files/listing.dart:46-54` — `stat()`
|
||||||
|
follows links, so `isSymlink` is always false) which means `walkFiles`
|
||||||
|
descends symlinked dirs the docs claim it skips.
|
||||||
|
|
||||||
|
5. **Closing a terminal pane never closes the shell.**
|
||||||
|
`lib/builtin/terminal/src/terminal_pane.dart:131-137` calls
|
||||||
|
`ClideKernel.of(context)` from `dispose()` — illegal ancestor lookup,
|
||||||
|
swallowed by `catch (_)` — so `pane.close` is never sent and the backend
|
||||||
|
PTY + daemon pane leak. The same idiom leaks the settings listener in every
|
||||||
|
disposed `ClaudePane` (`claude_pane.dart:460-466`). Combined with dragon #1
|
||||||
|
this is a two-stage leak pipeline. Cache the kernel ref in
|
||||||
|
`didChangeDependencies`, delete the catch-all.
|
||||||
|
|
||||||
|
6. **Project switch leaks the entire previous workspace.**
|
||||||
|
`lib/main.dart:335-344` — a new dispatcher gets fresh `PaneRegistry`,
|
||||||
|
`FilesService`, `EditorRegistry`, etc., but nothing calls the old set's
|
||||||
|
`shutdown()` methods (which exist and have zero callers). Old watchers keep
|
||||||
|
emitting into the new workspace's bus.
|
||||||
|
|
||||||
|
7. **T-274's root cause, found and verified:** the status bar is empty because
|
||||||
|
`statusStream` is a plain broadcast controller — the `system/init` event
|
||||||
|
fires while `spawn()` is still awaiting a 256KB transcript-tail read, before
|
||||||
|
the pane ever subscribes (`claude_pane.dart:322`,
|
||||||
|
`session_orchestrator.dart:222-225`). Seed from `session.status` on bind, or
|
||||||
|
make it replay-latest. (The broadcast-without-replay shape is a recurring
|
||||||
|
bug factory — see Part III.)
|
||||||
|
|
||||||
|
8. **Auto-scroll yanks a scrolled-up reader to the bottom on every streamed
|
||||||
|
token.** `conversation_view.dart:268-277` — the `_atBottom` pin exists but
|
||||||
|
is only consulted on viewport *resize*, not on new items. Anyone reading
|
||||||
|
earlier output during a long streaming reply is dragged to the bottom
|
||||||
|
continuously. One-line gate + the missing twin test.
|
||||||
|
|
||||||
|
9. **The terminal can crash on garbled output.** SGR 38/48 extended-color
|
||||||
|
parsing does unguarded `params[i + 1]` lookahead
|
||||||
|
(`lib/src/terminal/src/core/escape/parser.dart:501-516`) — `printf '\e[38m'`
|
||||||
|
throws RangeError inside `Terminal.write`. An emulator must never throw on
|
||||||
|
hostile bytes. While in there: colon-form SGR sub-parameters are mangled
|
||||||
|
into bogus params.
|
||||||
|
|
||||||
|
10. **A11y has drifted despite being a Tier-0 contract.** Two independent
|
||||||
|
verified findings: `ClideCollapserCard`'s `excludeSemantics: true` wipes
|
||||||
|
*every expanded child* from the a11y tree
|
||||||
|
(`lib/widgets/src/clide_collapser_card.dart:92-101`) — a screen-reader user
|
||||||
|
can expand a run and hear nothing; and the three a11y gate tests
|
||||||
|
hand-enumerate their subjects and have measurably fallen behind `lib/`
|
||||||
|
(contrast checks fewer themes than `main.dart:443-454` loads; i18n checks 4
|
||||||
|
of 8 namespaces). The gates stay green while covering less. Make `lib/`
|
||||||
|
export the canonical lists and iterate them in the gates.
|
||||||
|
|
||||||
|
### 🦂 Scorpions (medium — real, will sting eventually)
|
||||||
|
|
||||||
|
- **D-72's "serial dispatch" isn't.** `lib/src/ipc/server.dart:151-180` uses an
|
||||||
|
`async` onData without pausing the subscription — pipelined requests
|
||||||
|
interleave, and the shared `StringBuffer` framing can drop/double lines.
|
||||||
|
`client.cast<List<int>>().transform(utf8.decoder).transform(LineSplitter())`
|
||||||
|
+ `await for` fixes framing, UTF-8 split chunks, and serialization at once.
|
||||||
|
- **Split-chunk UTF-8 corruption is endemic at byte→String seams**: the
|
||||||
|
terminal's only ingestion API is `write(String)` (`terminal.dart:218`) so
|
||||||
|
both consumers decode per-chunk; `FileTailFollower` starts mid-character by
|
||||||
|
construction. Add `writeBytes()` with a persistent chunked decoder.
|
||||||
|
- **`Orchestrator.spawn()` races itself** — check-then-act across two awaits;
|
||||||
|
concurrent spawns for one id leak a live `claude` process
|
||||||
|
(`session_orchestrator.dart:191-249`). Hold a `Map<String, Future<ManagedSession>>`.
|
||||||
|
- **Fork panes misbehave on `/clear`, `/resume`, `/fork`** —
|
||||||
|
`widget.forkSourceId` wins forever, so `/clear` *re-forks the original
|
||||||
|
conversation* instead of clearing (`claude_pane.dart:266-281`).
|
||||||
|
- **Settings persistence corrupts maps-inside-lists on write**
|
||||||
|
(`settings.dart:199-219` emits `toString()`), breaking the documented keymap
|
||||||
|
overlay across restarts; writes are non-atomic and a parse failure silently
|
||||||
|
resets all settings.
|
||||||
|
- **Extension activation isn't transactional** — a throw mid-contribution
|
||||||
|
leaves contributions mounted while the extension records as failed; retry
|
||||||
|
double-applies (`extensions_manager.dart:133-192`). Plus: disabling an
|
||||||
|
extension ignores dependents, and registries clobber silently on id
|
||||||
|
collision — fine among curated builtins, hazardous the day Tier-6 Lua lands.
|
||||||
|
- **Terminal conformance debt** (the fork fixes what it trips over but has no
|
||||||
|
vttest-style suite): HTS is a no-op (`isSetAt` instead of `setAt`,
|
||||||
|
`terminal.dart:423`), DECCKM is tracked but never consumed, legacy mouse rows
|
||||||
|
are off-by-one *and the test enshrines the bug*, CPR replies 0-based where
|
||||||
|
every real terminal is 1-based, scrollback is maintained but structurally
|
||||||
|
unreachable (`ViewportOffset.zero()` pinned every build,
|
||||||
|
`terminal_view.dart:224`).
|
||||||
|
- **Markdown renderer**: hard breaks and images render as empty text (words
|
||||||
|
glue together; `clide_markdown.dart:408-410`), and the whole document
|
||||||
|
re-parses with sync `existsSync()` calls *inside build* on every streaming
|
||||||
|
delta — multiplied by the conversation view re-deriving everything O(n) per
|
||||||
|
notification and token streaming re-encoding the full reply per delta
|
||||||
|
(O(n²) churn, `stream_json_session.dart:410-431`).
|
||||||
|
- **Terminal panes spawn in `Directory.current`**, not the open project root
|
||||||
|
(`terminal_pane.dart:69`) — desktop launches get `$HOME` shells.
|
||||||
|
- **The Notifications service renders nowhere** — `notify.dart` has zero widget
|
||||||
|
consumers; cli_install's dogfood warnings vanish into an unrendered list
|
||||||
|
while `ToastService` sits right there.
|
||||||
|
- **Welcome screen no-ops**: "Clone from git…" and "Start a Claude session"
|
||||||
|
advertise shortcuts that don't exist and do nothing on tap
|
||||||
|
(`welcome_view.dart:173-174`).
|
||||||
|
- **`make test-e2e`/`ui-dev`/`ui-smoke` are dead** — `tools/ui/*.sh` still `cd`
|
||||||
|
into the removed `app/` directory; the staged Gitea CI workflow would fail in
|
||||||
|
three independent ways on activation, while D-32 calls it "ready."
|
||||||
|
|
||||||
|
### 🐀 Rats (low, but they breed)
|
||||||
|
|
||||||
|
Dead code worth a one-day extermination sweep: the entire legacy free-function
|
||||||
|
git API (~250 LOC duplicating `GitClient`, kept alive only by tests, *with its
|
||||||
|
own latent pipe-deadlock bug*), `ToolCheck`, ~60% of `ffi/libc.dart` (fd-passing
|
||||||
|
era), `GraphView` (unreachable placeholder), `ColumnHat` (duplicated
|
||||||
|
line-for-line in app.dart, kept alive by a zero-coverage test), the tmux-era
|
||||||
|
team pipeline (`TranscriptPublisher`, `TeamMemberJoined` — *nothing emits these
|
||||||
|
events*, yet the team roster UI still listens to them exclusively, meaning team
|
||||||
|
tiles are populated by ghosts), the dead `ptyc` binary still committed in
|
||||||
|
`native/linux-x64/` against D-62/D-63, and `mocktail` — pinned, documented in
|
||||||
|
D-25 as the IO-mocking strategy, and imported by exactly zero files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part III — Patterns I would change (the systemic stuff)
|
||||||
|
|
||||||
|
1. **Broadcast streams that carry state need replay-latest.** This one shape
|
||||||
|
caused T-274, the meta sidebar's manual compensation, and the
|
||||||
|
prompt-stream's `initialData` workaround. Write a tiny `ValueStream` wrapper
|
||||||
|
once; retrofit `statusStream`, `busyStream`, `pendingPromptStream`.
|
||||||
|
|
||||||
|
2. **Ban `catch (_) {}` on I/O and lifecycle paths.** The silent-swallow idiom
|
||||||
|
turned an illegal-lookup-in-dispose into two resource leaks and turned
|
||||||
|
process-spawn failures into blank panes. Cleanup paths may swallow; spawn,
|
||||||
|
read, and dispose paths must log through the kernel Logger they already have.
|
||||||
|
|
||||||
|
3. **Sync I/O in async handlers on the single isolate.** `files.read` does a
|
||||||
|
sync 10MB read; the replace engine reads and rewrites the workspace
|
||||||
|
synchronously *while grep right next to it fans out to isolates per D-79*.
|
||||||
|
Decide the rule (offload above N KB), write it into a D-record, apply it.
|
||||||
|
|
||||||
|
4. **Path confinement belongs at the dispatch layer, not per-verb.** files.read
|
||||||
|
remembered, search.replace half-remembered, editor.* forgot. A confinement
|
||||||
|
check keyed off the co-registered schema (the registry already knows which
|
||||||
|
params are paths) ends the per-verb lottery.
|
||||||
|
|
||||||
|
5. **Copy-paste is the repo's main duplication tax.** The welcome screen clones
|
||||||
|
FileActions' entire open-folder flow verbatim; palette and quick-open are
|
||||||
|
~230-line near-twins; three private "tail a growing file" implementations in
|
||||||
|
the claude builtin alone; five hand-rolled `_userErr` helpers; five
|
||||||
|
copy-pasted git test sandboxes (none isolating host git config); two
|
||||||
|
parallel ANSI flag enums that already drifted (strikethrough is stored but
|
||||||
|
never painted). Each is small; together they're how a solo-dev repo rots.
|
||||||
|
|
||||||
|
6. **Hand-enumerated lists drift; export the truth.** Bundled themes (already
|
||||||
|
drifted between `main.dart` and the testmode harness — catppuccin is
|
||||||
|
silently unvalidated), a11y gate subjects, i18n namespaces. One exported
|
||||||
|
const each, consumed by both sides.
|
||||||
|
|
||||||
|
7. **The claude builtin returns `ok` with an `error` payload in 16 handlers**,
|
||||||
|
drifting from the D-6 exit-code contract every other subsystem honors. A
|
||||||
|
scripted `clide claude.agent.set-permission-mode bogus` exits 0 today.
|
||||||
|
|
||||||
|
8. **God-files**: `app.dart` (1187 LOC, five concerns — split plan is in the
|
||||||
|
findings), `claude_meta_sidebar.dart` (1192), `parser.dart` (1139, T-123
|
||||||
|
already exists — and the split should also fix `_consumeCsi` discarding
|
||||||
|
intermediate bytes, which permanently blocks DECSCUSR/DECSTR).
|
||||||
|
|
||||||
|
9. **Docs drift at the front door**: CLAUDE.md and README still say "tmux owns
|
||||||
|
Claude session persistence (D-41)" — superseded by D-75/D-77 per
|
||||||
|
`docs/architecture.md`; README says "Pre-v2.0 (2.0.0-dev)" at v2.3.3 and
|
||||||
|
headlines "canvas and graph surfaces" that are a 17-line stub and a flat
|
||||||
|
ListView respectively. clide's honesty is its brand; the README is the one
|
||||||
|
place currently off-brand.
|
||||||
|
|
||||||
|
10. **Close the release loop.** Five CHANGELOG releases since the last git tag;
|
||||||
|
`ci/release.sh` exits 64 and references the dissolved sidecar; the pre-push
|
||||||
|
fast path's safety argument cites "release CI on tagged versions" that
|
||||||
|
doesn't exist; and the fast path skips ALL tests for pushes touching
|
||||||
|
`test/`, `ci/`, or the hook itself. Back-tag 2.2.0–2.3.3, add tagging to
|
||||||
|
the git-commit skill ritual, widen the fast-path regex. (This is also the
|
||||||
|
blocking prerequisite your own T-47 refinement identified for self-update.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part IV — Killer features (the dragon hoard)
|
||||||
|
|
||||||
|
Five ideation lenses, 27 proposals, deduplicated and ranked. The convergence
|
||||||
|
test mattered: **two lenses independently invented the flight recorder, and two
|
||||||
|
independently invented the visual canvas round-trip** — when separate agents
|
||||||
|
with different briefs land on the same feature, that's the market talking.
|
||||||
|
|
||||||
|
Clide's structural moats, verified against code: it *spawns and owns* the agent
|
||||||
|
process (D-77/D-78) where competitors are sandboxed extension guests; it owns
|
||||||
|
every pixel (terminal, markdown, canvas); everything is local-and-committed
|
||||||
|
(transcripts, costs, decisions, tickets) where competitors' business models
|
||||||
|
require cloud custody; and the pql vault is structured planning data no
|
||||||
|
mainstream IDE has an analogue for.
|
||||||
|
|
||||||
|
### Tier 1 — do these (high leverage, mostly M-effort, plumbing exists)
|
||||||
|
|
||||||
|
1. **Agent Blame + Session Flight Recorder** `[L]` — gutter action on any line:
|
||||||
|
*which session, which turn, which prompt, which permission grant, what it
|
||||||
|
cost* — opening the native conversation at the exact `tool_use`. Timeline
|
||||||
|
scrubber to replay a session. The transcript pipeline
|
||||||
|
(`transcript_reader.dart`, `session_index.dart`) already parses everything
|
||||||
|
needed. Cursor/Copilot cannot ship this: their logs live server-side by
|
||||||
|
business design. *Two lenses converged here.*
|
||||||
|
|
||||||
|
2. **Context X-ray** `[M]` — per-card token attribution ("this 40KB Bash tail
|
||||||
|
is 12% of your window") + a real compaction indicator. `stream_json_session.dart`
|
||||||
|
already parses usage and contextWindow per event; the renderer owns the
|
||||||
|
cards. Fixes T-244 (invisible compaction) as a side effect. Context is the
|
||||||
|
scarcest resource in agent pairing and every tool renders it as one opaque
|
||||||
|
percentage.
|
||||||
|
|
||||||
|
3. **Trust Ledger + decision-aware permission prompts** `[M]` — every
|
||||||
|
permission rule with provenance (which prompt, which session, which ticket),
|
||||||
|
ticket-scoped expiry; and when a `can_use_tool` request arrives, chip the
|
||||||
|
relevant D-record onto the card (edit touching pubspec.yaml → D-31
|
||||||
|
prefer-zero-deps, one keystroke to deny *with the decision cited*).
|
||||||
|
Governance stops being documentation and becomes live agent policy. No
|
||||||
|
competitor has a queryable in-repo decision system to even attempt this.
|
||||||
|
|
||||||
|
4. **Agent Activity HUD** `[M]` — four backlog tickets and one open question
|
||||||
|
are secretly one feature: build T-59's `OperationsRegistry` once and feed it
|
||||||
|
git/pql progress (T-59), sidebar badges (T-58), compaction state (T-244),
|
||||||
|
the status strip (T-274), with Q-34's budget slot reserved. Fix the T-274
|
||||||
|
plumbing bug first or the HUD inherits blank-slot syndrome.
|
||||||
|
|
||||||
|
5. **Active Ticket Context** `[S]` ← *cheapest win in the whole list* — picking
|
||||||
|
up a ticket binds it to the session: a "working on T-244" chip, auto
|
||||||
|
`in_progress` flip, `(T-NNN)` pre-suggested in commit messages, changelog
|
||||||
|
reminder on done. Makes kanban ambient instead of homework, and makes every
|
||||||
|
trail/ledger feature below reliable.
|
||||||
|
|
||||||
|
### Tier 2 — the differentiators (L/XL, each could headline a release)
|
||||||
|
|
||||||
|
6. **Twin-timeline rewind** `[L]` — snapshot the worktree as hidden git refs
|
||||||
|
(`git write-tree` → `refs/clide/checkpoints`) at every turn boundary, keyed
|
||||||
|
to turn uuid; every user-message card gains "restore files to before this."
|
||||||
|
Claude's `/rewind` only restores what Claude itself edited; clide owns both
|
||||||
|
timelines.
|
||||||
|
|
||||||
|
7. **Visual Dialog** `[L]` — one bidirectional scene schema: Claude draws
|
||||||
|
(D-91 canvas cards), the user annotates in the interaction zone (T-260), and
|
||||||
|
the annotations return as *structured geometry + flattened PNG*, not prose.
|
||||||
|
Merge the T-317/T-318 and T-260 specs into one protocol before they become
|
||||||
|
two dialects. *Two lenses converged here.*
|
||||||
|
|
||||||
|
8. **Immortal terminals** `[M]` — T-258 (terminal as editor-mode peer) fused
|
||||||
|
with T-325 live-tails: any long process — user shell *or* agent-spawned
|
||||||
|
build — promotes to a full tmux-backed surface that survives restart.
|
||||||
|
"The build that never dies" is structural for clide, a plugin fantasy for
|
||||||
|
Electron. Resolve Q-27 (swap vs split) as part of it, as T-258 already notes.
|
||||||
|
|
||||||
|
9. **Local cost ledger** `[M]` — per-turn cost/tokens persisted against the
|
||||||
|
active pql ticket; the board shows what each feature actually cost.
|
||||||
|
Flat-subscription opacity is the competitors' business model; turn-level
|
||||||
|
local cost data is clide's birthright. (Tokens primary, dollars advisory —
|
||||||
|
subscription auth reports notional costs.)
|
||||||
|
|
||||||
|
10. **Label-routed work queues / ticket dispatch** `[M→XL]` — T-277 labels +
|
||||||
|
the shipped pick-up path turn the board into an agent control surface;
|
||||||
|
the XL extension dispatches a ticket to a teammate session in an isolated
|
||||||
|
git worktree (SpawnSpec.cwd already exists). Local, no-telemetry
|
||||||
|
background agents with the work item, isolation, review surface, and audit
|
||||||
|
trail all in-repo.
|
||||||
|
|
||||||
|
### Tier 3 — moonshots (XL, pick one per quarter, they compound)
|
||||||
|
|
||||||
|
11. **Semantic terminal** — OSC 133 markers (clide spawns the shell, injection
|
||||||
|
is trivial) lift scrollback into foldable command regions with recognizers
|
||||||
|
for test runners and stack traces; real widgets between rows is something
|
||||||
|
xterm.js structurally cannot do. *Note: fix the scrollback-unreachable bug
|
||||||
|
first — a semantic scrollback you can't scroll is a koan.*
|
||||||
|
12. **Living codebase map** — tree-sitter imports + pql links + git churn on
|
||||||
|
the owned canvas, with live agent heat from `tool_use` events: watch Claude
|
||||||
|
*move through your codebase* in real time.
|
||||||
|
13. **Live mixed documents** — fenced blocks in the owned markdown renderer
|
||||||
|
become live embeds (canvas scenes, pql query results, decision cards,
|
||||||
|
confirm-gated command buttons). Notebook-grade, zero webview. The
|
||||||
|
`ClideMarkdownHooks` seam already exists.
|
||||||
|
14. **Remote Claude over SSH** — T-329 is fully ticketed and undersold: agent
|
||||||
|
on the buildbox, permission prompts rendering natively local. VS Code
|
||||||
|
Remote moves the editor; nobody remotes the *agent control channel*.
|
||||||
|
15. **Sealed-workspace mode** — an egress-audit proxy around the whole agent
|
||||||
|
stack, operationalizing D-60/D-64 into a provable property. The one
|
||||||
|
feature in this list competitors *cannot* copy without breaking their own
|
||||||
|
products.
|
||||||
|
|
||||||
|
### Honorable mentions
|
||||||
|
|
||||||
|
Plan-to-Board bridge (ExitPlanMode approval files the plan as a ticket tree),
|
||||||
|
Release Cockpit (renders `[Unreleased]` with word-count badges + one-action
|
||||||
|
release cut — would also unstall Part III #10), Daily Helm ("since you were
|
||||||
|
last here" pulse on the welcome screen, computed from data the repo already
|
||||||
|
commits), Total-recall conversation search (D-79 grep over the transcript
|
||||||
|
corpus + fork-from-here), Shared Gaze (attach editor selection/scroll context
|
||||||
|
to each outgoing turn), Speakable Layouts (`clide layout apply review.yaml` —
|
||||||
|
the agent stages your workspace), Agent fleet tray (enumerate per-workspace
|
||||||
|
sockets, show every repo's agent state — *requires fixing T-247's stale-socket
|
||||||
|
litter first*), Governance Graph (the D/Q/R/T web as a navigable map — gives
|
||||||
|
the placeholder graph view a flagship dataset).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part V — If I were you, Monday morning
|
||||||
|
|
||||||
|
1. **One leak-fix commit**: PTY fd on natural exit + kernel-lookup-in-dispose
|
||||||
|
(terminal & claude panes) + project-switch service disposal. Three findings,
|
||||||
|
one theme, one afternoon.
|
||||||
|
2. **One Claude-resilience commit**: drain stderr, watch exitCode, seed status
|
||||||
|
on bind, gate auto-scroll on `_atBottom`. The flagship pane stops having
|
||||||
|
silent failure modes.
|
||||||
|
3. **One security commit**: MCP auth token + editor.* path confinement +
|
||||||
|
search.replace glob filter + symlink-walk fix.
|
||||||
|
4. **One rat-extermination day**: dead git API, ToolCheck, libc bindings,
|
||||||
|
ColumnHat, GraphView, tmux-era team pipeline, ptyc binary, mocktail. The
|
||||||
|
diff is gloriously red and the coverage denominator thanks you.
|
||||||
|
5. **Tag your releases.** Five releases of honest changelog work are currently
|
||||||
|
unaddressable commits.
|
||||||
|
6. Then go build the **Active Ticket chip** (S!) and the **Context X-ray**, and
|
||||||
|
let clide start showing people things no other IDE can.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Findings methodology: every medium/high claim above survived an independent
|
||||||
|
adversarial re-read of the cited lines (one claim did not — the proposed
|
||||||
|
"can't-disable core extensions" guard, which D-14 deliberately rejects, so it
|
||||||
|
stays out of this report). The full per-finding evidence, severities, and
|
||||||
|
suggested fixes live in the review transcripts; ~34 additional low-severity
|
||||||
|
findings were verified by citation only.*
|
||||||
|
|
||||||
|
*— Fable, 2026-06-11*
|
||||||
+30
-3
@@ -66,14 +66,14 @@ You might also want, project-permitting:
|
|||||||
- [D-22: WCAG-AA contrast gate on bundled themes](decisions/accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes) — _accessibility_
|
- [D-22: WCAG-AA contrast gate on bundled themes](decisions/accessibility.md#d-22-wcag-aa-contrast-gate-on-bundled-themes) — _accessibility_
|
||||||
- [D-23: Test pyramid — seven layers](decisions/testing.md#d-23-test-pyramid--seven-layers) — _testing_
|
- [D-23: Test pyramid — seven layers](decisions/testing.md#d-23-test-pyramid--seven-layers) — _testing_
|
||||||
- [D-24: Golden tests — primitives only, Alchemist + Ahem](decisions/testing.md#d-24-golden-tests--primitives-only-alchemist--ahem) — _testing_
|
- [D-24: Golden tests — primitives only, Alchemist + Ahem](decisions/testing.md#d-24-golden-tests--primitives-only-alchemist--ahem) — _testing_
|
||||||
- [D-25: Mocks — mocktail at IO, hand-rolled fakes for ChangeNotifiers](decisions/testing.md#d-25-mocks--mocktail-at-io-hand-rolled-fakes-for-changenotifiers) — _testing_
|
- [D-25: Mocks — hand-rolled fakes throughout; mocktail dropped](decisions/testing.md#d-25-mocks--hand-rolled-fakes-throughout-mocktail-dropped) — _testing_
|
||||||
- [D-26: Web driver — raw Playwright + Flutter semantics](decisions/testing.md#d-26-web-driver--raw-playwright--flutter-semantics) — _testing_
|
- [D-26: Web driver — raw Playwright + Flutter semantics](decisions/testing.md#d-26-web-driver--raw-playwright--flutter-semantics) — _testing_
|
||||||
- [D-27: Startup regression gate](decisions/testing.md#d-27-startup-regression-gate) — _testing_
|
- [D-27: Startup regression gate](decisions/testing.md#d-27-startup-regression-gate) — _testing_
|
||||||
- [D-28: Test organisation — mirror `lib/` in `test/`](decisions/testing.md#d-28-test-organisation--mirror-lib-in-test) — _testing_
|
- [D-28: Test organisation — mirror `lib/` in `test/`](decisions/testing.md#d-28-test-organisation--mirror-lib-in-test) — _testing_
|
||||||
- [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_
|
||||||
@@ -133,6 +133,16 @@ You might also want, project-permitting:
|
|||||||
- [D-89: inline pasted-image thumbnails that expand to the lightbox](decisions/design.md#d-89-inline-pasted-image-thumbnails-that-expand-to-the-lightbox) — _design_
|
- [D-89: inline pasted-image thumbnails that expand to the lightbox](decisions/design.md#d-89-inline-pasted-image-thumbnails-that-expand-to-the-lightbox) — _design_
|
||||||
- [D-90: clide:// deep links — paranoid allowlist + user confirmation](decisions/architecture.md#d-90-clide-deep-links--paranoid-allowlist--user-confirmation) — _architecture_
|
- [D-90: clide:// deep links — paranoid allowlist + user confirmation](decisions/architecture.md#d-90-clide-deep-links--paranoid-allowlist--user-confirmation) — _architecture_
|
||||||
- [D-91: Unified conversation drawing card backed by a canvas renderer](decisions/architecture.md#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer) — _architecture_
|
- [D-91: Unified conversation drawing card backed by a canvas renderer](decisions/architecture.md#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer) — _architecture_
|
||||||
|
- [D-92: Ship pql bundled with clide](decisions/tooling.md#d-92-ship-pql-bundled-with-clide) — _tooling_
|
||||||
|
- [D-93: clide writes no directories of its own into the workspace](decisions/architecture.md#d-93-clide-writes-no-directories-of-its-own-into-the-workspace) — _architecture_
|
||||||
|
- [D-94: Workspace mode is a first-class, extensible declared capability](decisions/architecture.md#d-94-workspace-mode-is-a-first-class-extensible-declared-capability) — _architecture_
|
||||||
|
- [D-95: Workspace validity and onboarding flow](decisions/architecture.md#d-95-workspace-validity-and-onboarding-flow) — _architecture_
|
||||||
|
- [D-96: Remote-execution footprint — no-install ssh-exec](decisions/architecture.md#d-96-remote-execution-footprint--no-install-ssh-exec) — _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-99: Remote session identity keyed on (host, workspace)](decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace) — _architecture_
|
||||||
|
- [D-100: Fence `dart:ffi` behind conditional imports + web stubs to keep the web/WASM target compiling](decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling) — _tooling_
|
||||||
|
- [D-101: ClideSettings — one live-preferences access facade](decisions/architecture.md#d-101-clidesettings--one-live-preferences-access-facade) — _architecture_
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
@@ -152,7 +162,6 @@ You might also want, project-permitting:
|
|||||||
- [Q-17: Icon set growth](questions/process.md#q-17-icon-set-growth) — _process_
|
- [Q-17: Icon set growth](questions/process.md#q-17-icon-set-growth) — _process_
|
||||||
- [Q-18: Theme hot-reload in release builds](questions/process.md#q-18-theme-hot-reload-in-release-builds) — _process_
|
- [Q-18: Theme hot-reload in release builds](questions/process.md#q-18-theme-hot-reload-in-release-builds) — _process_
|
||||||
- [Q-20: Kernel DB service — namespaced SQL access?](questions/process.md#q-20-kernel-db-service--namespaced-sql-access) — _process_
|
- [Q-20: Kernel DB service — namespaced SQL access?](questions/process.md#q-20-kernel-db-service--namespaced-sql-access) — _process_
|
||||||
- [Q-23: SSH-remote development — run clide against a remote workspace](questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace) — _architecture_
|
|
||||||
- [Q-25: Body text face — mono everywhere vs Josefin Sans UI + mono code](questions/architecture.md#q-25-body-text-face--mono-everywhere-vs-josefin-sans-ui--mono-code) — _architecture_
|
- [Q-25: Body text face — mono everywhere vs Josefin Sans UI + mono code](questions/architecture.md#q-25-body-text-face--mono-everywhere-vs-josefin-sans-ui--mono-code) — _architecture_
|
||||||
- [Q-26: Small screen layout (< 1000px)](questions/architecture.md#q-26-small-screen-layout--1000px) — _architecture_
|
- [Q-26: Small screen layout (< 1000px)](questions/architecture.md#q-26-small-screen-layout--1000px) — _architecture_
|
||||||
- [Q-27: Two-editor split](questions/architecture.md#q-27-two-editor-split) — _architecture_
|
- [Q-27: Two-editor split](questions/architecture.md#q-27-two-editor-split) — _architecture_
|
||||||
@@ -160,6 +169,22 @@ You might also want, project-permitting:
|
|||||||
- [Q-30: Focus behavior when editor is dirty and viewer is peeked](questions/architecture.md#q-30-focus-behavior-when-editor-is-dirty-and-viewer-is-peeked) — _architecture_
|
- [Q-30: Focus behavior when editor is dirty and viewer is peeked](questions/architecture.md#q-30-focus-behavior-when-editor-is-dirty-and-viewer-is-peeked) — _architecture_
|
||||||
- [Q-31: XWayland fallback for frameless — proper Wayland protocol needed](questions/architecture.md#q-31-xwayland-fallback-for-frameless--proper-wayland-protocol-needed) — _architecture_
|
- [Q-31: XWayland fallback for frameless — proper Wayland protocol needed](questions/architecture.md#q-31-xwayland-fallback-for-frameless--proper-wayland-protocol-needed) — _architecture_
|
||||||
- [Q-34: How + when to surface the account/team token budget given upstream doesn't expose it](questions/architecture.md#q-34-how--when-to-surface-the-accountteam-token-budget-given-upstream-doesnt-expose-it) — _architecture_
|
- [Q-34: How + when to surface the account/team token budget given upstream doesn't expose it](questions/architecture.md#q-34-how--when-to-surface-the-accountteam-token-budget-given-upstream-doesnt-expose-it) — _architecture_
|
||||||
|
- [Q-35: Agent Blame + Session Flight Recorder — implement?](questions/design.md#q-35-agent-blame--session-flight-recorder--implement) — _design_
|
||||||
|
- [Q-36: Context X-ray — implement?](questions/design.md#q-36-context-x-ray--implement) — _design_
|
||||||
|
- [Q-37: Trust Ledger + decision-aware permission prompts — implement?](questions/design.md#q-37-trust-ledger--decision-aware-permission-prompts--implement) — _design_
|
||||||
|
- [Q-38: Agent Activity HUD — implement?](questions/design.md#q-38-agent-activity-hud--implement) — _design_
|
||||||
|
- [Q-39: Active Ticket Context — implement?](questions/design.md#q-39-active-ticket-context--implement) — _design_
|
||||||
|
- [Q-40: Twin-timeline rewind — implement?](questions/design.md#q-40-twin-timeline-rewind--implement) — _design_
|
||||||
|
- [Q-41: Visual Dialog — one bidirectional scene schema — implement?](questions/design.md#q-41-visual-dialog--one-bidirectional-scene-schema--implement) — _design_
|
||||||
|
- [Q-42: Immortal terminals — implement?](questions/design.md#q-42-immortal-terminals--implement) — _design_
|
||||||
|
- [Q-43: Local cost ledger — implement?](questions/design.md#q-43-local-cost-ledger--implement) — _design_
|
||||||
|
- [Q-44: Label-routed work queues / ticket dispatch — implement?](questions/design.md#q-44-label-routed-work-queues--ticket-dispatch--implement) — _design_
|
||||||
|
- [Q-45: Semantic terminal — implement?](questions/design.md#q-45-semantic-terminal--implement) — _design_
|
||||||
|
- [Q-46: Living codebase map — implement?](questions/design.md#q-46-living-codebase-map--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-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
|
||||||
|
- [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
|
||||||
|
|
||||||
@@ -169,9 +194,11 @@ You might also want, project-permitting:
|
|||||||
- [Q-19: (withdrawn)](questions/process.md#q-19-withdrawn) — _process_
|
- [Q-19: (withdrawn)](questions/process.md#q-19-withdrawn) — _process_
|
||||||
- [Q-21: Pql absorbs planning vs keeps separate](questions/architecture.md#q-21-pql-absorbs-planning-vs-keeps-separate) — _architecture_
|
- [Q-21: Pql absorbs planning vs keeps separate](questions/architecture.md#q-21-pql-absorbs-planning-vs-keeps-separate) — _architecture_
|
||||||
- [Q-22: Ticket persistence strategy](questions/architecture.md#q-22-ticket-persistence-strategy) — _architecture_
|
- [Q-22: Ticket persistence strategy](questions/architecture.md#q-22-ticket-persistence-strategy) — _architecture_
|
||||||
|
- [Q-23: SSH-remote development — run clide against a remote workspace](questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace) — _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-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
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Core, rendering, IPC, kernel, panel manager.
|
|||||||
|
|
||||||
### D-4: Ignore file strategy
|
### D-4: Ignore file strategy
|
||||||
- **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage)
|
- **Date:** 2026-04-20 (was ADR 0004; ported from the claudian lineage)
|
||||||
|
- **Amendment (2026-06-11):** Per [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), clide no longer writes a `.clide/` directory into the repo; only `.pql/` is added to `.gitignore` at install time. The `.clide/` mention below is retained for history.
|
||||||
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
|
- **Decision:** One mechanism everywhere: the `ignore_files:` list in `.pql/config.yaml`. Ordered list of gitignore-shaped files; later entries win on per-pattern conflicts. pql defaults to `ignore_files: [.gitignore]`. Per [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), clide writes the list on load — `[.gitignore, .clideignore]` if `.clideignore` exists, else `[.gitignore]`. `.clideignore` carries **only** the clide-specific deviations from `.gitignore` (supports `!pattern` negations); never duplicate gitignore's contents. Walker magic: none except `.git/` — every other tool-owned dir (`.pql/`, `.clide/`) is added to `.gitignore` at install time; exclusion flows through the normal `ignore_files:` chain.
|
||||||
- **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept.
|
- **Context:** Every file-enumerating surface in clide (pql query panels, canvas drivers, graph view, file watchers, pane lists, file tree) needs to skip the obvious junk — `vendor/`, `node_modules/`, `dist/`, build artifacts — or results drown in noise. Clide's working assumption is that the git repo *is* the workspace — no separate "vault" concept.
|
||||||
- **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface.
|
- **Rationale:** Users get one config knob, in a file they might already know (pql users) or never need to touch (clide-only users). `.clideignore` is short by design — it's deltas, not a full list. Sidecar consumers read the same key and apply identical precedence, so Claude and the user always see the same filtered surface.
|
||||||
@@ -72,7 +73,7 @@ Core, rendering, IPC, kernel, panel manager.
|
|||||||
### D-10: State management — `ChangeNotifier` + `ListenableBuilder`
|
### D-10: State management — `ChangeNotifier` + `ListenableBuilder`
|
||||||
- **Date:** 2026-04-21
|
- **Date:** 2026-04-21
|
||||||
- **Decision:** Per-feature state uses `ChangeNotifier` exposed through a feature facade (singleton-per-kernel); widgets subscribe via `ListenableBuilder`. No Riverpod, Provider, BLoC, or Redux.
|
- **Decision:** Per-feature state uses `ChangeNotifier` exposed through a feature facade (singleton-per-kernel); widgets subscribe via `ListenableBuilder`. No Riverpod, Provider, BLoC, or Redux.
|
||||||
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-25](testing.md#d-25-mocks--mocktail-at-io-hand-rolled-fakes-for-changenotifiers)). Violates [D-31 prefer-zero-deps](tooling.md#d-31-prefer-zero-deps-exact-pin) otherwise. See [R-8](../rejected/architecture.md#r-8-riverpod--provider--bloc-for-state).
|
- **Rationale:** SDK-shipped, zero deps, trivial to fake in tests (hand-rolled fakes in [D-25](testing.md#d-25-mocks--hand-rolled-fakes-throughout-mocktail-dropped)). Violates [D-31 prefer-zero-deps](tooling.md#d-31-prefer-zero-deps-exact-pin) otherwise. See [R-8](../rejected/architecture.md#r-8-riverpod--provider--bloc-for-state).
|
||||||
- **Cost:** No codegen ergonomics; manual `notifyListeners()` discipline. The `ListenableBuilder.listenable` contract rejects rebuilds outside the subscribed notifier — intentional.
|
- **Cost:** No codegen ergonomics; manual `notifyListeners()` discipline. The `ListenableBuilder.listenable` contract rejects rebuilds outside the subscribed notifier — intentional.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
@@ -190,6 +191,7 @@ Core, rendering, IPC, kernel, panel manager.
|
|||||||
|
|
||||||
### D-53: State persistence across sessions
|
### D-53: State persistence across sessions
|
||||||
- **Date:** 2026-04-22
|
- **Date:** 2026-04-22
|
||||||
|
- **Amendment (2026-06-11):** Per [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), this state moves from in-repo `.clide/settings.yaml` to user-scope storage keyed by workspace-path hash. The `.clide/settings.yaml` references below are retained for history.
|
||||||
- **Decision:** The following layout state is persisted across app restarts: collapse state of left and right panels, active left section (tickets/decisions/files/git/pr), active right context type, pql pane expanded/collapsed, editor split ratio when open, fuzzy find recent picks. Stored via `SettingsStore` in project-scoped settings (`.clide/settings.yaml`).
|
- **Decision:** The following layout state is persisted across app restarts: collapse state of left and right panels, active left section (tickets/decisions/files/git/pr), active right context type, pql pane expanded/collapsed, editor split ratio when open, fuzzy find recent picks. Stored via `SettingsStore` in project-scoped settings (`.clide/settings.yaml`).
|
||||||
- **Rationale:** Users expect their workspace layout to survive restarts. Without persistence, every launch starts at the default layout preset, which is disorienting when the user has customised their column widths and panel states.
|
- **Rationale:** Users expect their workspace layout to survive restarts. Without persistence, every launch starts at the default layout preset, which is disorienting when the user has customised their column widths and panel states.
|
||||||
- **Cost:** Adds write-on-change to several layout operations. Must handle migration if the setting keys evolve. `.clide/settings.yaml` is already gitignored, so personal layout state stays personal.
|
- **Cost:** Adds write-on-change to several layout operations. Must handle migration if the setting keys evolve. `.clide/settings.yaml` is already gitignored, so personal layout state stays personal.
|
||||||
@@ -469,4 +471,75 @@ Core, rendering, IPC, kernel, panel manager.
|
|||||||
- **Relationship:** Narrows [Q-4](../questions/architecture.md#q-4-canvas-schema-compatibility-with-obsidian) — clide's canvas is its own HTML-canvas-inspired JSON; Obsidian `.canvas` is an *import* format via conversion, not the native schema. Consumes the stdin/`--file` JSON input plumbing (T-315). Subsumes the standalone icon card (T-313) and image-annotation work (T-316) as templates of this card. **Merges the former Tier-5 "canvas and graph view" epic (T-7) into one canvas epic (T-317):** the Tier-5 canvas *pane* (T-322, interactive/editable — distinct from the display-only conversation card) and graph *view* (T-323) consume the same shared renderer; T-7 is cancelled as superseded. The conversation drawing card stays display-only per [D-78]; the canvas pane is a full interactive pane. (D-17 "panels are extension-shaped" is unaffected and still governs the panes.)
|
- **Relationship:** Narrows [Q-4](../questions/architecture.md#q-4-canvas-schema-compatibility-with-obsidian) — clide's canvas is its own HTML-canvas-inspired JSON; Obsidian `.canvas` is an *import* format via conversion, not the native schema. Consumes the stdin/`--file` JSON input plumbing (T-315). Subsumes the standalone icon card (T-313) and image-annotation work (T-316) as templates of this card. **Merges the former Tier-5 "canvas and graph view" epic (T-7) into one canvas epic (T-317):** the Tier-5 canvas *pane* (T-322, interactive/editable — distinct from the display-only conversation card) and graph *view* (T-323) consume the same shared renderer; T-7 is cancelled as superseded. The conversation drawing card stays display-only per [D-78]; the canvas pane is a full interactive pane. (D-17 "panels are extension-shaped" is unaffected and still governs the panes.)
|
||||||
- **Raised by:** 2026-06-10 — user, while refining the icon-preview card (T-313): "make it all into one drawing card that receives a json input and selects based on the context inside the json what to draw … pull the entire thing closer to a dynamic canvas than a bunch of one-off renderers." Clarified the model is HTML `<canvas>` (not Obsidian's), templates-over-primitives, per-object label/description, and reuse as the `.canvas` renderer; before/after comparisons, SVGs, icons, and graphs all become things you send into the card.
|
- **Raised by:** 2026-06-10 — user, while refining the icon-preview card (T-313): "make it all into one drawing card that receives a json input and selects based on the context inside the json what to draw … pull the entire thing closer to a dynamic canvas than a bunch of one-off renderers." Clarified the model is HTML `<canvas>` (not Obsidian's), templates-over-primitives, per-object label/description, and reuse as the `.canvas` renderer; before/after comparisons, SVGs, icons, and graphs all become things you send into the card.
|
||||||
|
|
||||||
|
### D-93: clide writes no directories of its own into the workspace
|
||||||
|
- **Date:** 2026-06-11
|
||||||
|
- **Decision:** clide-the-IDE contributes **zero** directories to a workspace. The only tool-owned directories physically written into a repo are `.git/` (git's, brought by the user) and `.pql/` (pql's repo data — index + planning changelog). All IDE-local per-workspace state — panel collapse, active sections, split ratios, project theme, recent picks ([D-53](#d-53-state-persistence-across-sessions)), and any future per-repo extension DB — moves to **user scope**, stored outside the repo and keyed by a hash of the workspace path, the same convention the IPC socket already uses ([D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)). If clide ever needs shared, *committed* per-repo config, it lives as clide-owned keys in `.pql/config.yaml` (the existing `ignore_files:` precedent, [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)/[D-4](#d-4-ignore-file-strategy)) — never a new directory.
|
||||||
|
- **Context:** clide previously wrote project-scoped settings to an in-repo `.clide/` directory ([D-53](#d-53-state-persistence-across-sessions)). Even gitignored, that put an IDE scratch dir physically inside the user's repo. "Written in the repo" — not "checked in" — is the thing being minimized.
|
||||||
|
- **Rationale:** One tool dir in the repo (`.pql/`), and it earns its place because it holds data *about* the repo. Personal IDE state is not repo data, so it belongs in user scope — exactly where [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic) and [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) already keep per-workspace runtime state. Nothing shared is lost: `.clide/settings.yaml` was already gitignored, so it was never committed anyway.
|
||||||
|
- **Cost:** A one-time migration of any existing in-repo `.clide/settings.yaml` to user scope, then dropping the dir. Per-workspace state inherits [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)'s trade-off: moving or renaming a repo re-keys it and resets personal layout.
|
||||||
|
- **Amends [D-4](#d-4-ignore-file-strategy):** D-4's clause "`.clide/`) is added to `.gitignore` at install time" is moot — clide no longer writes `.clide/` into the repo. Only `.pql/` is added to `.gitignore` at install time.
|
||||||
|
- **Amends [D-53](#d-53-state-persistence-across-sessions):** persisted layout state moves from in-repo `.clide/settings.yaml` to user-scope storage keyed by workspace-path hash.
|
||||||
|
- **Cross-reference:** [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-4](#d-4-ignore-file-strategy), [D-53](#d-53-state-persistence-across-sessions), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed).
|
||||||
|
- **Raised by:** 2026-06-11 — user: "I am not a fan of IDEs tossing in multiple dirs … only the pql dir which contains repo data gets [written] in repo."
|
||||||
|
|
||||||
|
### D-94: Workspace mode is a first-class, extensible declared capability
|
||||||
|
- **Date:** 2026-06-11
|
||||||
|
- **Decision:** A clide workspace runs in exactly one **mode** at a time, drawn from an open, extensible vocabulary — initially `edit` (full local read/write; the default) and `read` (read-only; no writable `.pql/`), with `remote`, `ssh`, and `webui` reserved as future values. Every extension declares the modes it supports in its manifest (`modes: [edit, read]`); an extension with no declaration is assumed `edit`-only. The extension host activates an extension only when the active workspace mode is in its declared set — unsupported extensions stay dormant. New modes are added as new vocabulary values **without schema changes**; the open SSH-remote question ([Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace)) is expected to resolve *into* a mode value, not a parallel mechanism.
|
||||||
|
- **Context:** Read-mode degrade ([D-95](#d-95-workspace-validity-and-onboarding-flow)) needs to know which extensions remain functional without pql and without write access. A boolean `read_mode_safe` would answer only today's question and would not compose with the `remote`/`ssh`/`webui` modes already on the horizon.
|
||||||
|
- **Rationale:** Modelling capability as a declared mode set is uniform and future-proof — one mechanism the host gates on, one place extensions opt in, and third-party extensions participate by declaring. Reserving the future values now means remote/ssh/webui work plugs into an existing seam instead of inventing its own.
|
||||||
|
- **Cost:** Every builtin extension must declare its modes (a one-time classification pass); the host gains mode-gating logic; the vocabulary is open-ended and must stay coherent as values accrue. Defaulting an undeclared extension to `edit`-only is conservative but may surprise authors.
|
||||||
|
- **Cross-reference:** [D-17](extensions.md#d-17-panels-are-extension-shaped-from-day-one), [D-95](#d-95-workspace-validity-and-onboarding-flow), [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace).
|
||||||
|
- **Raised by:** 2026-06-11 — user: "read_mode_safe: true is not leaving space for further modes (ssh mode, remote mode, webui mode, read mode, edit mode). Prepare it for that."
|
||||||
|
|
||||||
|
### D-95: Workspace validity and onboarding flow
|
||||||
|
- **Date:** 2026-06-11
|
||||||
|
- **Decision:** A clide workspace is valid only when it is a git repo with an initialized `.pql/`. Two consequences. **(1) Git is a precondition the user owns.** clide never auto-runs `git init`; opening a non-git folder *offers* initialization (**default no**, with a guard that warns when a parent `.git` would make this a nested repo) or lets the user pick another folder. **(2) pql is clide-provisioned.** Because pql now ships bundled ([D-92](tooling.md#d-92-ship-pql-bundled-with-clide)), an uninitialized repo triggers a **required, idempotent** prep flow that reconciles state (virgin / pql-user / partially-init / fully-init / previously-declined) and **discloses exactly what it writes** — `.gitignore` entries for `.pql/`, pql's config, and (only on opt-in) git hooks. The mandatory floor is pql **config + index** (the files/query/ignore engine); the **planning layer** (decisions/tickets + the changelog hooks of [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code), which alter the user's git workflow) is a **contextual opt-in** offered when the user first opens the Decisions or Tickets surface — never forced at onboarding. A writable repo with no `.pql/` is *invalid-until-initialized*; a repo clide **cannot** write (read-only mount, no permission) degrades to **read mode** ([D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability)) — file tree, editor, and the pure-Dart content search ([D-79](#d-79-workspace-content-search-is-a-pure-dart-in-process-engine-outside-pql)) stay live; pql-backed surfaces go dark behind a clear banner. A decline is remembered in user scope, keyed by repo path; clide does not re-nag, and an explicit "initialize workspace" command is always available.
|
||||||
|
- **Context:** [D-4](#d-4-ignore-file-strategy) already specified that `.pql/` is "added to `.gitignore` at install time" — presuming an install-time event that never had a trigger. Bundling pql ([D-92](tooling.md#d-92-ship-pql-bundled-with-clide)) is what makes "pql required" honest: clide can always provide the means to create `.pql/`. This record is that missing trigger.
|
||||||
|
- **Rationale:** pql is clide's core query/ignore engine, not just the ticket board, so a repo without it is degraded for *core editing*, not only planning — gating on `.pql/` is truthful. Git, by contrast, is a foundational, identity-level user decision (and `git init` in the wrong place is a footgun), so clide offers but never imposes it. Splitting the mandatory config+index from the opt-in planning hooks keeps the invasive git-workflow change consensual and contextual. Read-mode degrade keeps clide usable as an editor on repos it cannot write — consistent with [D-80](#d-80-filesread-allows-trusted-claude-config-roots-beyond-the-workspace)'s read appetite — instead of refusing them outright.
|
||||||
|
- **Cost:** An onboarding/state-reconciliation flow with a disclosing modal. The installer must handle the known hooks gotcha (`pql init` writes to `.git/hooks` and ignores an existing `core.hooksPath`) — it must not silently clobber a repo that sets `core.hooksPath`. The read-mode path gates extensions by their declared modes ([D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability)) and must provide graceful fallbacks where pql surfaces go dark.
|
||||||
|
- **Cross-reference:** [D-3](#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-4](#d-4-ignore-file-strategy), [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code), [D-79](#d-79-workspace-content-search-is-a-pure-dart-in-process-engine-outside-pql), [D-80](#d-80-filesread-allows-trusted-claude-config-roots-beyond-the-workspace), [D-92](tooling.md#d-92-ship-pql-bundled-with-clide), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability).
|
||||||
|
- **Raised by:** 2026-06-11 — user, this planning session: a repo without `.pql/` is "invalid for clide"; non-git folder → "offer default no"; planning hooks contextual; unwritable repos degrade.
|
||||||
|
|
||||||
|
### D-96: Remote-execution footprint — no-install ssh-exec
|
||||||
|
- **Date:** 2026-06-12
|
||||||
|
- **Decision:** SSH-remote workspaces (T-329, shape A of [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace)) use **stock OpenSSH only — nothing clide-specific is ever installed on the remote.** Interactive surfaces (terminal panes, the Claude process) run over `ssh -tt` PTY channels; command-style subsystems (git, pql, file ops, search) run as exec channels multiplexed over a persistent **ControlMaster** connection; file watching degrades to **polling** (debounced mtime/git-status sweep, `inotifywait` used opportunistically when present) that emits the same FileChange events, so the UI layer is unaware of the difference. Subsystems reach the remote through a `RemoteExecutionContext` seam instead of bare `Process.run`/`File`/`Directory`. The rejected alternative — an auto-pushed self-managed remote agent (VS Code Remote model) — would have bought native inotify and a stateful remote backend at the price of deploying and version-managing clide components on the remote.
|
||||||
|
- **Rationale:** The user's standing constraint is decisive: no clide components to install, update, GC, or version-reconcile on remote machines. Zero-footprint also dissolves the agent model's open sub-questions (placement, multi-client sharing, version skew, cleanup) — they simply don't arise. The costs (per-command round-trip, polling watcher) are bounded and amortizable (ControlMaster reuses one authenticated connection); the agent model's costs are operational and permanent.
|
||||||
|
- **D-56 reconciliation:** [D-56](#d-56-dissolve-daemon-process-flutter-app-hosts-ipc-server)'s "single process" rule is *strengthened*, not bent: with no-install there is no second clide process anywhere — the local Flutter app remains the only clide process, and the remote side is plain sshd + the tools already on the box. [D-5](#d-5-dart-core-sidecar-dissolved-ptyc-as-pql-peer) is likewise untouched — ssh is an external tool clide shells out to, not a second core language or runtime.
|
||||||
|
- **Cost:** Every remote command pays an SSH round-trip (ControlMaster removes handshake cost, not latency). Watching is polling-grade — change events arrive on the sweep cadence, not instantly. The execution-context seam must be threaded through each subsystem that touches the filesystem or spawns processes; that sweep is the bulk of T-336. No stateful remote backend means event streams are synthesized locally from command results.
|
||||||
|
- **Cross-reference:** Resolves [Q-23](../questions/architecture.md#q-23-ssh-remote-development--run-clide-against-a-remote-workspace). [D-97](#d-97-ssh-workspace-uri--system-ssh-auth) (naming + auth), [D-98](#d-98-remote-tool-contract--connect-preflight) (what must exist remotely), [D-99](#d-99-remote-session-identity-keyed-on-host-workspace) (identity), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability) (remote workspaces surface as a workspace mode — the reserved `ssh` value — so extensions gate on it declaratively). Implemented by the T-329 epic; execution layer is T-336.
|
||||||
|
- **Raised by:** 2026-06-12 — user, resolving the T-330 footprint spike: "go with the no-install ssh-exec model."
|
||||||
|
|
||||||
|
### D-97: ssh:// workspace URI + system-ssh auth
|
||||||
|
- **Date:** 2026-06-12
|
||||||
|
- **Decision:** A remote workspace is named by the URI `ssh://[user@]host[:port]/abs/remote/path`. `host` may be a `~/.ssh/config` alias; user/port are optional and, when absent, resolve through ssh's own config machinery. **Auth delegates entirely to system ssh** — agent, keys, `~/.ssh/config`, ProxyJump, all of it; clide never stores credentials or implements an auth flow of its own. v1 connections run ssh in **BatchMode** (non-interactive): when auth would prompt, the connect fails with an actionable message ("set up key auth / ssh-agent for <host>") instead of clide hosting a password dialog. Windows (no standard ssh config surface) is an acknowledged v1 gap. The `WorkspaceRef` value type (T-332) is the canonical carrier — parse/round-trip of this URI, `host:path` display form, bare-path = local.
|
||||||
|
- **Rationale:** Matches the epic's locked auth posture and pql's "wrap, don't duplicate" instinct applied to OpenSSH: the user's existing ssh config is the source of truth, and anything clide reimplements (agents, prompts, jump hosts) would be a worse, second implementation of it. BatchMode keeps the failure mode crisp instead of wedging a TTY prompt inside a GUI flow.
|
||||||
|
- **Cost:** First-run UX depends on the user's ssh hygiene — no in-app password fallback. Host-alias resolution means the same workspace can be reachable under two names (`buildbox` vs `buildbox.lan`) and be keyed as two identities ([D-99](#d-99-remote-session-identity-keyed-on-host-workspace) keys on the *given* host string; aliasing dedupe is deliberately not attempted).
|
||||||
|
- **Cross-reference:** [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec), [D-99](#d-99-remote-session-identity-keyed-on-host-workspace). Implemented by T-332 (WorkspaceRef landed 2026-06-12; open-flow pending T-336).
|
||||||
|
- **Raised by:** 2026-06-12 — T-330 spike artifacts, URI shape locked at epic planning (2026-06-10).
|
||||||
|
|
||||||
|
### D-98: Remote-tool contract + connect preflight
|
||||||
|
- **Date:** 2026-06-12
|
||||||
|
- **Decision:** What must exist on the remote, and what merely degrades. **Required:** a POSIX shell and `git` — without them the workspace cannot open (workspace validity, [D-95](#d-95-workspace-validity-and-onboarding-flow), requires a git repo). **Optional, degrading:** `pql` — absent, the planning/query surfaces (tickets, decisions, vault queries) go dark behind a banner, mirroring [D-95](#d-95-workspace-validity-and-onboarding-flow)'s read-mode degrade; clide cannot provision pql remotely under [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec)'s no-install rule, so the banner tells the user what to install where. **Optional, degrading:** `claude` — absent, the Claude pane is disabled with a notice; terminal/editor/git stay fully live. On connect, a single batched preflight command probes all of these (one round-trip: `command -v` + version for each) and the result drives the degrade set; a missing *required* tool fails the open with the probe output.
|
||||||
|
- **Rationale:** The contract keeps "remote" honest without smuggling an installer in: clide states what it found, works with what's there, and never mutates the remote toolset. One batched probe respects the per-command latency cost [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec) accepts. Folding pql-absence into the existing degrade vocabulary (D-94 modes / D-95 banner) reuses a shipped pattern instead of inventing a remote-special one.
|
||||||
|
- **Cost:** A degraded-but-open remote workspace is a new partial state to keep coherent (which surfaces dark, which live). Version *skew* (remote pql older than the bundled local one) is real and detected by the preflight but only surfaced, not reconciled, in v1.
|
||||||
|
- **Cross-reference:** [D-92](tooling.md#d-92-ship-pql-bundled-with-clide) (bundling is local-only under no-install), [D-94](#d-94-workspace-mode-is-a-first-class-extensible-declared-capability), [D-95](#d-95-workspace-validity-and-onboarding-flow), [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec).
|
||||||
|
- **Raised by:** 2026-06-12 — T-330 spike artifacts ("decide the remote-tool contract: what must exist remotely, whether pql is hard-required or degrades, and how a preflight surfaces what is missing").
|
||||||
|
|
||||||
|
### D-99: Remote session identity keyed on (host, workspace)
|
||||||
|
- **Date:** 2026-06-12
|
||||||
|
- **Decision:** Workspace-keyed identity generalizes from *path* to *(host, path)* — local workspaces are `(null, path)`, so nothing changes for them. Consequences: Claude session identity ([D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed)'s one-primary-per-repo, [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer)'s stream-json sessions) re-keys on (host, repo) — the same repo path on two hosts (or local + remote) is two distinct sessions, never one; Claude's `--resume` transcripts live on the host where claude runs (the remote's `~/.claude/…`), which falls out naturally because claude is spawned remotely. Per-workspace user-scope state ([D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace)/[D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic)'s path-hash keying) hashes the WorkspaceRef canonical URI instead of the bare path — same generalization, same machinery. The host string is taken as given (alias ≠ FQDN; no dedupe, per [D-97](#d-97-ssh-workspace-uri--system-ssh-auth)).
|
||||||
|
- **Rationale:** Path-only keying would silently fuse two different machines' checkouts of the same repo path into one session/layout/socket identity — wrong in every case. Hashing the canonical URI is the smallest amendment that fixes this everywhere at once, because every consumer already keys off one derived string.
|
||||||
|
- **Amends [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed) / [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer):** "per repo" reads as "per (host, repo)" throughout; local keeps its existing identity (null host hashes identically to the pre-amendment bare path — no migration).
|
||||||
|
- **Cost:** Renaming a host alias re-keys its sessions and layout state (accepted; same trade-off D-70 already made for moved repos).
|
||||||
|
- **Cross-reference:** [D-41](#d-41-claude-panes-one-primary-per-repo-tmux-backed), [D-70](#d-70-ipc-socket-path-is-per-workspace-deterministic), [D-77](#d-77-drive-claude-via-the-stream-json-control-protocol-teams-become-a-clide-owned-coordination-layer), [D-93](#d-93-clide-writes-no-directories-of-its-own-into-the-workspace), [D-96](#d-96-remote-execution-footprint--no-install-ssh-exec), [D-97](#d-97-ssh-workspace-uri--system-ssh-auth). Implemented across T-332 (identity carrier) and T-333 (session re-key).
|
||||||
|
- **Raised by:** 2026-06-12 — T-330 spike artifacts ("session identity keyed on (host, repo) amending D-41/D-77").
|
||||||
|
|
||||||
|
### D-101: ClideSettings — one live-preferences access facade
|
||||||
|
- **Date:** 2026-06-17
|
||||||
|
- **Decision:** Live, user-selectable preferences are read through a single widget-facing facade, `ClideSettings`, namespaced by concern: `ClideSettings.fonts.monoOf(context)` / `.fonts.uiOf(context)`, `ClideSettings.theme.of(context)`, `ClideSettings.i18n.of(context)`. Values originate in the kernel `SettingsStore`; the app root (`root_shell`) resolves them and provides a `ClideSettingsScope` InheritedWidget (carrying the font families), rebuilding it on a settings change so dependents re-read live. Theme and i18n **delegate** to their existing live providers (`ClideTheme` / the `I18n` service) rather than being duplicated — one source of truth. Reads outside a scope fall back to the bundled font defaults, so a widget renders without a provider (isolated tests). "Plumb once, use many."
|
||||||
|
- **Rationale:** Before this, each live setting had its own ad-hoc read path — theme via `ClideTheme.of`, i18n via `ClideKernel.of(context).i18n`, fonts as a compile-time `const` that couldn't change at runtime at all. A new preference meant inventing another path. One facade gives every current and future preference a uniform, discoverable read site and a single root resolution point — without a mutable global (the shortcut rejected during the T-471 font-flow design) and without a big-bang rewrite of the established theme/i18n providers (they delegate, so their consumers migrate incrementally).
|
||||||
|
- **Cost:** Two read paths coexist during migration — `ClideTheme.of` / `i18n.string` still work (the facade delegates to them), so their hundreds of call sites move to `ClideSettings.theme` / `.i18n` incrementally rather than at once. The font consts (`clideMonoFamily` / `clideUiFamily`) remain as the facade's defaults; ~11 context-less helper sites still read the const directly, migrated in a follow-up (T-472). The facade lives in `widgets` and reaches into `kernel` for the theme/i18n delegates (a dependency already present).
|
||||||
|
- **Cross-reference:** Fonts landed it: T-460 (Inter default + UI picker) and T-471 (mono picker) migrated ~93 sites onto `ClideSettings.fonts`. Consumer migration of theme + i18n onto the facade, and the context-less font stragglers (T-472), are staged follow-ups. Values live in the kernel `SettingsStore`.
|
||||||
|
- **Raised by:** 2026-06-17 — user, during T-471 font-flow design: "I do see reason in nesting them all in one settings object that dynamically loads so we can extend it in the future… plumb once, use many."
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Q&D record system itself, kanban, commit conventions, changelog.
|
|||||||
- **Rationale:** Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild does not. Imperative mood reads better for a project-governance log; types are noise when every commit is scoped to a subsystem already.
|
- **Rationale:** Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild does not. Imperative mood reads better for a project-governance log; types are noise when every commit is scoped to a subsystem already.
|
||||||
- **Cost:** Contributors with Conventional Commits muscle memory adjust.
|
- **Cost:** Contributors with Conventional Commits muscle memory adjust.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
- **Amendment (2026-06-17):** Reversed — the rebuild **does** use [Conventional Commits 1.0](https://www.conventionalcommits.org/en/v1.0.0/) after all. Format is `type(scope): imperative subject`, with the standard type set (`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `chore`); `scope` is the subsystem (`settings`, `vim`, `pty`, `plan`, …); append `!` after the scope for a breaking change; keep a trailing `(T-NNN)` ticket ref where one applies. Subject (prefix included) stays ≤ 72 chars; the no-emoji, body, HEREDOC, and attribution-trailer rules from the original decision are unchanged. Practice had already drifted to this form (`feat(settings): category rail + navigation (T-447)`); the decision now matches it. `.claude/skills/git-commit/SKILL.md`, `CONTRIBUTING.md`, and `POLICY.md` updated to suit. **Why the reversal:** the original "types are noise" call didn't hold up — scoped types make `git log` skimmable and the changelog subsection (Added/Fixed/…) maps cleanly onto the commit type.
|
||||||
|
|
||||||
### D-38: Changelog discipline — Keep a Changelog 1.1.0
|
### D-38: Changelog discipline — Keep a Changelog 1.1.0
|
||||||
- **Date:** 2026-04-21
|
- **Date:** 2026-04-21
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ Test pyramid, drivers, client-side constraint.
|
|||||||
- **Cost:** Goldens have zero real text; layouts rely on widget tests. Acceptable.
|
- **Cost:** Goldens have zero real text; layouts rely on widget tests. Acceptable.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
### D-25: Mocks — mocktail at IO, hand-rolled fakes for ChangeNotifiers
|
### D-25: Mocks — hand-rolled fakes throughout; mocktail dropped
|
||||||
- **Date:** 2026-04-21
|
- **Date:** 2026-04-21 (amended 2026-06-12)
|
||||||
- **Decision:** `mocktail 1.0.4` mocks IO boundaries (sockets, processes, `dart:io` File/Directory). `ChangeNotifier` facades get hand-rolled fakes — tiny classes that extend `ChangeNotifier` with test-controlled setters. No `mocktail` for notifiers.
|
- **Decision:** Test doubles are hand-rolled fakes — tiny classes that extend the real base (`ChangeNotifier` facades, `StreamJsonProcess`, `DaemonClient`) with test-controlled setters. **Amendment (2026-06-12, T-385):** `mocktail` was originally pinned for IO boundaries, but after the T-91 coverage drive it had zero imports — every IO seam ended up with an injected hand-rolled fake (`FakeDaemonClient`, fake process factories, recording event sinks) instead. The unused dep is dropped; the no-mocks-for-notifiers rule stands and in practice covers IO seams too.
|
||||||
- **Rationale:** Mocking a `ChangeNotifier` with a generated mock hides subscription bugs — `notifyListeners` becomes a mock call instead of actually firing. Hand-rolled fakes exercise the real subscription machinery.
|
- **Rationale:** Mocking a `ChangeNotifier` with a generated mock hides subscription bugs — `notifyListeners` becomes a mock call instead of actually firing. Hand-rolled fakes exercise the real subscription machinery. The same held at IO seams: constructor-injected fakes kept tests on real control flow.
|
||||||
- **Cost:** Roughly 20 lines per fake. Rounds out to less code than configuring a mocktail whenCall chain.
|
- **Cost:** Roughly 20 lines per fake. Rounds out to less code than configuring a mocktail whenCall chain.
|
||||||
- **Raised by:** 2026-04-21 planning.
|
- **Raised by:** 2026-04-21 planning.
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -91,4 +92,21 @@ Toolchain, supply chain, CI, ignore strategy.
|
|||||||
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-61](#d-61-dependency-vetting-checklist), `POLICY.md`.
|
- **Cross-reference:** [D-31](#d-31-prefer-zero-deps-exact-pin), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-61](#d-61-dependency-vetting-checklist), `POLICY.md`.
|
||||||
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
|
- **Raised by:** 2026-04-26 policy-to-decision migration (T-28).
|
||||||
|
|
||||||
|
### D-92: Ship pql bundled with clide
|
||||||
|
- **Date:** 2026-06-11
|
||||||
|
- **Decision:** clide ships `pql` as a vendored, version-pinned native binary — the same model used for git via dugite ([D-59](#d-59-bundled-git-via-dugite-native)). The pinned binary lives under `native/<platform>/` with a `BUILD.md` provenance record ([D-63](#d-63-vendored-binary-rebuild-process)) and an `assets/licenses.yaml` entry ([D-42](#d-42-dependencies-documented-in-licensesyaml), [D-65](#d-65-license-compatibility-matrix)). Resolution order is: `CLIDE_PQL_BIN` env override (dev escape hatch — e.g. pointing at a pql built side-by-side) → bundled binary resolved against the **install directory** (next to the executable, never workspace-relative) → system `pql` on PATH. The bundled copy never self-updates — the pin is the contract, so `pql self-update` is inert for it. A version floor is enforced *softly*: if the resolved pql (override or PATH) is older than the pinned floor, the Problems panel surfaces it rather than clide silently mis-driving an incompatible binary. CI runs the in-tree binary instead of provisioning pql on the runner.
|
||||||
|
- **Context:** pql is clide's files/query/ignore engine **and** its planning engine ([D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)), yet it was an unmanaged external dependency: users installed and updated it themselves, with no version pin. Beyond the "update the binary, then install it" friction, an old PATH pql replaying the changelog ([D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code)) is a latent *corruption* risk, not merely a missing-feature one. This is a distribution gap, not an architecture one.
|
||||||
|
- **Rationale:** Bundling makes a fresh clone/install work with zero separate pql setup, pins the version clide was tested against (closing the changelog-schema-skew risk), and reuses the proven dugite pattern and its supply-chain gates ([D-60](#d-60-no-network-on-default-launch-path)/[D-61](#d-61-dependency-vetting-checklist)/[D-63](#d-63-vendored-binary-rebuild-process)). It is **additive, not a fork**: pql stays a standalone tool, clide still wraps and never reimplements ([D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates)), and pql's universality for terminal/VS Code users is untouched. pql being a pure-Go, no-CGo static binary makes per-platform bundling cheap.
|
||||||
|
- **Cost:** A pinned binary per shipped platform (linux-x64 now; macOS arm64/x64 when those builds land), each carried through the [D-63](#d-63-vendored-binary-rebuild-process) rebuild ritual on every pql release — the same bump cadence as dugite and tree-sitter. Resolving the bundled binary against the install dir and **never** a workspace-relative path is mandatory: a repo could otherwise plant `native/pql` and gain code execution (the T-98 dugite lesson).
|
||||||
|
- **Cross-reference:** [D-3](architecture.md#d-3-pql-as-supporter-tool-clide-wraps-never-duplicates), [D-59](#d-59-bundled-git-via-dugite-native), [D-60](#d-60-no-network-on-default-launch-path), [D-61](#d-61-dependency-vetting-checklist), [D-63](#d-63-vendored-binary-rebuild-process), [D-42](#d-42-dependencies-documented-in-licensesyaml), [D-65](#d-65-license-compatibility-matrix), [D-67](process.md#d-67-pql-changelog-files-are-committed-alongside-code).
|
||||||
|
- **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."
|
||||||
|
|||||||
@@ -60,9 +60,15 @@ ticket persistence.
|
|||||||
- **Source:** 2026-04-21 planning.
|
- **Source:** 2026-04-21 planning.
|
||||||
|
|
||||||
### Q-23: SSH-remote development — run clide against a remote workspace
|
### Q-23: SSH-remote development — run clide against a remote workspace
|
||||||
- **Status:** Open
|
- **Status:** Resolved → [D-96](../decisions/architecture.md#d-96-remote-execution-footprint--no-install-ssh-exec), [D-97](../decisions/architecture.md#d-97-ssh-workspace-uri--system-ssh-auth), [D-98](../decisions/architecture.md#d-98-remote-tool-contract--connect-preflight), [D-99](../decisions/architecture.md#d-99-remote-session-identity-keyed-on-host-workspace)
|
||||||
|
- **Resolved (2026-06-12):** Shape (A) — execution remote, UI local — with the **no-install ssh-exec** footprint (user pick): stock OpenSSH only, `ssh -tt` PTYs + ControlMaster exec channels, polling watcher, zero clide components on the remote (D-96). Naming/auth via `ssh://` URI + system ssh in BatchMode (D-97); remote-tool contract with batched connect preflight (D-98); session + state identity re-keyed on (host, repo), amending D-41/D-77 (D-99). Implementation: T-329 epic, execution layer T-336.
|
||||||
- **Question:** Clide today assumes the workspace, the daemon, and the Flutter UI all run on the same machine. A growing class of users edits on remote systems (build servers, GPU boxes, cloud dev environments). What's the architecture for "open repo on host-B from UI on host-A"? Two shapes: (A) daemon-on-remote — clide's Dart daemon runs on the remote; the app talks to it over an SSH-tunnelled unix socket or a dedicated TCP socket (mTLS?), pty/process/filesystem work stays server-side; local app is pure UI. (B) filesystem-mounted — remote mounted via sshfs/9p/rclone, daemon runs locally against the mount; simpler but every fs op + git call crosses the network, and PTYs get complicated (local shell on remote filesystem? ssh-exec per command?). (A) matches VS Code Remote / JetBrains Gateway; (B) matches nothing load-bearing. Sub-questions either way: auth (ssh-agent? per-project keys? OIDC?), tmux / Claude session persistence semantics (does primary-per-repo re-key on host + repo?), multi-host identity in `.pql/pql.db`, latency tolerance for the event stream, re-sync on disconnect.
|
- **Question:** Clide today assumes the workspace, the daemon, and the Flutter UI all run on the same machine. A growing class of users edits on remote systems (build servers, GPU boxes, cloud dev environments). What's the architecture for "open repo on host-B from UI on host-A"? Two shapes: (A) daemon-on-remote — clide's Dart daemon runs on the remote; the app talks to it over an SSH-tunnelled unix socket or a dedicated TCP socket (mTLS?), pty/process/filesystem work stays server-side; local app is pure UI. (B) filesystem-mounted — remote mounted via sshfs/9p/rclone, daemon runs locally against the mount; simpler but every fs op + git call crosses the network, and PTYs get complicated (local shell on remote filesystem? ssh-exec per command?). (A) matches VS Code Remote / JetBrains Gateway; (B) matches nothing load-bearing. Sub-questions either way: auth (ssh-agent? per-project keys? OIDC?), tmux / Claude session persistence semantics (does primary-per-repo re-key on host + repo?), multi-host identity in `.pql/pql.db`, latency tolerance for the event stream, re-sync on disconnect.
|
||||||
- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-5` and `D-6`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind.
|
- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-5` and `D-6`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind. The 2026-06-11 Fable review (fable-ous.md Part IV, Tier 3 #14) reframed the differentiator and ranked T-329 as undersold: the agent runs on the buildbox while permission prompts render natively local — VS Code Remote moves the *editor*; nobody remotes the *agent control channel*. That framing favours shape (A).
|
||||||
|
- **Triage (2026-06-12):** Shape (A) is effectively settled (T-329 epic locked: execution remote, UI + clipboard local, system ssh auth for v1). The model-independent backbone is proceeding: Phase 1 (T-331, `DaemonTransport` seam) landed. What remains open is the **footprint model** — the user decision T-330 gates on:
|
||||||
|
1. **No-install ssh-exec** — zero remote footprint; stock `ssh -tt` PTYs + ControlMaster command channels; watching degrades to polling; heavier subsystem surface locally.
|
||||||
|
2. **Auto-pushed self-managed agent** (VS Code Remote model) — clide deploys/version-checks a headless agent binary on connect; native inotify + stateful backend; but installs clide components on the remote, which the user has said they don't want to manage.
|
||||||
|
|
||||||
|
If (2), the agent sub-questions need answers before T-336 expands: placement (per-host `~/.clide/agent` likely), GC on disconnect/version-bump/repo-removal, multi-client sharing (IpcServer is already multi-connection, D-72), version skew (version-named binaries coexist). Either way the **remote-tool contract** needs a D-record: what must exist remotely (git/shell at minimum), whether pql is hard-required or degrades, and how a connect-preflight surfaces gaps. Evidence gap: the ControlMaster per-command latency probe (T-330) needs a reachable sshd — none in the dev environment; run it against a real remote before deciding if latency is the deciding factor.
|
||||||
- **Source:** 2026-04-22 planning (user-raised).
|
- **Source:** 2026-04-22 planning (user-raised).
|
||||||
|
|
||||||
### Q-22: Ticket persistence strategy
|
### Q-22: Ticket persistence strategy
|
||||||
@@ -153,4 +159,17 @@ ticket persistence.
|
|||||||
- **Context:** The only unshipped piece of the otherwise-complete native-Claude epic (T-132). Blocked on data availability, not on clide work — hence a question (when/how to revisit) rather than active scope. Option (c) interacts with D-75's "version-pinned coupling to CC internals" posture. Resolved by T-158 when a viable path lands.
|
- **Context:** The only unshipped piece of the otherwise-complete native-Claude epic (T-132). Blocked on data availability, not on clide work — hence a question (when/how to revisit) rather than active scope. Option (c) interacts with D-75's "version-pinned coupling to CC internals" posture. Resolved by T-158 when a viable path lands.
|
||||||
- **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?
|
||||||
|
- **Status:** Resolved → [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling)
|
||||||
|
- **Resolved (2026-06-15):** **Fence** (option a). Every `dart:ffi` importer moves behind a conditional-import facade with a web stub, and a `flutter build web --wasm` compile gate is added so it can't rot. The maintainer wants the web/WASM build kept alive as a hopeful future target; desktop fidelity is untouched (stubs exist only on the web target). Functional web parity is explicitly not promised — only that the tree compiles to wasm and the e2e/Playwright harness can run again. Implementation tracked in T-438. See [D-100](../decisions/tooling.md#d-100-fence-dartffi-behind-conditional-imports--web-stubs-to-keep-the-webwasm-target-compiling).
|
||||||
|
- **Question:** `flutter build web --wasm` no longer compiles: the tree-sitter FFI pivot and the native PTY both import `dart:ffi` unconditionally, which the wasm target forbids. That kills `make test-e2e` / `ui-dev` / `ui-smoke` and the Playwright harness regardless of the `cd app` staleness T-384 fixed. Options: (a) fence every `dart:ffi` import behind conditional imports with web stubs (ongoing tax on every future native binding, for a target CLAUDE.md calls "a happy accident"); (b) keep the harness parked and re-evaluate if/when a web build matters (D-26's Playwright driver stays dormant); (c) drop the web target + `tools/ui/` harness formally and amend D-26/D-32. The guardrail says don't compromise desktop fidelity for web — (a) leans against it; (b) defers; (c) is honest but irreversible-ish.
|
||||||
|
- **Context:** Surfaced 2026-06-12 while fixing T-384 (dead make targets). The mechanical path fixes (post app/-flattening) are done; the Gitea workflow's e2e job is withheld with a pointer here. The startup-regression gate (D-27) and integration tests are unaffected — only the browser/Playwright surface is blocked.
|
||||||
|
- **Source:** T-384 / 2026-06-11 Fable review (epic T-359).
|
||||||
|
|
||||||
|
### Q-51: Unify workspace lifecycle on a single fenced open primitive
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** There is no single "open workspace X" primitive — only two half-primitives in different layers. `project.open(root)` (`lib/kernel/src/project.dart`) is the only repo-targeting path and is intrinsically *in-place*: it rebuilds services in the same process, reusing the shared `daemonBus`. `newWindow()` (`lib/builtin/menubar/src/file_actions.dart`) spawns a blank detached `Process.start` with no repo argument and no env scrubbing. To open a repo in a *new* window you must spawn a blank window and then run the in-place switch inside it. Should both fold behind one `WorkspaceService.open(root, {target: thisWindow | newWindow})` that is the *sole* deriver of IPC identity from a root — and, more fundamentally, should in-place switching survive at all, or should `workspace ⇒ window ⇒ process ⇒ socket ⇒ bus ⇒ session-id` be strictly one-to-one so the leak/bleed class becomes structurally impossible?
|
||||||
|
- **Context:** Surfaced 2026-06-14 from [T-421](../../) (status-bar branch bleeds across parallel windows). The same root cause — scattered, per-entry-point workspace lifecycle with no single fencing owner — already produced T-367 (in-place switch leaked the entire previous service set) and T-269 (kept the previous repo's Claude session). If in-place switching is abolished, the teardown burden those tickets patch disappears entirely. Relevant decisions: [D-70](../decisions/architecture.md) (per-workspace socket path), [D-56](../decisions/architecture.md) (one server per workspace), [D-72](../decisions/architecture.md) (multi-connection serial dispatch).
|
||||||
|
- **Source:** T-421 / 2026-06-14 user review.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Open Questions — Design
|
||||||
|
|
||||||
|
Feature proposals from the 2026-06-11 Fable review (fable-ous.md Part IV,
|
||||||
|
epic [T-359]). Each asks the same question — are we going to implement this
|
||||||
|
feature? — so the answer can resolve into a D-record (and an initiative
|
||||||
|
ticket) or an R-record. Effort tags `[S/M/L/XL]` come from the review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q-35: Agent Blame + Session Flight Recorder — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to implement agent blame — a gutter action on any line answering *which session, which turn, which prompt, which permission grant, what it cost*, opening the native conversation at the exact `tool_use` — plus a timeline scrubber to replay a session? `[L]`
|
||||||
|
- **Context:** Two ideation lenses independently invented this. The transcript pipeline (`transcript_reader.dart`, `session_index.dart`) already parses everything needed. Cursor/Copilot cannot ship it: their logs live server-side by business design — local-and-committed transcripts are a structural moat.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 1 #1 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-36: Context X-ray — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to implement per-card token attribution ("this 40KB Bash tail is 12% of your window") plus a real compaction indicator? `[M]`
|
||||||
|
- **Context:** `stream_json_session.dart` already parses usage and contextWindow per event; the renderer owns the cards. Would fix T-244 (invisible compaction) as a side effect. Context is the scarcest resource in agent pairing and every tool renders it as one opaque percentage.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 1 #2 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-37: Trust Ledger + decision-aware permission prompts — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to implement a permission-rule ledger with provenance (which prompt, which session, which ticket; ticket-scoped expiry), and decision-aware prompts that chip the relevant D-record onto a `can_use_tool` card (edit touching pubspec.yaml → [D-31](../decisions/tooling.md#d-31-prefer-zero-deps-exact-pin), one keystroke to deny *with the decision cited*)? `[M]`
|
||||||
|
- **Context:** Governance stops being documentation and becomes live agent policy. No competitor has a queryable in-repo decision system to attempt this. Builds on the D-78 interaction-zone prompt surface.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 1 #3 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-38: Agent Activity HUD — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to build the `OperationsRegistry` (T-59) once and feed it git/pql progress (T-59), sidebar badges (T-58), compaction state (T-244), and the status strip (T-274), with [Q-34](architecture.md#q-34-how--when-to-surface-the-accountteam-token-budget-given-upstream-doesnt-expose-it)'s budget slot reserved? `[M]`
|
||||||
|
- **Context:** Four backlog tickets and one open question are secretly one feature. Prerequisite: fix the T-274 plumbing bug first or the HUD inherits blank-slot syndrome (root cause is on T-274; the ValueStream retrofit is T-386).
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 1 #4 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-39: Active Ticket Context — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to bind picking up a ticket to the session — a "working on T-NNN" chip, auto `in_progress` flip, `(T-NNN)` pre-suggested in commit messages, changelog reminder on done? `[S]`
|
||||||
|
- **Context:** Cheapest win in the review's whole feature list. Makes kanban ambient instead of homework, and makes trail/ledger features (Q-35, Q-43) reliable by giving every turn a ticket anchor.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 1 #5 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-40: Twin-timeline rewind — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to snapshot the worktree as hidden git refs (`git write-tree` → `refs/clide/checkpoints`) at every turn boundary, keyed to turn uuid, so every user-message card gains "restore files to before this"? `[L]`
|
||||||
|
- **Context:** Claude's `/rewind` only restores what Claude itself edited; clide owns both timelines. Needs a retention/GC policy for the checkpoint refs.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 2 #6 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-41: Visual Dialog — one bidirectional scene schema — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to define one bidirectional scene schema where Claude draws ([D-91](../decisions/architecture.md#d-91-unified-conversation-drawing-card-backed-by-a-canvas-renderer) canvas cards), the user annotates in the interaction zone (T-260), and annotations return as *structured geometry + flattened PNG*, not prose?
|
||||||
|
- **Context:** Two lenses converged here. The T-317/T-318 and T-260 specs should merge into one protocol *before* they become two dialects — this question is urgent in ordering even if the build is later. `[L]`
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 2 #7 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-42: Immortal terminals — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to fuse T-258 (terminal as editor-mode peer) with T-325 live-tails so any long process — user shell *or* agent-spawned build — promotes to a full tmux-backed surface that survives restart? `[M]`
|
||||||
|
- **Context:** "The build that never dies" is structural for clide, a plugin fantasy for Electron competitors. Resolving [Q-27](architecture.md#q-27-two-editor-split) (swap vs split) is part of it, as T-258 already notes.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 2 #8 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-43: Local cost ledger — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to persist per-turn cost/tokens against the active pql ticket so the board shows what each feature actually cost? (Tokens primary, dollars advisory — subscription auth reports notional costs.) `[M]`
|
||||||
|
- **Context:** Flat-subscription opacity is the competitors' business model; turn-level local cost data is clide's birthright. Depends on Q-39 (active ticket binding) for reliable attribution.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 2 #9 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-44: Label-routed work queues / ticket dispatch — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to turn the board into an agent control surface — T-277 labels + the shipped pick-up path routing work queues, with an XL extension dispatching a ticket to a teammate session in an isolated git worktree (`SpawnSpec.cwd` already exists)? `[M→XL]`
|
||||||
|
- **Context:** Local, no-telemetry background agents with the work item, isolation, review surface, and audit trail all in-repo.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 2 #10 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-45: Semantic terminal — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to inject OSC 133 markers (clide spawns the shell, injection is trivial) to lift scrollback into foldable command regions, with recognizers for test runners and stack traces, and real widgets between rows? `[XL]`
|
||||||
|
- **Context:** xterm.js structurally cannot do widgets between rows. Hard prerequisite: the scrollback-unreachable bug (in T-378) — a semantic scrollback you can't scroll is a koan.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 3 #11 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-46: Living codebase map — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to render tree-sitter imports + pql links + git churn on the owned canvas, with live agent heat from `tool_use` events — watching Claude move through the codebase in real time? `[XL]`
|
||||||
|
- **Context:** Needs the canvas surface (T-317 family) and the tree-sitter FFI work to be solid first.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 3 #12 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-47: Live mixed documents — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to make fenced blocks in the owned markdown renderer live embeds — canvas scenes, pql query results, decision cards, confirm-gated command buttons? Notebook-grade, zero webview. `[XL]`
|
||||||
|
- **Context:** The `ClideMarkdownHooks` seam already exists.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 3 #13 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-48: Sealed-workspace mode — implement?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Are we going to build an egress-audit proxy around the whole agent stack, operationalizing [D-60](../decisions/tooling.md#d-60-no-network-on-default-launch-path)/[D-64](../decisions/architecture.md#d-64-no-telemetry--architectural-commitment) into a provable property? `[XL]`
|
||||||
|
- **Context:** The one feature in the review's list competitors cannot copy without breaking their own products.
|
||||||
|
- **Source:** fable-ous.md Part IV, Tier 3 #15 (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
### Q-49: Review honorable mentions — which, if any, get promoted?
|
||||||
|
- **Status:** Open
|
||||||
|
- **Question:** Which of the review's honorable mentions, if any, do we promote to tickets: Plan-to-Board bridge (ExitPlanMode approval files the plan as a ticket tree), Release Cockpit (renders `[Unreleased]` + one-action release cut — would also unstall the release-loop story T-393), Daily Helm (since-you-were-last-here pulse on the welcome screen), Total-recall conversation search (D-79 grep over the transcript corpus + fork-from-here), Shared Gaze (attach editor selection/scroll context to outgoing turns), Speakable Layouts (`clide layout apply review.yaml`), Agent fleet tray (per-workspace sockets — requires T-247's stale-socket fix first), Governance Graph (the D/Q/R/T web as a navigable map)?
|
||||||
|
- **Context:** Kept as one record to avoid fifteen low-signal questions; promote individually as appetite appears.
|
||||||
|
- **Source:** fable-ous.md Part IV, honorable mentions (2026-06-11 Fable review).
|
||||||
|
|
||||||
|
---
|
||||||
+11
-1129
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|
||||||
@@ -74,6 +74,14 @@ final class EditRun extends RenderGroup {
|
|||||||
/// Tools whose result is a diff the user wants to keep first-class at L1/L2.
|
/// Tools whose result is a diff the user wants to keep first-class at L1/L2.
|
||||||
bool isDiffTool(String name) => const {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Update'}.contains(name);
|
bool isDiffTool(String name) => const {'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Update'}.contains(name);
|
||||||
|
|
||||||
|
/// Tool names that spawn a sub-agent (sidechain): Claude Code emits `Task`,
|
||||||
|
/// the Agent SDK surface uses `Agent`. An agent spawn is ALWAYS its own
|
||||||
|
/// first-class collapsing card — it breaks the Activity cluster so a fan-out
|
||||||
|
/// of N agents reads as N cards, never one merged "Activity / N steps" card
|
||||||
|
/// (T-342). Each card carries its own folded prompt (T-263) + nested run
|
||||||
|
/// (T-264); the fold mechanics are unchanged, only the grouping boundary.
|
||||||
|
bool isAgentTool(String name) => name == 'Task' || name == 'Agent';
|
||||||
|
|
||||||
/// The file an edit tool-use targets, or null if [it] isn't a same-file edit
|
/// The file an edit tool-use targets, or null if [it] isn't a same-file edit
|
||||||
/// (used to group consecutive edits, T-296).
|
/// (used to group consecutive edits, T-296).
|
||||||
String? editFilePath(ConversationItem it) {
|
String? editFilePath(ConversationItem it) {
|
||||||
@@ -159,6 +167,13 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
|
|||||||
case AssistantThinkingMessage():
|
case AssistantThinkingMessage():
|
||||||
return level != FoldLevel.tools;
|
return level != FoldLevel.tools;
|
||||||
case AssistantToolUse(:final name):
|
case AssistantToolUse(:final name):
|
||||||
|
// An Agent/Task spawn is always its own first-class card (T-342) — it
|
||||||
|
// breaks the cluster at every level, including L3, so parallel agents
|
||||||
|
// never merge into one Activity card.
|
||||||
|
if (isAgentTool(name)) return false;
|
||||||
|
// A Workflow run is a first-class orchestration card too (T-416): it owns
|
||||||
|
// the live agent fan-out, so it never folds into a generic Activity card.
|
||||||
|
if (name == 'Workflow') return false;
|
||||||
// The Edit/Write call stays first-class with its diff at L1/L2.
|
// 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);
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/// Detect the file a Bash command follows, for the live-tail sub-card (T-325).
|
||||||
|
///
|
||||||
|
/// Claude Code runs every Bash tool itself and clide only sees the final
|
||||||
|
/// `tool_result` block — we never tap the running command's stdout. So instead
|
||||||
|
/// of mirroring the process, we detect a *file-backed source* the command
|
||||||
|
/// reads/follows and open our own read-only follower on the same file.
|
||||||
|
///
|
||||||
|
/// Deliberately conservative (the ticket's "small, explicit allowlist"): only
|
||||||
|
/// the read/follow verbs below, only a single file argument, and only paths
|
||||||
|
/// that resolve INSIDE the workspace. Anything else — a pipe into `tail`, a
|
||||||
|
/// redirect, two files, a path outside the repo — returns null so the caller
|
||||||
|
/// shows a "nothing to follow" affordance instead of following the wrong thing.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:clide/src/files/path_safety.dart';
|
||||||
|
|
||||||
|
/// Verbs whose single file argument clide can independently follow read-only.
|
||||||
|
const Set<String> _followVerbs = {'tail', 'cat', 'less'};
|
||||||
|
|
||||||
|
/// The file [command] reads/follows that clide can mirror read-only, as an
|
||||||
|
/// absolute path inside [workspaceRoot] — or null when there is no single,
|
||||||
|
/// safe, file-backed source. See the library doc for the policy.
|
||||||
|
String? detectBashTailSource(String command, {required Directory workspaceRoot}) {
|
||||||
|
String? found;
|
||||||
|
for (final segment in _commandSegments(command)) {
|
||||||
|
final tokens = _tokenize(segment);
|
||||||
|
if (tokens.isEmpty || !_followVerbs.contains(tokens.first)) continue;
|
||||||
|
final files = _fileArgs(tokens.first, tokens.sublist(1));
|
||||||
|
if (files.length != 1) continue; // 0 → reads stdin (a pipe); >1 → ambiguous
|
||||||
|
|
||||||
|
final String resolved;
|
||||||
|
try {
|
||||||
|
resolved = resolveUnderRoot(workspaceRoot, files.single);
|
||||||
|
} on PathOutsideRoot {
|
||||||
|
continue; // outside the workspace → don't follow (v1 policy)
|
||||||
|
}
|
||||||
|
if (found != null && found != resolved) return null; // two distinct sources
|
||||||
|
found = resolved;
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [command] expresses an intent to FOLLOW a file — used to decide
|
||||||
|
/// when to surface the live-tail segment at all, so ordinary commands (`ls`,
|
||||||
|
/// `git status`, a plain `cat`) get no segment, but a `tail …` with no
|
||||||
|
/// followable file still shows the "nothing to follow" note. v1 triggers on
|
||||||
|
/// `tail` or a follow flag (`-f`/`-F`/`--follow`); `cat`/`less` are detectable
|
||||||
|
/// sources but don't trigger the UI on their own (T-325).
|
||||||
|
bool bashHasTailIntent(String command) {
|
||||||
|
for (final segment in _commandSegments(command)) {
|
||||||
|
final tokens = _tokenize(segment);
|
||||||
|
if (tokens.isEmpty) continue;
|
||||||
|
if (tokens.first == 'tail') return true;
|
||||||
|
if (tokens.any((t) => t == '-f' || t == '-F' || t == '--follow')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a command line into command/pipeline segments on `|`, `;`, `&`. The
|
||||||
|
/// doubled forms (`&&`, `||`) fall out as empty middles and are dropped.
|
||||||
|
Iterable<String> _commandSegments(String command) => command.split(RegExp(r'[|;&]')).where((s) => s.trim().isNotEmpty);
|
||||||
|
|
||||||
|
/// Positional (non-flag) file arguments for [verb]. Skips flags, consumes the
|
||||||
|
/// value of `tail -n N` / `-c N`, honours `--` (end of options), and stops at a
|
||||||
|
/// redirect (`>` / `<`) — everything after a redirect targets a fd, not the
|
||||||
|
/// command's input.
|
||||||
|
List<String> _fileArgs(String verb, List<String> args) {
|
||||||
|
final files = <String>[];
|
||||||
|
for (var i = 0; i < args.length; i++) {
|
||||||
|
final a = args[i];
|
||||||
|
if (a == '--') {
|
||||||
|
files.addAll(args.sublist(i + 1).where((t) => !t.contains('>') && !t.contains('<')));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (a.contains('>') || a.contains('<')) break; // a redirect ends positional args
|
||||||
|
if (a.startsWith('-')) {
|
||||||
|
if (verb == 'tail' && (a == '-n' || a == '-c') && i + 1 < args.length) i++; // -n N / -c N
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.add(a);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal shell tokeniser: splits on whitespace, honours single/double quotes
|
||||||
|
/// (no escape or expansion handling — enough to recover file arguments).
|
||||||
|
List<String> _tokenize(String s) {
|
||||||
|
final out = <String>[];
|
||||||
|
final buf = StringBuffer();
|
||||||
|
String? quote;
|
||||||
|
var has = false;
|
||||||
|
for (var i = 0; i < s.length; i++) {
|
||||||
|
final ch = s[i];
|
||||||
|
if (quote != null) {
|
||||||
|
if (ch == quote) {
|
||||||
|
quote = null;
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
}
|
||||||
|
has = true;
|
||||||
|
} else if (ch == '"' || ch == "'") {
|
||||||
|
quote = ch;
|
||||||
|
has = true;
|
||||||
|
} else if (ch == ' ' || ch == '\t') {
|
||||||
|
if (has) {
|
||||||
|
out.add(buf.toString());
|
||||||
|
buf.clear();
|
||||||
|
has = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buf.write(ch);
|
||||||
|
has = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (has) out.add(buf.toString());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -37,10 +37,13 @@ class ClaudeBanner extends StatelessWidget {
|
|||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
|
ClideText('Claude', fontSize: clideFontDialogTitle, color: claudeAccent, fontWeight: FontWeight.w500),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily),
|
ClideText(role, fontSize: clideFontSmall, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
|
if (ws != null) ClideText(ws, fontSize: clideFontCaption, muted: true),
|
||||||
if (statusLine != null) ...[const SizedBox(height: 2), ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: clideMonoFamily)],
|
if (statusLine != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ClideText(statusLine!, fontSize: clideFontSmall, muted: true, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true),
|
const ClideText('Warming up — your conversation will appear here.', fontSize: clideFontSmall, muted: true),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
|||||||
import 'package:clide/builtin/claude/src/permission_mode_control.dart';
|
import 'package:clide/builtin/claude/src/permission_mode_control.dart';
|
||||||
import 'package:clide/builtin/claude/src/running_indicator.dart';
|
import 'package:clide/builtin/claude/src/running_indicator.dart';
|
||||||
import 'package:clide/builtin/claude/src/slash_commands.dart';
|
import 'package:clide/builtin/claude/src/slash_commands.dart';
|
||||||
import 'package:clide/kernel/src/theme/controller.dart';
|
|
||||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -415,7 +414,7 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = ClideTheme.of(context).surface;
|
final theme = ClideSettings.theme.of(context).surface;
|
||||||
final hasText = _controller.text.isNotEmpty;
|
final hasText = _controller.text.isNotEmpty;
|
||||||
final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted;
|
final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted;
|
||||||
|
|
||||||
|
|||||||
@@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
|
|||||||
/// Returns a change stream for [dir] (fires on any file event under it).
|
/// 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({
|
||||||
@@ -253,6 +238,7 @@ class ClaudeConfig extends ChangeNotifier {
|
|||||||
_version = _parseVersion(await _guard(_versionRunner));
|
_version = _parseVersion(await _guard(_versionRunner));
|
||||||
await _readProbeCache();
|
await _readProbeCache();
|
||||||
await _loadDiskConfig();
|
await _loadDiskConfig();
|
||||||
|
if (_disposed) return; // activation fired-and-forgot; teardown won
|
||||||
_startWatchers();
|
_startWatchers();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -271,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;
|
||||||
@@ -282,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +280,19 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set when [dispose] runs. The fire-and-forget [load] from extension
|
||||||
|
/// activation checks this so a teardown racing an in-flight load can't
|
||||||
|
/// notify (or start watchers on) a disposed notifier.
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
_stopWatching();
|
_stopWatching();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,9 @@ 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 'session_defaults.dart';
|
||||||
import 'prompt_card.dart';
|
import 'prompt_card.dart';
|
||||||
import 'session_index.dart';
|
import 'session_index.dart';
|
||||||
import 'session_naming.dart';
|
import 'session_naming.dart';
|
||||||
@@ -24,6 +26,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
|
||||||
@@ -71,7 +74,11 @@ class ClaudePane extends StatefulWidget {
|
|||||||
|
|
||||||
class _ClaudePaneState extends State<ClaudePane> {
|
class _ClaudePaneState extends State<ClaudePane> {
|
||||||
StreamSubscription<SessionStatus>? _statusSub;
|
StreamSubscription<SessionStatus>? _statusSub;
|
||||||
|
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();
|
||||||
@@ -80,6 +87,21 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
String _statusLine = 'starting…';
|
String _statusLine = 'starting…';
|
||||||
|
|
||||||
|
/// One-shot fork source: seeds the first bind, then cleared so /clear,
|
||||||
|
/// /resume, and respawns operate on this pane's own session (T-375).
|
||||||
|
late String? _forkSource = widget.forkSourceId;
|
||||||
|
|
||||||
|
/// Whether a bare `/model` opened the picker in the interaction zone
|
||||||
|
/// (T-408). An open prompt takes precedence; the picker shows once it
|
||||||
|
/// resolves.
|
||||||
|
bool _modelPickerOpen = false;
|
||||||
|
bool _effortPickerOpen = false;
|
||||||
|
bool _permissionPickerOpen = false;
|
||||||
|
|
||||||
|
/// Effort level this pane's session runs at (`--effort`, T-412). Null =
|
||||||
|
/// the CLI default. Set by /effort; carried by every respawn.
|
||||||
|
String? _effort;
|
||||||
|
|
||||||
bool _spawned = false;
|
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
|
||||||
@@ -110,12 +132,13 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
final seg = statusSegmentsAroundMode(_status);
|
final seg = statusSegmentsAroundMode(_status);
|
||||||
final mode = _status.permissionMode;
|
final mode = _status.permissionMode;
|
||||||
|
|
||||||
Widget text(String t) => ClideText(t, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusBarForeground, maxLines: 1);
|
Widget text(String t) =>
|
||||||
|
ClideText(t, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.statusBarForeground, maxLines: 1);
|
||||||
|
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
void add(Widget w) {
|
void add(Widget w) {
|
||||||
if (children.isNotEmpty) {
|
if (children.isNotEmpty) {
|
||||||
children.add(ClideText(' · ', fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.globalTextMuted, maxLines: 1));
|
children.add(ClideText(' · ', fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted, maxLines: 1));
|
||||||
}
|
}
|
||||||
children.add(w);
|
children.add(w);
|
||||||
}
|
}
|
||||||
@@ -141,6 +164,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
// Cache the kernel for dispose() — ancestor lookups there are illegal,
|
||||||
|
// and the old lookup-and-swallow leaked the settings listener on every
|
||||||
|
// disposed pane (T-366).
|
||||||
|
_kernel = ClideKernel.of(context);
|
||||||
// Spawn once, after the kernel is available.
|
// Spawn once, after the kernel is available.
|
||||||
if (!_spawned) {
|
if (!_spawned) {
|
||||||
_spawned = true;
|
_spawned = true;
|
||||||
@@ -155,6 +182,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);
|
||||||
@@ -168,11 +207,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
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 = 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;
|
||||||
@@ -224,6 +270,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
Future<void> _rebindToActiveProject() async {
|
Future<void> _rebindToActiveProject() async {
|
||||||
_statusSub?.cancel();
|
_statusSub?.cancel();
|
||||||
_statusSub = null;
|
_statusSub = null;
|
||||||
|
_endSub?.cancel();
|
||||||
|
_endSub = null;
|
||||||
|
_modelErrorSub?.cancel();
|
||||||
|
_modelErrorSub = null;
|
||||||
|
_workflowsSub?.cancel();
|
||||||
|
_workflowsSub = null;
|
||||||
|
_modelPickerOpen = false;
|
||||||
|
_effortPickerOpen = false;
|
||||||
|
_permissionPickerOpen = false;
|
||||||
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
|
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
|
||||||
_conversation = null;
|
_conversation = null;
|
||||||
_session = null;
|
_session = null;
|
||||||
@@ -241,6 +296,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
|
|
||||||
Future<void> _spawn() async {
|
Future<void> _spawn() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// New sessions inherit the user's Claude defaults (T-457): effort flows
|
||||||
|
// through --effort below; model + permission mode are applied post-spawn.
|
||||||
|
final settings = ClideKernel.of(context).settings;
|
||||||
|
_effort ??= defaultEffortFlag(settings);
|
||||||
|
var isNewSession = false;
|
||||||
final ipc = _ipc();
|
final ipc = _ipc();
|
||||||
if (ipc == null || !ipc.isConnected) {
|
if (ipc == null || !ipc.isConnected) {
|
||||||
setState(() => _error = 'Daemon not connected.');
|
setState(() => _error = 'Daemon not connected.');
|
||||||
@@ -263,7 +323,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final ManagedSession managed;
|
final ManagedSession managed;
|
||||||
final forkSource = widget.forkSourceId;
|
final forkSource = _forkSource;
|
||||||
if (forkSource != null) {
|
if (forkSource != null) {
|
||||||
// Fork pane: branch source session into a new clide-managed session.
|
// Fork pane: branch source session into a new clide-managed session.
|
||||||
// The clide-internal id is a fresh UUID; the real claude session id is
|
// The clide-internal id is a fresh UUID; the real claude session id is
|
||||||
@@ -271,12 +331,23 @@ 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');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// One-shot: the fork source seeds only the FIRST bind. Leaving it set
|
||||||
|
// made /clear re-fork the original conversation instead of clearing —
|
||||||
|
// every later respawn must operate on this pane's own session (T-375).
|
||||||
|
_forkSource = null;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _statusLine = 'fork of $forkSource');
|
setState(() => _statusLine = 'fork of $forkSource');
|
||||||
} else {
|
} else {
|
||||||
@@ -298,6 +369,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) {
|
||||||
@@ -305,16 +377,24 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
isNewSession = !resume;
|
||||||
setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId');
|
setState(() => _statusLine = resume ? 'resumed · $_sessionId' : 'new session · $_sessionId');
|
||||||
}
|
}
|
||||||
|
|
||||||
_session = managed.session;
|
_session = managed.session;
|
||||||
_conversation = managed.conversation;
|
_conversation = managed.conversation;
|
||||||
|
// A brand-new session (not a resume or fork) starts on the user's default
|
||||||
|
// model + permission mode (T-457). Sent as control requests; the CLI
|
||||||
|
// applies them after init. 'default'/unset values are no-ops.
|
||||||
|
if (isNewSession) applySessionDefaults(managed.session, settings);
|
||||||
|
// The wire never reports effort — record what this session was spawned
|
||||||
|
// with so the status line / sidebar can show it (T-412).
|
||||||
|
if (_effort != null) managed.session.noteEffort(_effort!);
|
||||||
// Diagnostic (T-274 follow-up): record how this pane bound its session —
|
// 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`.
|
||||||
final seeded = _conversation?.items.length ?? 0;
|
final seeded = _conversation?.items.length ?? 0;
|
||||||
_kernel()?.log.info(
|
_kernel?.log.info(
|
||||||
'claude',
|
'claude',
|
||||||
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot — '
|
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot — '
|
||||||
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
|
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
|
||||||
@@ -323,6 +403,39 @@ 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):
|
||||||
|
// late binders read the replayed end; live sessions stream it.
|
||||||
|
final alreadyEnded = managed.session.end;
|
||||||
|
if (alreadyEnded != null) {
|
||||||
|
_onSessionEnd(alreadyEnded);
|
||||||
|
} else {
|
||||||
|
_endSub = managed.session.endedStream.listen(_onSessionEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The claude process exited under this pane's live session. Stop looking
|
||||||
|
/// busy, say so in the status line, and log the drained stderr tail —
|
||||||
|
/// the diagnostics that used to vanish (T-361).
|
||||||
|
void _onSessionEnd(SessionEnd end) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
|
||||||
|
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
|
||||||
|
// 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
|
||||||
@@ -342,10 +455,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) {
|
||||||
@@ -370,7 +625,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +677,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
/// re-bind the pane to it.
|
/// re-bind the pane to it.
|
||||||
Future<void> _resumeFlow() async {
|
Future<void> _resumeFlow() async {
|
||||||
final root = _repoRoot;
|
final root = _repoRoot;
|
||||||
final dialog = _kernel()?.dialog;
|
final dialog = _kernel?.dialog;
|
||||||
if (root == null || dialog == null) return;
|
if (root == null || dialog == null) return;
|
||||||
final dir = Directory(claudeProjectDir(root));
|
final dir = Directory(claudeProjectDir(root));
|
||||||
final sessions = await listSessions(dir);
|
final sessions = await listSessions(dir);
|
||||||
@@ -440,6 +695,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
Future<void> _respawnWithSession(String sessionId, {bool clearTranscript = false}) async {
|
Future<void> _respawnWithSession(String sessionId, {bool clearTranscript = false}) async {
|
||||||
_statusSub?.cancel();
|
_statusSub?.cancel();
|
||||||
_statusSub = null;
|
_statusSub = null;
|
||||||
|
_endSub?.cancel();
|
||||||
|
_endSub = null;
|
||||||
|
_modelErrorSub?.cancel();
|
||||||
|
_modelErrorSub = null;
|
||||||
|
_workflowsSub?.cancel();
|
||||||
|
_workflowsSub = null;
|
||||||
|
_modelPickerOpen = false;
|
||||||
|
_effortPickerOpen = false;
|
||||||
|
_permissionPickerOpen = false;
|
||||||
await activeSessionOrchestrator?.close(_orchId); // kills the old session
|
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;
|
||||||
@@ -455,22 +719,17 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
|
|
||||||
// -- helpers --------------------------------------------------------------
|
// -- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
DaemonClient? _ipc() => _kernel?.ipc;
|
||||||
|
|
||||||
KernelServices? _kernel() {
|
/// Cached in didChangeDependencies (T-366); see note there.
|
||||||
try {
|
KernelServices? _kernel;
|
||||||
return ClideKernel.of(context);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- build ----------------------------------------------------------------
|
// -- build ----------------------------------------------------------------
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}';
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
|
||||||
final Widget body;
|
final Widget body;
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
@@ -496,10 +755,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
onTap: _focusComposerOnTap,
|
onTap: _focusComposerOnTap,
|
||||||
child: ConversationView(
|
child: ConversationView(
|
||||||
controller: _conversation!,
|
controller: _conversation!,
|
||||||
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
|
foldLevel: foldLevelFromName(_kernel?.settings.get<String>(kActivityFoldLevelKey)),
|
||||||
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,
|
||||||
@@ -516,9 +776,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,
|
||||||
@@ -571,7 +858,13 @@ class _ModeBadge extends StatelessWidget {
|
|||||||
return Semantics(
|
return Semantics(
|
||||||
label: 'permission mode: ${permissionModeLabel(mode)}',
|
label: 'permission mode: ${permissionModeLabel(mode)}',
|
||||||
excludeSemantics: true,
|
excludeSemantics: true,
|
||||||
child: ClideText(permissionModeLabel(mode), fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: permissionModeColor(mode, tokens), maxLines: 1),
|
child: ClideText(
|
||||||
|
permissionModeLabel(mode),
|
||||||
|
fontSize: clideFontSmall,
|
||||||
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||||
|
color: permissionModeColor(mode, tokens),
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ String nextSafePermissionMode(String current) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Status-line segments split around the permission-mode badge so the UI can
|
/// 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:clide/builtin/claude/src/task_list.dart';
|
import 'package:clide/builtin/claude/src/task_list.dart';
|
||||||
import 'package:clide/kernel/src/theme/controller.dart';
|
|
||||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -30,7 +29,7 @@ class _ClaudeTaskDockState extends State<ClaudeTaskDock> {
|
|||||||
final tasks = widget.tasks;
|
final tasks = widget.tasks;
|
||||||
if (tasks.isEmpty) return const SizedBox.shrink(); // no chrome when empty
|
if (tasks.isEmpty) return const SizedBox.shrink(); // no chrome when empty
|
||||||
|
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
final done = tasks.where((t) => t.status == TaskStatus.completed).length;
|
final done = tasks.where((t) => t.status == TaskStatus.completed).length;
|
||||||
final inProgress = tasks.where((t) => t.status == TaskStatus.inProgress);
|
final inProgress = tasks.where((t) => t.status == TaskStatus.inProgress);
|
||||||
final current = inProgress.isEmpty ? null : inProgress.first.text;
|
final current = inProgress.isEmpty ? null : inProgress.first.text;
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
final content = Column(
|
final content = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -241,13 +241,13 @@ class _ConversationCardState extends State<ConversationCard> {
|
|||||||
if (widget.collapsible) _caret(tokens),
|
if (widget.collapsible) _caret(tokens),
|
||||||
// Header label size matches ClideCollapserCard (clideFontCaption) so
|
// Header label size matches ClideCollapserCard (clideFontCaption) so
|
||||||
// neighbouring cards in the conversation stream align (T-344).
|
// neighbouring cards in the conversation stream align (T-344).
|
||||||
ClideText(widget.label, fontSize: clideFontCaption, color: widget.accent, fontFamily: clideMonoFamily),
|
ClideText(widget.label, fontSize: clideFontCaption, color: widget.accent, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||||
// While collapsed, show a one-line gist next to the label so the card
|
// While collapsed, show a one-line gist next to the label so the card
|
||||||
// still says what it holds.
|
// still says what it holds.
|
||||||
if (_collapsed && summary != null) ...[
|
if (_collapsed && summary != null) ...[
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily, maxLines: 1),
|
child: ClideText(summary, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context), maxLines: 1),
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -322,7 +322,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
|||||||
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
ClideText(label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
|
Expanded(child: Container(height: 1, color: tokens.panelBorder)),
|
||||||
],
|
],
|
||||||
@@ -349,7 +349,7 @@ class _ConversationCardState extends State<ConversationCard> {
|
|||||||
onTap: items[i].onTap,
|
onTap: items[i].onTap,
|
||||||
builder: (_, hovered, pressed) => Padding(
|
builder: (_, hovered, pressed) => Padding(
|
||||||
padding: const EdgeInsets.only(left: 10),
|
padding: const EdgeInsets.only(left: 10),
|
||||||
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
child: ClideText(items[i].label, fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ class ConversationController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build a controller fed from the kernel [MessageBus] — it consumes
|
/// 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
|
||||||
|
|||||||
@@ -14,15 +14,21 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
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/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/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/tokens.dart';
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
|
import 'package:clide/src/terminal/terminal.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
@@ -35,11 +41,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;
|
||||||
@@ -205,6 +218,15 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
if (it is AssistantToolUse) toolUseIds.add(it.toolUseId);
|
if (it is AssistantToolUse) toolUseIds.add(it.toolUseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With more than one agent in the turn (a parallel fan-out), the
|
||||||
|
// "nearest preceding agent" fallback is unsafe: an item with no
|
||||||
|
// parent_tool_use_id and no rooted parentUuid chain would mis-file into
|
||||||
|
// whichever agent was emitted last — landing in a SIBLING agent's card.
|
||||||
|
// Drop the fallback in that case so an unattributable item orphans
|
||||||
|
// (rendered inline) rather than cross-attributed (T-342). A single agent
|
||||||
|
// has only one possible owner, so the fallback stays safe there.
|
||||||
|
final multipleAgents = agentByToolUseId.length > 1;
|
||||||
|
|
||||||
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
|
AssistantToolUse? resolveOwner(ConversationItem item, AssistantToolUse? nearest) {
|
||||||
// Direct route: stream-json hands us the spawning Agent's tool-use id on
|
// Direct route: stream-json hands us the spawning Agent's tool-use id on
|
||||||
// the item itself (T-338) — no chain to walk.
|
// the item itself (T-338) — no chain to walk.
|
||||||
@@ -223,7 +245,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
if (sidechainByUuid[parent] != true) break; // left the run's chain
|
if (sidechainByUuid[parent] != true) break; // left the run's chain
|
||||||
cur = parent;
|
cur = parent;
|
||||||
}
|
}
|
||||||
return nearest;
|
return multipleAgents ? null : nearest;
|
||||||
}
|
}
|
||||||
|
|
||||||
final owned = <String>{};
|
final owned = <String>{};
|
||||||
@@ -256,9 +278,13 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
void _onChanged() {
|
void _onChanged() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
// Follow the tail — jump to the bottom after the new item lays out.
|
// Follow the tail — but only when already pinned to it. New items arrive
|
||||||
|
// on every streamed token; jumping unconditionally yanks a reader who
|
||||||
|
// scrolled up back to the bottom for the whole reply (T-368, twin of the
|
||||||
|
// T-297 resize gate).
|
||||||
|
if (!_atBottom) return;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_scroll.hasClients) {
|
if (_scroll.hasClients && _atBottom) {
|
||||||
_scroll.jumpTo(_scroll.position.maxScrollExtent);
|
_scroll.jumpTo(_scroll.position.maxScrollExtent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -266,7 +292,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
final allItems = widget.controller.items;
|
final allItems = widget.controller.items;
|
||||||
// T-263/T-264: resolve each sidechain run → owning Agent card before
|
// T-263/T-264: resolve each sidechain run → owning Agent card before
|
||||||
// culling, so the run is suppressed up top and folded into / nested under
|
// culling, so the run is suppressed up top and folded into / nested under
|
||||||
@@ -309,6 +335,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
key: ValueKey('turn.${item.uuid}'),
|
key: ValueKey('turn.${item.uuid}'),
|
||||||
item: item,
|
item: item,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
|
mono: ClideSettings.fonts.monoOf(context),
|
||||||
collapseTools: true,
|
collapseTools: true,
|
||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||||
@@ -316,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}'),
|
||||||
@@ -327,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}'),
|
||||||
@@ -360,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,9 +438,9 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
/// used for the "claude" message card's stripe + label.
|
/// used for the "claude" message card's stripe + label.
|
||||||
const claudeAccent = Color(0xFFD97757);
|
const claudeAccent = Color(0xFFD97757);
|
||||||
|
|
||||||
/// The tool names that launch a sub-agent (sidechain). Claude Code emits
|
/// The tool names that launch a sub-agent (sidechain) — shared with the
|
||||||
/// `Task`; the Agent SDK surface uses `Agent` — accept both (T-263).
|
/// grouping pass so "is this an agent spawn?" has one definition (T-342).
|
||||||
bool _isAgentTool(String name) => name == 'Task' || name == 'Agent';
|
bool _isAgentTool(String name) => isAgentTool(name);
|
||||||
|
|
||||||
/// Open a governance/ticket record clicked in the conversation (T-279) in its
|
/// Open a governance/ticket record clicked in the conversation (T-279) in its
|
||||||
/// context-pane reader, reusing the existing `selection` MessageBus addressing
|
/// context-pane reader, reusing the existing `selection` MessageBus addressing
|
||||||
@@ -414,12 +481,73 @@ void _openFile(BuildContext context, String path, int? line) {
|
|||||||
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, 'line': ?line}));
|
unawaited(ClideKernel.of(context).ipc.request('editor.open', args: {'path': path, 'line': ?line}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A live, read-only tail of the file a Bash command follows (T-325).
|
||||||
|
///
|
||||||
|
/// Mounts when the Bash card is EXPANDED — the collapser builds its children
|
||||||
|
/// lazily (clide_collapser_card.dart), so initialising here and tearing down in
|
||||||
|
/// [dispose] gives the "connect on expand, disconnect on collapse" lifecycle
|
||||||
|
/// for free. Resolves the followed file from the command against the open
|
||||||
|
/// workspace; when there's no independent file-backed source (a pipe into
|
||||||
|
/// `tail`, a path outside the repo) it shows a muted note instead of an empty
|
||||||
|
/// terminal.
|
||||||
|
class _BashLiveTail extends StatefulWidget {
|
||||||
|
const _BashLiveTail({required this.command});
|
||||||
|
|
||||||
|
final String command;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_BashLiveTail> createState() => _BashLiveTailState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BashLiveTailState extends State<_BashLiveTail> {
|
||||||
|
Terminal? _terminal;
|
||||||
|
FileTailFollower? _follower;
|
||||||
|
bool _resolved = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_resolved) return; // resolve once — InheritedWidget access needs context
|
||||||
|
_resolved = true;
|
||||||
|
final root = ClideKernel.of(context).project.current;
|
||||||
|
final source = root == null ? null : detectBashTailSource(widget.command, workspaceRoot: root);
|
||||||
|
if (source == null) return; // no file-backed source → muted note in build
|
||||||
|
final term = Terminal(maxLines: 1000);
|
||||||
|
_terminal = term;
|
||||||
|
// writeBytes: the follower's chunk boundaries are arbitrary (it can even
|
||||||
|
// start mid-rune by construction) — keep decode state across reads (T-373).
|
||||||
|
_follower = FileTailFollower(source, onData: term.writeBytes);
|
||||||
|
unawaited(_follower!.start());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_follower?.stop();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final term = _terminal;
|
||||||
|
if (term == null) {
|
||||||
|
return ClideText('no independent source to follow', muted: true, fontSize: clideFontMeta);
|
||||||
|
}
|
||||||
|
return SizedBox(
|
||||||
|
height: 160,
|
||||||
|
child: ClipRect(
|
||||||
|
child: ClidePtyView(terminal: term, label: 'live tail', fontSize: clideFontMeta),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One conversation item, rendered by kind.
|
/// One conversation item, rendered by kind.
|
||||||
class _ConversationTurn extends StatelessWidget {
|
class _ConversationTurn extends StatelessWidget {
|
||||||
const _ConversationTurn({
|
const _ConversationTurn({
|
||||||
super.key,
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.tokens,
|
required this.tokens,
|
||||||
|
required this.mono,
|
||||||
this.collapseTools = false,
|
this.collapseTools = false,
|
||||||
this.toolUseOutcomes = const <String, bool>{},
|
this.toolUseOutcomes = const <String, bool>{},
|
||||||
this.quietErrorToolUseIds = const <String>{},
|
this.quietErrorToolUseIds = const <String>{},
|
||||||
@@ -427,11 +555,17 @@ 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;
|
||||||
final SurfaceTokens tokens;
|
final SurfaceTokens tokens;
|
||||||
|
|
||||||
|
/// The live monospace family (T-471/T-472), resolved from context by the
|
||||||
|
/// parent and threaded in so the context-free tool-body/result helpers honour
|
||||||
|
/// the Settings → Appearance choice.
|
||||||
|
final String mono;
|
||||||
|
|
||||||
/// When true (top-level stream items), a tool use renders as its own
|
/// When true (top-level stream items), a tool use renders as its own
|
||||||
/// collapser over a one-item list (T-305). When false (already inside a run /
|
/// collapser over a one-item list (T-305). When false (already inside a run /
|
||||||
/// edit collapser), it renders the bare inner content card so collapsers
|
/// edit collapser), it renders the bare inner content card so collapsers
|
||||||
@@ -462,6 +596,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;
|
||||||
@@ -501,6 +638,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.
|
||||||
@@ -610,6 +758,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(
|
||||||
@@ -639,6 +794,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
key: ValueKey('run.${r.uuid}'),
|
key: ValueKey('run.${r.uuid}'),
|
||||||
item: r,
|
item: r,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
|
mono: mono,
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
@@ -653,6 +809,99 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A dedicated card for a Workflow run (T-416): the harness's multi-agent
|
||||||
|
/// orchestration. The collapser header carries the run's live status (spinner
|
||||||
|
/// while running, check when done) and a `done/total agents` counter; the body
|
||||||
|
/// lists each fanned-out agent — grouped under phase headers when the workflow
|
||||||
|
/// declared phases — plus the run's usage and the orchestration script.
|
||||||
|
Widget _workflowCard(AssistantToolUse t, WorkflowRun run) {
|
||||||
|
final title = run.name ?? 'workflow';
|
||||||
|
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
|
||||||
|
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
|
||||||
|
final detail = run.done ? (run.summary ?? run.description) : run.description;
|
||||||
|
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
|
||||||
|
return ClideCollapserCard(
|
||||||
|
label: 'workflow',
|
||||||
|
color: color,
|
||||||
|
collapsedSummary: collapsedSummary,
|
||||||
|
counter: counter,
|
||||||
|
status: run.done ? ClideRunStatus.success : ClideRunStatus.running,
|
||||||
|
children: [_workflowBody(t, run)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _workflowBody(AssistantToolUse t, WorkflowRun run) {
|
||||||
|
final agents = run.orderedAgents;
|
||||||
|
final phases = run.orderedPhases;
|
||||||
|
final rows = <Widget>[];
|
||||||
|
if (phases.isEmpty) {
|
||||||
|
rows.addAll(agents.map(_workflowAgentRow));
|
||||||
|
} else {
|
||||||
|
for (final p in phases) {
|
||||||
|
rows.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6, bottom: 2),
|
||||||
|
child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow));
|
||||||
|
}
|
||||||
|
// Agents the deltas never tagged with a phase still render, after the
|
||||||
|
// phased groups, so nothing fanned out is silently dropped.
|
||||||
|
rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow));
|
||||||
|
}
|
||||||
|
if (rows.isEmpty) {
|
||||||
|
rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta));
|
||||||
|
}
|
||||||
|
|
||||||
|
final script = t.input['script'];
|
||||||
|
return ConversationCard(
|
||||||
|
variant: ConversationCardVariant.bordered,
|
||||||
|
accent: run.done ? tokens.statusSuccess : tokens.globalFocus,
|
||||||
|
label: run.name ?? 'workflow',
|
||||||
|
copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input),
|
||||||
|
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows),
|
||||||
|
extraSegments: [
|
||||||
|
if (run.totalTokens != null && run.totalTokens! > 0)
|
||||||
|
CardSegment(
|
||||||
|
label: 'usage',
|
||||||
|
child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta),
|
||||||
|
),
|
||||||
|
if (script is String)
|
||||||
|
CardSegment(
|
||||||
|
label: 'script',
|
||||||
|
child: ClideCodeBlock(source: script, language: 'javascript'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One agent row in a workflow card: a state glyph (spinner while running, a
|
||||||
|
/// muted check once done), the agent's label, and its model (T-416).
|
||||||
|
Widget _workflowAgentRow(WorkflowAgent a) {
|
||||||
|
final done = a.state == WorkflowAgentState.done;
|
||||||
|
final Widget glyph = done
|
||||||
|
? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess)
|
||||||
|
: ClideSpinner(size: 12, color: tokens.globalTextMuted);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: 16, child: Center(child: glyph)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
if (a.model != null && a.model!.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The inner content card for a tool use (T-305): the call body + folded
|
/// 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
|
||||||
@@ -685,6 +934,14 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
label: 'result',
|
label: 'result',
|
||||||
child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)),
|
child: ClideCodeBlock(source: result.content, language: _resultLanguage(t)),
|
||||||
),
|
),
|
||||||
|
// T-325: a Bash card that follows a file (`tail -f …`) gets a live,
|
||||||
|
// scrolling tail of that file below the result — connected lazily, only
|
||||||
|
// while the card is expanded (the collapser builds segments on expand).
|
||||||
|
if (t.name == 'Bash' && t.input['command'] is String && bashHasTailIntent(t.input['command'] as String))
|
||||||
|
CardSegment(
|
||||||
|
label: 'live tail',
|
||||||
|
child: _BashLiveTail(command: t.input['command'] as String),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// A resolved permission-prompted call is tinted green if approved / red if
|
// A resolved permission-prompted call is tinted green if approved / red if
|
||||||
@@ -698,7 +955,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
label: t.name,
|
label: t.name,
|
||||||
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
|
copyText: const JsonEncoder.withIndent(' ').convert(t.input),
|
||||||
status: status,
|
status: status,
|
||||||
body: toolInputBody(tokens, t.name, t.input),
|
body: toolInputBody(tokens, t.name, t.input, mono),
|
||||||
extraSegments: segments,
|
extraSegments: segments,
|
||||||
// Inside a collapser the surrounding padding is even on all sides
|
// Inside a collapser the surrounding padding is even on all sides
|
||||||
// (T-305): a matching bottom margin is the canvas's bottom inset and the
|
// (T-305): a matching bottom margin is the canvas's bottom inset and the
|
||||||
@@ -755,7 +1012,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
collapsible: quiet || multiline,
|
collapsible: quiet || multiline,
|
||||||
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
||||||
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
|
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
|
||||||
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: quiet ? tokens.globalTextMuted : tokens.statusError),
|
body: ClideText(t.content, fontSize: clideFontMeta, fontFamily: mono, color: quiet ? tokens.globalTextMuted : tokens.statusError),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,7 +1033,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
||||||
body: isOutputTool
|
body: isOutputTool
|
||||||
? ClideCodeBlock(source: t.content, language: 'text')
|
? ClideCodeBlock(source: t.content, language: 'text')
|
||||||
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
: ClideText(t.content, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalForeground),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,6 +1069,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;
|
||||||
@@ -822,6 +1080,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) {
|
||||||
@@ -837,12 +1096,14 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
key: ValueKey('step.${item.uuid}'),
|
key: ValueKey('step.${item.uuid}'),
|
||||||
item: item,
|
item: item,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
|
mono: ClideSettings.fonts.monoOf(context),
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
runByToolUseId: runByToolUseId,
|
runByToolUseId: runByToolUseId,
|
||||||
|
workflows: workflows,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -901,6 +1162,7 @@ class _EditRunCard extends StatelessWidget {
|
|||||||
key: ValueKey('edit.${item.uuid}'),
|
key: ValueKey('edit.${item.uuid}'),
|
||||||
item: item,
|
item: item,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
|
mono: ClideSettings.fonts.monoOf(context),
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
quietErrorToolUseIds: quietErrorToolUseIds,
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import 'package:clide/clide.dart';
|
|||||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
|
import 'package:clide/builtin/claude/src/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';
|
||||||
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
|
import 'package:clide/builtin/claude/src/claude_meta_sidebar.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/session_defaults.dart';
|
||||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/stream_json_session.dart' show kEffortLevels, kFallbackModels, kPermissionModes;
|
||||||
import 'package:clide/builtin/claude/src/session_storage.dart';
|
import 'package:clide/builtin/claude/src/session_storage.dart';
|
||||||
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
|
import 'package:clide/builtin/claude/src/ticket_pick_up.dart';
|
||||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
|
import 'package:clide/builtin/claude/src/transcript_reader.dart' show ImageMessage;
|
||||||
@@ -21,6 +24,18 @@ 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';
|
||||||
|
|
||||||
|
/// D-6 contract (T-391): a failed command returns an ERROR envelope (non-zero
|
||||||
|
/// CLI exit), never `ok` with an `error` field a script can't detect.
|
||||||
|
IpcResponse _userErr(String msg, {String? hint}) => IpcResponse.err(
|
||||||
|
id: '',
|
||||||
|
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: msg, hint: hint),
|
||||||
|
);
|
||||||
|
|
||||||
|
IpcResponse _notFound(String msg) => IpcResponse.err(
|
||||||
|
id: '',
|
||||||
|
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: msg),
|
||||||
|
);
|
||||||
|
|
||||||
class ClaudeExtension extends ClideExtension {
|
class ClaudeExtension extends ClideExtension {
|
||||||
@override
|
@override
|
||||||
String get id => 'builtin.claude';
|
String get id => 'builtin.claude';
|
||||||
@@ -87,6 +102,81 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: cycle activity fold level',
|
title: 'Claude: cycle activity fold level',
|
||||||
run: _cycleFoldLevel,
|
run: _cycleFoldLevel,
|
||||||
),
|
),
|
||||||
|
// Activity settings category (T-453) — the fold level as a schema field,
|
||||||
|
// written to kActivityFoldLevelKey; the panes already rebuild off the
|
||||||
|
// settings notifier, so picking a level applies live.
|
||||||
|
const SettingsCategoryContribution(
|
||||||
|
id: 'activity',
|
||||||
|
category: SettingsCategory(
|
||||||
|
id: 'activity',
|
||||||
|
title: 'Activity',
|
||||||
|
iconName: 'cards-three',
|
||||||
|
priority: 50,
|
||||||
|
sections: [
|
||||||
|
SettingsSection(
|
||||||
|
label: 'Conversation',
|
||||||
|
fields: [
|
||||||
|
SettingsField(
|
||||||
|
key: kActivityFoldLevelKey,
|
||||||
|
kind: SettingsFieldKind.select,
|
||||||
|
label: 'Fold level',
|
||||||
|
help: 'How aggressively the conversation folds tool calls, thinking, and results.',
|
||||||
|
defaultValue: 'tools',
|
||||||
|
options: [
|
||||||
|
SettingsOption(value: 'none', label: 'Show everything'),
|
||||||
|
SettingsOption(value: 'tools', label: 'Fold tool calls'),
|
||||||
|
SettingsOption(value: 'thinking', label: 'Fold tools + thinking'),
|
||||||
|
SettingsOption(value: 'everything', label: 'Fold all but prose'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Claude settings category (T-457) — defaults applied to NEW sessions
|
||||||
|
// (the pane reads these keys at spawn). Effort flows through --effort;
|
||||||
|
// model + permission mode are sent as control requests post-spawn.
|
||||||
|
SettingsCategoryContribution(
|
||||||
|
id: 'claude',
|
||||||
|
category: SettingsCategory(
|
||||||
|
id: 'claude',
|
||||||
|
title: 'Claude',
|
||||||
|
iconName: 'sparkle',
|
||||||
|
priority: 40,
|
||||||
|
sections: [
|
||||||
|
SettingsSection(
|
||||||
|
label: 'New session defaults',
|
||||||
|
fields: [
|
||||||
|
SettingsField(
|
||||||
|
key: kDefaultModelKey,
|
||||||
|
kind: SettingsFieldKind.select,
|
||||||
|
label: 'Model',
|
||||||
|
help: 'Model for new sessions.',
|
||||||
|
defaultValue: 'default',
|
||||||
|
options: [for (final m in kFallbackModels) SettingsOption(value: m.value, label: m.displayName)],
|
||||||
|
),
|
||||||
|
SettingsField(
|
||||||
|
key: kDefaultEffortKey,
|
||||||
|
kind: SettingsFieldKind.select,
|
||||||
|
label: 'Effort',
|
||||||
|
help: 'Reasoning effort for new sessions (applied via --effort at spawn).',
|
||||||
|
defaultValue: 'high',
|
||||||
|
options: [for (final l in kEffortLevels) SettingsOption(value: l.value, label: l.displayName)],
|
||||||
|
),
|
||||||
|
SettingsField(
|
||||||
|
key: kDefaultPermissionModeKey,
|
||||||
|
kind: SettingsFieldKind.select,
|
||||||
|
label: 'Permission mode',
|
||||||
|
help: 'Starting permission mode for new sessions.',
|
||||||
|
defaultValue: 'default',
|
||||||
|
options: [for (final p in kPermissionModes) SettingsOption(value: p.value, label: p.displayName)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
// T-171: agent roster controls (D-6 CLI/UI parity).
|
// T-171: agent roster controls (D-6 CLI/UI parity).
|
||||||
// Usage: clide claude.agent.show <sessionId>
|
// Usage: clide claude.agent.show <sessionId>
|
||||||
CommandContribution(
|
CommandContribution(
|
||||||
@@ -95,7 +185,7 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: show an agent session pane',
|
title: 'Claude: show an agent session pane',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
final id = args.firstOrNull;
|
final id = args.firstOrNull;
|
||||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
if (id == null) return _userErr('missing session id');
|
||||||
_orchestrator?.show(id);
|
_orchestrator?.show(id);
|
||||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
|
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
|
||||||
},
|
},
|
||||||
@@ -106,7 +196,7 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: hide an agent session pane',
|
title: 'Claude: hide an agent session pane',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
final id = args.firstOrNull;
|
final id = args.firstOrNull;
|
||||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
if (id == null) return _userErr('missing session id');
|
||||||
_orchestrator?.hide(id);
|
_orchestrator?.hide(id);
|
||||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
|
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
|
||||||
},
|
},
|
||||||
@@ -117,7 +207,7 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: close (kill) an agent session',
|
title: 'Claude: close (kill) an agent session',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
final id = args.firstOrNull;
|
final id = args.firstOrNull;
|
||||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
if (id == null) return _userErr('missing session id');
|
||||||
await _orchestrator?.close(id);
|
await _orchestrator?.close(id);
|
||||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
|
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
|
||||||
},
|
},
|
||||||
@@ -128,7 +218,7 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: mute broker delivery to an agent session',
|
title: 'Claude: mute broker delivery to an agent session',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
final id = args.firstOrNull;
|
final id = args.firstOrNull;
|
||||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
if (id == null) return _userErr('missing session id');
|
||||||
_orchestrator?.mute(id);
|
_orchestrator?.mute(id);
|
||||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
|
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
|
||||||
},
|
},
|
||||||
@@ -139,7 +229,7 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: unmute broker delivery to an agent session',
|
title: 'Claude: unmute broker delivery to an agent session',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
final id = args.firstOrNull;
|
final id = args.firstOrNull;
|
||||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
if (id == null) return _userErr('missing session id');
|
||||||
_orchestrator?.unmute(id);
|
_orchestrator?.unmute(id);
|
||||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
|
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
|
||||||
},
|
},
|
||||||
@@ -151,9 +241,9 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: inject a text turn into an agent session',
|
title: 'Claude: inject a text turn into an agent session',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
final id = args.firstOrNull;
|
final id = args.firstOrNull;
|
||||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
if (id == null) return _userErr('missing session id');
|
||||||
final text = args.skip(1).join(' ');
|
final text = args.skip(1).join(' ');
|
||||||
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
|
if (text.isEmpty) return _userErr('missing message text');
|
||||||
_orchestrator?.injectMessage(id, text);
|
_orchestrator?.injectMessage(id, text);
|
||||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
|
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
|
||||||
},
|
},
|
||||||
@@ -169,12 +259,12 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: set permission mode for an agent session',
|
title: 'Claude: set permission mode for an agent session',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
final id = args.firstOrNull;
|
final id = args.firstOrNull;
|
||||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
if (id == null) return _userErr('missing session id');
|
||||||
final mode = args.length >= 2 ? args[1] : null;
|
final mode = args.length >= 2 ? args[1] : null;
|
||||||
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
|
if (mode == null) return _userErr('missing mode (default|acceptEdits|plan|bypassPermissions)');
|
||||||
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
|
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
|
||||||
if (!valid.contains(mode)) {
|
if (!valid.contains(mode)) {
|
||||||
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
|
return _userErr('unknown mode "$mode"; use one of: ${valid.join(', ')}');
|
||||||
}
|
}
|
||||||
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
|
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
|
||||||
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
|
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
|
||||||
@@ -188,7 +278,7 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
title: 'Claude: Cycle permission mode',
|
title: 'Claude: Cycle permission mode',
|
||||||
run: (_) async {
|
run: (_) async {
|
||||||
final managed = _orchestrator?.byId('primary');
|
final managed = _orchestrator?.byId('primary');
|
||||||
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
|
if (managed == null) return _notFound('no primary session');
|
||||||
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
|
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
|
||||||
managed.session.setPermissionMode(next);
|
managed.session.setPermissionMode(next);
|
||||||
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
|
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
|
||||||
@@ -200,11 +290,12 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
command: 'claude.task.reassign',
|
command: 'claude.task.reassign',
|
||||||
title: 'Claude: reassign a shared task to an agent',
|
title: 'Claude: reassign a shared task to an agent',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
|
if (args.length < 2) return _userErr('usage: <taskId> <sessionId>');
|
||||||
final taskId = args[0];
|
final taskId = args[0];
|
||||||
final toId = args[1];
|
final toId = args[1];
|
||||||
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
|
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
|
||||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
|
if (!ok) return _notFound('could not reassign task "$taskId" to "$toId"');
|
||||||
|
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': true});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// T-180: full team chat pane opened as a workspace tab.
|
// T-180: full team chat pane opened as a workspace tab.
|
||||||
@@ -241,7 +332,7 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
command: 'claude.team-chat.post',
|
command: 'claude.team-chat.post',
|
||||||
title: 'Claude: post a message into the team channel as the user',
|
title: 'Claude: post a message into the team channel as the user',
|
||||||
run: (args) async {
|
run: (args) async {
|
||||||
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
|
if (args.isEmpty) return _userErr('usage: [@name] <text>');
|
||||||
final raw = args.join(' ');
|
final raw = args.join(' ');
|
||||||
String? recipient;
|
String? recipient;
|
||||||
String body = raw;
|
String body = raw;
|
||||||
@@ -269,15 +360,18 @@ class ClaudeExtension extends ClideExtension {
|
|||||||
run: (args) async {
|
run: (args) async {
|
||||||
final sourceId = args.firstOrNull;
|
final sourceId = args.firstOrNull;
|
||||||
if (sourceId == null) {
|
if (sourceId == null) {
|
||||||
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
|
return _userErr('usage: claude.agent.fork <sourceSessionId> [<cwd>]');
|
||||||
}
|
}
|
||||||
final orch = _orchestrator;
|
final orch = _orchestrator;
|
||||||
if (orch == null) {
|
if (orch == null) {
|
||||||
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
|
return IpcResponse.err(
|
||||||
|
id: '',
|
||||||
|
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'orchestrator unavailable'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final source = orch.byId(sourceId);
|
final source = orch.byId(sourceId);
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
|
return _notFound('unknown session "$sourceId"');
|
||||||
}
|
}
|
||||||
final cwd = args.length >= 2 ? args[1] : source.cwd;
|
final cwd = args.length >= 2 ? args[1] : source.cwd;
|
||||||
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
|
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
@@ -292,6 +386,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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/// Read-only file follower for the Bash live-tail sub-card (T-325).
|
||||||
|
///
|
||||||
|
/// clide can't see a running Bash command's stdout (Claude Code owns the
|
||||||
|
/// process), so to "watch the same output" we open our OWN read-only follower
|
||||||
|
/// on the file the command tails. This never spawns a process and never
|
||||||
|
/// touches Claude's command — it just reads the file as it grows, like
|
||||||
|
/// `tail -f`, and hands new bytes to `onData`.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// truncation/rotation (size shrinking → re-read from the top).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
class FileTailFollower {
|
||||||
|
FileTailFollower(this.path, {required this.onData, this.tailBytes = 16384, this.interval = const Duration(milliseconds: 300)});
|
||||||
|
|
||||||
|
/// Absolute path of the file to follow.
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
/// New bytes appended since the last read (or the initial tail window).
|
||||||
|
final void Function(Uint8List bytes) onData;
|
||||||
|
|
||||||
|
/// On first read, start this many bytes from the end (a `tail -c` window)
|
||||||
|
/// rather than dumping the whole file.
|
||||||
|
final int tailBytes;
|
||||||
|
|
||||||
|
final Duration interval;
|
||||||
|
|
||||||
|
int _pos = 0;
|
||||||
|
bool _primed = false;
|
||||||
|
bool _stopped = false;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
/// Begin following: emit the initial tail window, then poll for growth.
|
||||||
|
Future<void> start() async {
|
||||||
|
await pollOnce();
|
||||||
|
if (_stopped) return;
|
||||||
|
_timer = Timer.periodic(interval, (_) => pollOnce());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One read cycle. Public so tests can drive it deterministically without
|
||||||
|
/// waiting on the timer. Reads any bytes appended since the last position
|
||||||
|
/// (or, on the first call, the trailing [tailBytes]); resets to the top if
|
||||||
|
/// the file shrank (truncated/rotated).
|
||||||
|
Future<void> pollOnce() async {
|
||||||
|
if (_stopped) return;
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) return; // not created yet — keep waiting
|
||||||
|
final length = await file.length();
|
||||||
|
|
||||||
|
if (!_primed) {
|
||||||
|
_pos = length > tailBytes ? length - tailBytes : 0;
|
||||||
|
_primed = true;
|
||||||
|
} else if (length < _pos) {
|
||||||
|
_pos = 0; // truncated / rotated → re-read from the top
|
||||||
|
}
|
||||||
|
if (length <= _pos) return;
|
||||||
|
|
||||||
|
final raf = await file.open();
|
||||||
|
try {
|
||||||
|
await raf.setPosition(_pos);
|
||||||
|
final bytes = await raf.read(length - _pos);
|
||||||
|
_pos = length;
|
||||||
|
if (!_stopped && bytes.isNotEmpty) onData(Uint8List.fromList(bytes));
|
||||||
|
} finally {
|
||||||
|
await raf.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop following and release the timer. Idempotent.
|
||||||
|
void stop() {
|
||||||
|
_stopped = true;
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ void openImageLightbox(BuildContext context, String path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _placeholder(BuildContext context, double size) {
|
Widget _placeholder(BuildContext context, double size) {
|
||||||
final t = ClideTheme.of(context).surface;
|
final t = ClideSettings.theme.of(context).surface;
|
||||||
return Container(
|
return Container(
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -46,7 +46,7 @@ class ImageThumbnail extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = ClideTheme.of(context).surface;
|
final t = ClideSettings.theme.of(context).surface;
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'Image $_fileName',
|
label: 'Image $_fileName',
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/// The Activity tab: session controls, usage, stats (stats-cache.json), and
|
||||||
|
/// the primary session's live runtime row. Split out of
|
||||||
|
/// claude_meta_sidebar.dart (T-395); session controls + the usage block are
|
||||||
|
/// the power-panel additions (T-415).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/claude_stats.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||||
|
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class ActivityTabView extends StatelessWidget {
|
||||||
|
const ActivityTabView({
|
||||||
|
super.key,
|
||||||
|
required this.stats,
|
||||||
|
required this.primaryStatus,
|
||||||
|
required this.config,
|
||||||
|
this.usage,
|
||||||
|
this.workflows = const <String, WorkflowRun>{},
|
||||||
|
});
|
||||||
|
|
||||||
|
final ClaudeStats stats;
|
||||||
|
final SessionStatus? primaryStatus;
|
||||||
|
final ClaudeConfig? config;
|
||||||
|
|
||||||
|
/// Parsed `/usage` output for the usage block, refreshed via the refresh
|
||||||
|
/// control (T-415). Null until the first refresh.
|
||||||
|
final ClaudeUsage? usage;
|
||||||
|
|
||||||
|
/// Live Workflow runs in the primary session, keyed by launching tool-use id
|
||||||
|
/// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with
|
||||||
|
/// its done/total agent count and running/done state.
|
||||||
|
final Map<String, WorkflowRun> workflows;
|
||||||
|
|
||||||
|
/// Publish a slash command for the primary pane to execute — the session
|
||||||
|
/// controls are the same code path as typing the command (D-6).
|
||||||
|
void _command(BuildContext context, String text) {
|
||||||
|
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': text});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
final latest = stats.latest;
|
||||||
|
final u = usage;
|
||||||
|
final sections = <MetaSection>[
|
||||||
|
..._workflowSection(tokens),
|
||||||
|
if (u != null)
|
||||||
|
MetaSection('USAGE', [
|
||||||
|
if (u.session != null) MetaRow('session', u.session!),
|
||||||
|
if (u.week != null) MetaRow('week (all)', u.week!),
|
||||||
|
if (u.weekSonnet != null) MetaRow('week (sonnet)', u.weekSonnet!),
|
||||||
|
]),
|
||||||
|
if (latest != null)
|
||||||
|
MetaSection('TODAY', [
|
||||||
|
MetaRow('messages', '${latest.messageCount}'),
|
||||||
|
MetaRow('sessions', '${latest.sessionCount}'),
|
||||||
|
MetaRow('tool calls', '${latest.toolCallCount}'),
|
||||||
|
]),
|
||||||
|
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
|
||||||
|
..._runtimeSection(tokens),
|
||||||
|
];
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
children: [
|
||||||
|
// SESSION control strip (T-415): drives the primary session through
|
||||||
|
// the builtin.claude/command bus — identical to typing the command.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ClideText('SESSION', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_control(context, tokens, 'clear', 'trash', '/clear'),
|
||||||
|
_control(context, tokens, 'compact', 'arrows-in-simple', '/compact'),
|
||||||
|
_control(context, tokens, 'fork', 'git-branch', '/fork'),
|
||||||
|
_control(context, tokens, 'resume', 'clock-counter-clockwise', '/resume'),
|
||||||
|
const Spacer(),
|
||||||
|
_control(context, tokens, 'refresh usage', 'arrow-clockwise', '/usage'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
if (sections.isEmpty) metaPlaceholder('No activity recorded yet.') else ...metaTableChildren(tokens, sections),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
final st = primaryStatus;
|
||||||
|
final skills = config?.skills.length;
|
||||||
|
final rows = <MetaRow>[
|
||||||
|
if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
|
||||||
|
if (st?.effort != null) MetaRow('effort', st!.effort!),
|
||||||
|
if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
|
||||||
|
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
|
||||||
|
if (skills != null) MetaRow('skills', '$skills'),
|
||||||
|
];
|
||||||
|
return rows.isEmpty ? const [] : [MetaSection('RUNTIME · primary', rows)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
/// The Config tab (T-183): the settings table over [ClaudeConfig] plus the
|
||||||
|
/// skills/agents/commands/hooks/permissions/MCP accordion. Split out of
|
||||||
|
/// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in
|
||||||
|
/// the parent (it survives tab switches) and arrives as a prop + toggle
|
||||||
|
/// callback.
|
||||||
|
///
|
||||||
|
/// T-414 makes the settings table a control panel: model / effort /
|
||||||
|
/// permission-mode rows are live popover controls. Picking an option
|
||||||
|
/// publishes the explicit slash command (`/model sonnet`) on the
|
||||||
|
/// `builtin.claude`/`command` channel; the primary Claude pane executes it
|
||||||
|
/// through the same `_send` routing the composer uses — one implementation,
|
||||||
|
/// two surfaces (D-6).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/stream_json_session.dart' show ModelOption, kEffortLevels, kFallbackModels, kPermissionModes;
|
||||||
|
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class ConfigTabView extends StatelessWidget {
|
||||||
|
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection, this.status, this.models});
|
||||||
|
|
||||||
|
final ClaudeConfig? config;
|
||||||
|
|
||||||
|
/// The primary session's live status — drives the control rows' current
|
||||||
|
/// values. Null before the session reports (controls fall back to the
|
||||||
|
/// probe/settings values).
|
||||||
|
final SessionStatus? status;
|
||||||
|
|
||||||
|
/// Models selectable for the primary session (from its `initialize`
|
||||||
|
/// response); falls back to [kFallbackModels].
|
||||||
|
final List<ModelOption>? models;
|
||||||
|
|
||||||
|
/// Sections currently expanded — owned by the parent state.
|
||||||
|
final Set<ConfigSection> expanded;
|
||||||
|
final void Function(ConfigSection section) onToggleSection;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
final cfg = config;
|
||||||
|
if (cfg == null) {
|
||||||
|
return metaPlaceholder('Claude environment not loaded.');
|
||||||
|
}
|
||||||
|
final settings = cfg.settings;
|
||||||
|
final model = status?.model ?? cfg.probe?.model ?? settings['model']?.toString() ?? 'default';
|
||||||
|
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
|
||||||
|
final mode = status?.permissionMode ?? cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
|
||||||
|
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
|
||||||
|
|
||||||
|
final children = <Widget>[
|
||||||
|
// Pinned SETTINGS control panel — not collapsible.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||||
|
),
|
||||||
|
SettingControlRow(
|
||||||
|
label: 'model',
|
||||||
|
value: model,
|
||||||
|
valueColor: tokens.globalFocus,
|
||||||
|
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
|
||||||
|
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
|
||||||
|
command: 'model',
|
||||||
|
),
|
||||||
|
SettingControlRow(label: 'effort', value: effort, options: kEffortLevels, isActive: (o) => o.value == effort, command: 'effort'),
|
||||||
|
SettingControlRow(
|
||||||
|
label: 'permission mode',
|
||||||
|
value: permissionModeLabel(mode),
|
||||||
|
options: kPermissionModes,
|
||||||
|
isActive: (o) => o.value == mode,
|
||||||
|
command: 'permissions',
|
||||||
|
),
|
||||||
|
_configRow(tokens, 'output style', outputStyle),
|
||||||
|
_configRow(tokens, 'source', '~/.claude + .claude'),
|
||||||
|
|
||||||
|
// ---- Accordion sections ----
|
||||||
|
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section),
|
||||||
|
|
||||||
|
// Footer hint.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One read-only key→value row in the pinned SETTINGS table.
|
||||||
|
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: kMetaLabelColumnWidth,
|
||||||
|
child: ClideText(label, muted: true, fontSize: kMetaFont),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ClideText(value, fontSize: kMetaFont, color: valueColor ?? tokens.globalForeground),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _sectionLabel(ConfigSection section) => switch (section) {
|
||||||
|
ConfigSection.skills => 'SKILLS',
|
||||||
|
ConfigSection.agents => 'AGENTS',
|
||||||
|
ConfigSection.commands => 'COMMANDS',
|
||||||
|
ConfigSection.hooks => 'HOOKS',
|
||||||
|
ConfigSection.permissions => 'PERMISSIONS',
|
||||||
|
ConfigSection.mcpServers => 'MCP SERVERS',
|
||||||
|
};
|
||||||
|
|
||||||
|
int _sectionCount(ClaudeConfig config, ConfigSection section) => switch (section) {
|
||||||
|
ConfigSection.skills => config.skills.length,
|
||||||
|
ConfigSection.agents => config.agents.length,
|
||||||
|
ConfigSection.commands => config.commands.length,
|
||||||
|
ConfigSection.hooks => config.hooks.length,
|
||||||
|
ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
|
||||||
|
ConfigSection.mcpServers => config.mcpServers.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
Widget _accordion(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||||
|
final isExpanded = expanded.contains(section);
|
||||||
|
final children = isExpanded ? _sectionChildren(context, tokens, config, section) : const <Widget>[];
|
||||||
|
return ClideAccordion(
|
||||||
|
label: _sectionLabel(section),
|
||||||
|
count: _sectionCount(config, section),
|
||||||
|
expanded: isExpanded,
|
||||||
|
onToggle: () => onToggleSection(section),
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _sectionChildren(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||||
|
switch (section) {
|
||||||
|
case ConfigSection.skills:
|
||||||
|
return [for (final skill in config.skills) _fileRow(context, tokens, skill.name, skill.path)];
|
||||||
|
case ConfigSection.agents:
|
||||||
|
return [for (final agent in config.agents) _fileRow(context, tokens, agent.name, agent.path)];
|
||||||
|
case ConfigSection.commands:
|
||||||
|
return [for (final cmd in config.commands) _fileRow(context, tokens, cmd.name, cmd.path)];
|
||||||
|
case ConfigSection.hooks:
|
||||||
|
return [
|
||||||
|
for (final hook in config.hooks)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClideText(hook.event, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||||
|
for (final cmd in hook.commands)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8, top: 1),
|
||||||
|
child: ClideText(cmd, fontSize: clideFontSmall, muted: true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
case ConfigSection.permissions:
|
||||||
|
return _permissionRows(tokens, config.permissions);
|
||||||
|
case ConfigSection.mcpServers:
|
||||||
|
return [
|
||||||
|
for (final srv in config.mcpServers)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||||
|
child: ClideText(srv.name, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tappable row for file-backed items (skills, agents, commands).
|
||||||
|
/// All config items are .md files — opens in the markdown reader panel
|
||||||
|
/// via the kernel MessageBus (D-6, T-183).
|
||||||
|
Widget _fileRow(BuildContext context, SurfaceTokens tokens, String name, String? path) {
|
||||||
|
final row = Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||||
|
child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
|
||||||
|
);
|
||||||
|
if (path == null) return row;
|
||||||
|
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: name,
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: openMarkdown,
|
||||||
|
child: ClideTappable(
|
||||||
|
tooltip: path,
|
||||||
|
onTap: openMarkdown,
|
||||||
|
builder: (ctx, hovered, _) => Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||||
|
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders grouped allow/ask/deny permission rows, colour-coded by kind.
|
||||||
|
List<Widget> _permissionRows(SurfaceTokens tokens, ClaudePermissions perms) {
|
||||||
|
// allow → statusSuccess, ask → statusWarning, deny → statusError
|
||||||
|
Color kindColor(ConfigPermKind k) => switch (k) {
|
||||||
|
ConfigPermKind.allow => tokens.statusSuccess,
|
||||||
|
ConfigPermKind.ask => tokens.statusWarning,
|
||||||
|
ConfigPermKind.deny => tokens.statusError,
|
||||||
|
};
|
||||||
|
|
||||||
|
String kindLabel(ConfigPermKind k) => switch (k) {
|
||||||
|
ConfigPermKind.allow => 'allow',
|
||||||
|
ConfigPermKind.ask => 'ask',
|
||||||
|
ConfigPermKind.deny => 'deny',
|
||||||
|
};
|
||||||
|
|
||||||
|
final groups = [(ConfigPermKind.allow, perms.allow), (ConfigPermKind.ask, perms.ask), (ConfigPermKind.deny, perms.deny)];
|
||||||
|
|
||||||
|
final rows = <Widget>[];
|
||||||
|
for (final (kind, rules) in groups) {
|
||||||
|
if (rules.isEmpty) continue;
|
||||||
|
final color = kindColor(kind);
|
||||||
|
rows.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 36,
|
||||||
|
child: ClideText(kindLabel(kind), fontSize: clideFontSmall, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (final rule in rules)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 1),
|
||||||
|
child: ClideText(rule, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One live setting row (T-414): label + current value as a popover control on
|
||||||
|
/// the owned anchored-menu primitive. Picking an option publishes the explicit
|
||||||
|
/// slash command on `builtin.claude`/`command`; the primary Claude pane
|
||||||
|
/// executes it through its normal `_send` routing — so the sidebar control and
|
||||||
|
/// the typed command are literally the same code path (D-6).
|
||||||
|
class SettingControlRow extends StatefulWidget {
|
||||||
|
const SettingControlRow({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.options,
|
||||||
|
required this.isActive,
|
||||||
|
required this.command,
|
||||||
|
this.valueColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Current value, displayed on the trigger.
|
||||||
|
final String value;
|
||||||
|
final Color? valueColor;
|
||||||
|
|
||||||
|
final List<ModelOption> options;
|
||||||
|
final bool Function(ModelOption option) isActive;
|
||||||
|
|
||||||
|
/// The slash-command token this control drives (`model`, `effort`,
|
||||||
|
/// `permissions`); a pick publishes `/<command> <option.value>`.
|
||||||
|
final String command;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingControlRow> createState() => _SettingControlRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingControlRowState extends State<SettingControlRow> {
|
||||||
|
final ClideOverlayController _overlay = ClideOverlayController();
|
||||||
|
|
||||||
|
void _pick(String value) {
|
||||||
|
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': '/${widget.command} $value'});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: kMetaLabelColumnWidth,
|
||||||
|
child: ClideText(widget.label, muted: true, fontSize: kMetaFont),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ClideAnchoredOverlay(
|
||||||
|
controller: _overlay,
|
||||||
|
align: ClideAnchorAlign.start,
|
||||||
|
overlayBuilder: (ctx, c) => ClideMenu(
|
||||||
|
onClose: c.close,
|
||||||
|
entries: [
|
||||||
|
for (final o in widget.options)
|
||||||
|
ClideMenuItem(
|
||||||
|
label: o.description.isEmpty ? o.displayName : '${o.displayName} — ${o.description}',
|
||||||
|
active: widget.isActive(o),
|
||||||
|
semanticLabel: '${widget.label}: ${o.displayName}',
|
||||||
|
onSelect: () => _pick(o.value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
anchor: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: '${widget.label}: ${widget.value}. Click to change.',
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: _overlay.toggle,
|
||||||
|
child: ClideTappable(
|
||||||
|
tooltip: 'change ${widget.label}',
|
||||||
|
onTap: _overlay.toggle,
|
||||||
|
builder: (ctx, hovered, _) => DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: ClideText(widget.value, fontSize: kMetaFont, color: widget.valueColor ?? tokens.globalForeground, maxLines: 1),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/// A single icon-button used by the roster row controls + task rows.
|
||||||
|
/// Split out of claude_meta_sidebar.dart (T-395). Promote to
|
||||||
|
/// lib/widgets/ only when a second consumer appears.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class MetaIconButton extends StatelessWidget {
|
||||||
|
const MetaIconButton({super.key, required this.painter, required this.tooltip, required this.color, required this.onTap});
|
||||||
|
|
||||||
|
final ClideIconPainter painter;
|
||||||
|
final String tooltip;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Icon-only button: expose the tooltip text as the Semantics button label
|
||||||
|
// so AT (and widget tests) can find and activate it by name.
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: tooltip,
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: onTap,
|
||||||
|
child: ClideTappable(
|
||||||
|
tooltip: tooltip,
|
||||||
|
onTap: onTap,
|
||||||
|
builder: (ctx, hovered, _) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||||
|
child: ClideIcon(painter, size: 12, color: hovered ? ClideSettings.theme.of(ctx).surface.globalForeground : color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/// Inline text input for injecting a message into a session (T-171).
|
||||||
|
/// Submits on Enter; Cancel is handled by the parent's icon button.
|
||||||
|
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class InjectTextField extends StatelessWidget {
|
||||||
|
const InjectTextField({super.key, required this.controller, required this.tokens, required this.onSubmit});
|
||||||
|
|
||||||
|
final TextEditingController controller;
|
||||||
|
final SurfaceTokens tokens;
|
||||||
|
final void Function(String text) onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 22,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tokens.panelBackground,
|
||||||
|
border: Border.all(color: tokens.panelBorder),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: EditableText(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
|
||||||
|
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||||
|
cursorColor: tokens.globalFocus,
|
||||||
|
backgroundCursorColor: tokens.globalTextMuted,
|
||||||
|
onSubmitted: onSubmit,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/// Shared models + table geometry for the Claude meta sidebar's tabs.
|
||||||
|
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// The shared label-column width + row pitch the Activity and Config tables
|
||||||
|
/// both use, so toggling between tabs keeps every value at the same x and y.
|
||||||
|
const double kMetaLabelColumnWidth = 110;
|
||||||
|
const double kMetaRowPitch = 6;
|
||||||
|
|
||||||
|
/// Type scale for the sidebar tables (T-414 styling pass): labels/values read
|
||||||
|
/// at meta size (13) — the old 12px-everything read as bland and cramped.
|
||||||
|
const double kMetaFont = clideFontMeta;
|
||||||
|
|
||||||
|
/// The sidebar's sub-tabs.
|
||||||
|
enum SidebarTab { activity, team, config }
|
||||||
|
|
||||||
|
// T-183: accordion sections for the Config tab.
|
||||||
|
enum ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
|
||||||
|
|
||||||
|
/// Permission kind for colour-coding in the Config tab (T-183).
|
||||||
|
enum ConfigPermKind { allow, ask, deny }
|
||||||
|
|
||||||
|
class MetaSection {
|
||||||
|
const MetaSection(this.header, this.rows);
|
||||||
|
final String header;
|
||||||
|
final List<MetaRow> rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MetaRow {
|
||||||
|
const MetaRow(this.label, this.value, {this.valueColor});
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color? valueColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The muted empty-state body shared by every tab.
|
||||||
|
Widget metaPlaceholder(String text) => Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: ClideText(text, muted: true, fontSize: kMetaFont),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Key→value sections on the shared table geometry (Activity + Config).
|
||||||
|
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) =>
|
||||||
|
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections));
|
||||||
|
|
||||||
|
/// The table rows without the enclosing ListView, for tabs that compose extra
|
||||||
|
/// widgets around the sections (the Activity tab's control strip, T-415).
|
||||||
|
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||||
|
final children = <Widget>[];
|
||||||
|
for (var i = 0; i < sections.length; i++) {
|
||||||
|
final s = sections[i];
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
|
||||||
|
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (final r in s.rows) {
|
||||||
|
children.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: kMetaLabelColumnWidth,
|
||||||
|
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/// Clickable permission-mode badge shown in each roster row (T-181).
|
||||||
|
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/services.dart' show HardwareKeyboard;
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Maps a permission-mode string to a single-letter badge label.
|
||||||
|
String permissionModeBadgeLabel(String mode) => switch (mode) {
|
||||||
|
'acceptEdits' => 'A',
|
||||||
|
'plan' => 'P',
|
||||||
|
'bypassPermissions' => 'B',
|
||||||
|
_ => 'D', // default
|
||||||
|
};
|
||||||
|
|
||||||
|
/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default.
|
||||||
|
/// - Shift-click → shows the bypass confirm inline in the parent row.
|
||||||
|
///
|
||||||
|
/// The badge reflects the LIVE mode from `SessionStatus.permissionMode`
|
||||||
|
/// (T-157). It is a custom painted label (no Material), consistent with the
|
||||||
|
/// rendering stack rules (D-7, CLAUDE.md guardrails).
|
||||||
|
class PermissionModeBadge extends StatelessWidget {
|
||||||
|
const PermissionModeBadge({super.key, required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
|
||||||
|
|
||||||
|
final String mode;
|
||||||
|
final SurfaceTokens tokens;
|
||||||
|
|
||||||
|
/// Called on a plain click — the parent cycles to the next safe mode.
|
||||||
|
final VoidCallback onCycle;
|
||||||
|
|
||||||
|
/// Called on a shift-click — the parent shows the bypass confirm.
|
||||||
|
final VoidCallback onBypass;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final label = permissionModeBadgeLabel(mode);
|
||||||
|
final isBypass = mode == 'bypassPermissions';
|
||||||
|
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
|
||||||
|
|
||||||
|
final tooltip =
|
||||||
|
'Permission mode: ${permissionModeLabel(mode)}. '
|
||||||
|
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 3),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: 'Permission mode: $label',
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: () {
|
||||||
|
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||||
|
onBypass();
|
||||||
|
} else {
|
||||||
|
onCycle();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: ClideTappable(
|
||||||
|
tooltip: tooltip,
|
||||||
|
onTap: () {
|
||||||
|
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||||
|
onBypass();
|
||||||
|
} else {
|
||||||
|
onCycle();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (ctx, hovered, _) => Container(
|
||||||
|
width: 16,
|
||||||
|
height: 14,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: badgeColor.withAlpha(hovered ? 51 : 26),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
|
||||||
|
),
|
||||||
|
child: ClideText(label, fontSize: 9, color: badgeColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/// A single agent roster row: color dot + name + status sub-text +
|
||||||
|
/// controls (T-171). Split out of claude_meta_sidebar.dart (T-395).
|
||||||
|
///
|
||||||
|
/// Controls (trailing region):
|
||||||
|
/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click
|
||||||
|
/// reaches bypassPermissions behind a confirm
|
||||||
|
/// - eye / eye-slash — show / hide the session pane
|
||||||
|
/// - speaker / speaker-slash — mute / unmute broker delivery
|
||||||
|
/// - inject (chat icon) — expand the inline message input
|
||||||
|
/// - fork (git-branch icon) — open a new pane branching from this session (T-172)
|
||||||
|
/// - close (×) — kill the session
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/inject_field.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/permission_badge.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor;
|
||||||
|
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class AgentRosterRow extends StatefulWidget {
|
||||||
|
const AgentRosterRow({
|
||||||
|
super.key,
|
||||||
|
required this.member,
|
||||||
|
required this.status,
|
||||||
|
required this.orchestrator,
|
||||||
|
required this.injectingAgentId,
|
||||||
|
required this.injectController,
|
||||||
|
required this.onToggleInject,
|
||||||
|
required this.onInjectSubmit,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onSetPermissionMode,
|
||||||
|
required this.onFork,
|
||||||
|
});
|
||||||
|
|
||||||
|
final TeamMemberJoined member;
|
||||||
|
final SessionStatus? status;
|
||||||
|
final ClaudeSessionOrchestrator? orchestrator;
|
||||||
|
|
||||||
|
/// The member name currently in inject mode (null = none).
|
||||||
|
final String? injectingAgentId;
|
||||||
|
|
||||||
|
/// Shared text controller for the inject field (cleared on submit/cancel).
|
||||||
|
final TextEditingController injectController;
|
||||||
|
|
||||||
|
final void Function(String memberName) onToggleInject;
|
||||||
|
final void Function(String memberName, String text) onInjectSubmit;
|
||||||
|
final void Function(String memberName) onClose;
|
||||||
|
|
||||||
|
/// Called when the badge cycles to a new `mode` string for this member.
|
||||||
|
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
|
||||||
|
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
|
||||||
|
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||||
|
|
||||||
|
/// Called when the fork button is tapped (T-172). The session id of the
|
||||||
|
/// member's managed session is passed so the host can open a fork pane.
|
||||||
|
final void Function(String memberName) onFork;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AgentRosterRow> createState() => _AgentRosterRowState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AgentRosterRowState extends State<AgentRosterRow> {
|
||||||
|
/// Whether the bypass-confirm inline prompt is showing.
|
||||||
|
bool _confirmingBypass = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
final managed = widget.orchestrator?.byMemberName(widget.member.name);
|
||||||
|
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
|
||||||
|
final st = widget.status;
|
||||||
|
final model = st?.model ?? widget.member.model;
|
||||||
|
final sub = [
|
||||||
|
widget.member.agentType,
|
||||||
|
if (model != null) shortModelLabel(model),
|
||||||
|
if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!),
|
||||||
|
if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx',
|
||||||
|
].join(' · ');
|
||||||
|
|
||||||
|
final isVisible = managed?.visible ?? true;
|
||||||
|
final isMuted = managed?.muted ?? false;
|
||||||
|
final isInjecting = widget.injectingAgentId == widget.member.name;
|
||||||
|
final currentMode = st?.permissionMode ?? 'default';
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Color dot
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 3),
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Name + status
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
// T-181: permission-mode badge (inline below the status sub-text).
|
||||||
|
if (managed != null)
|
||||||
|
PermissionModeBadge(
|
||||||
|
mode: currentMode,
|
||||||
|
tokens: tokens,
|
||||||
|
onCycle: () {
|
||||||
|
final next = _nextSafeMode(currentMode);
|
||||||
|
widget.onSetPermissionMode(widget.member.name, next);
|
||||||
|
},
|
||||||
|
onBypass: () => setState(() => _confirmingBypass = true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// Trailing controls (T-171).
|
||||||
|
// T-172 seam: append a fork icon button to this row.
|
||||||
|
if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Bypass confirm: replaces inject field area when active.
|
||||||
|
if (_confirmingBypass) _buildBypassConfirm(tokens),
|
||||||
|
// Inline inject-message field — visible only when toggled.
|
||||||
|
if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Safe-mode cycle: default → acceptEdits → plan → default (T-181).
|
||||||
|
static String _nextSafeMode(String current) {
|
||||||
|
const cycle = ['default', 'acceptEdits', 'plan'];
|
||||||
|
final idx = cycle.indexOf(current);
|
||||||
|
return cycle[(idx + 1) % cycle.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBypassConfirm(SurfaceTokens tokens) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// Confirm
|
||||||
|
Semantics(
|
||||||
|
button: true,
|
||||||
|
label: 'Confirm bypass',
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: () {
|
||||||
|
setState(() => _confirmingBypass = false);
|
||||||
|
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||||
|
},
|
||||||
|
child: ClideTappable(
|
||||||
|
tooltip: 'Confirm',
|
||||||
|
onTap: () {
|
||||||
|
setState(() => _confirmingBypass = false);
|
||||||
|
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||||
|
},
|
||||||
|
builder: (ctx, hovered, _) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||||
|
child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
// Cancel
|
||||||
|
Semantics(
|
||||||
|
button: true,
|
||||||
|
label: 'Cancel bypass',
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: () => setState(() => _confirmingBypass = false),
|
||||||
|
child: ClideTappable(
|
||||||
|
tooltip: 'Cancel',
|
||||||
|
onTap: () => setState(() => _confirmingBypass = false),
|
||||||
|
builder: (ctx, hovered, _) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||||
|
child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Show / hide
|
||||||
|
MetaIconButton(
|
||||||
|
painter: isVisible ? PhosphorIcons.byName('eye') : PhosphorIcons.byName('eye-slash'),
|
||||||
|
tooltip: isVisible ? 'Hide pane' : 'Show pane',
|
||||||
|
color: tokens.globalTextMuted,
|
||||||
|
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
|
||||||
|
),
|
||||||
|
// Mute / unmute
|
||||||
|
MetaIconButton(
|
||||||
|
painter: isMuted ? PhosphorIcons.byName('eye-slash') : PhosphorIcons.byName('eye'),
|
||||||
|
// NOTE: We use eye/eyeSlash as stand-ins until a dedicated speaker
|
||||||
|
// icon is added to PhosphorIcons (no speaker codepoint yet).
|
||||||
|
// The semantic tooltip still says mute/unmute so AT users are clear.
|
||||||
|
tooltip: isMuted ? 'Unmute messages' : 'Mute messages',
|
||||||
|
color: isMuted ? tokens.globalFocus : tokens.globalTextMuted,
|
||||||
|
onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id),
|
||||||
|
),
|
||||||
|
// Inject message
|
||||||
|
MetaIconButton(
|
||||||
|
painter: PhosphorIcons.byName('chat-circle'),
|
||||||
|
tooltip: 'Inject message',
|
||||||
|
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
|
||||||
|
onTap: () => widget.onToggleInject(widget.member.name),
|
||||||
|
),
|
||||||
|
// Fork session (T-172): branch into a new pane without touching the original.
|
||||||
|
MetaIconButton(
|
||||||
|
painter: PhosphorIcons.byName('git-branch'),
|
||||||
|
tooltip: 'Fork session',
|
||||||
|
color: tokens.globalTextMuted,
|
||||||
|
onTap: () => widget.onFork(widget.member.name),
|
||||||
|
),
|
||||||
|
// Close session
|
||||||
|
MetaIconButton(
|
||||||
|
painter: PhosphorIcons.byName('x'),
|
||||||
|
tooltip: 'Close session',
|
||||||
|
color: tokens.globalTextMuted,
|
||||||
|
onTap: () => widget.onClose(widget.member.name),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInjectField(BuildContext context, SurfaceTokens tokens) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: InjectTextField(
|
||||||
|
controller: widget.injectController,
|
||||||
|
tokens: tokens,
|
||||||
|
onSubmit: (text) {
|
||||||
|
if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
MetaIconButton(
|
||||||
|
painter: PhosphorIcons.byName('x'),
|
||||||
|
tooltip: 'Cancel',
|
||||||
|
color: tokens.globalTextMuted,
|
||||||
|
onTap: () => widget.onToggleInject(widget.member.name),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/// The Activity / Team / Config sub-tab strip — same interaction as the pql
|
||||||
|
/// panel's view tabs, with an underline under the active tab. Split out of
|
||||||
|
/// claude_meta_sidebar.dart (T-395).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class SidebarTabStrip extends StatelessWidget {
|
||||||
|
const SidebarTabStrip({super.key, required this.current, required this.memberCount, required this.onPick});
|
||||||
|
final SidebarTab current;
|
||||||
|
final int memberCount;
|
||||||
|
final ValueChanged<SidebarTab> onPick;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
for (final t in SidebarTab.values)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: t == current,
|
||||||
|
label: _label(t),
|
||||||
|
excludeSemantics: true,
|
||||||
|
onTap: () => onPick(t),
|
||||||
|
child: ClideTappable(
|
||||||
|
onTap: () => onPick(t),
|
||||||
|
builder: (ctx, hovered, _) => Container(
|
||||||
|
padding: const EdgeInsets.only(bottom: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
|
||||||
|
),
|
||||||
|
child: ClideText(_label(t), fontSize: clideFontSmall, color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _label(SidebarTab t) => switch (t) {
|
||||||
|
SidebarTab.activity => 'Activity',
|
||||||
|
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
|
||||||
|
SidebarTab.config => 'Config',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/// One row in the Team tab's TASKS section: status marker + title +
|
||||||
|
/// owner + reassign control (T-171). Split out of
|
||||||
|
/// claude_meta_sidebar.dart (T-395).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class TaskRow extends StatelessWidget {
|
||||||
|
const TaskRow({super.key, required this.task, required this.members, required this.broker});
|
||||||
|
|
||||||
|
final TeamTask task;
|
||||||
|
final List<TeamMemberJoined> members;
|
||||||
|
final TeamBroker? broker;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
final marker = switch (task.status) {
|
||||||
|
'done' => '✓',
|
||||||
|
'claimed' => '◈',
|
||||||
|
_ => '○',
|
||||||
|
};
|
||||||
|
final markerColor = switch (task.status) {
|
||||||
|
'done' => tokens.globalTextMuted,
|
||||||
|
'claimed' => tokens.globalFocus,
|
||||||
|
_ => tokens.globalForeground,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClideText(marker, fontSize: clideFontSmall, color: markerColor),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: ClideText(
|
||||||
|
task.title,
|
||||||
|
fontSize: clideFontSmall,
|
||||||
|
color: task.status == 'done' ? tokens.globalTextMuted : tokens.globalForeground,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (task.owner != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4),
|
||||||
|
child: ClideText(task.owner!, fontSize: clideFontSmall, color: tokens.globalFocus),
|
||||||
|
),
|
||||||
|
// Reassign: cycle to the next roster member.
|
||||||
|
if (broker != null && broker!.members.length > 1)
|
||||||
|
MetaIconButton(
|
||||||
|
painter: PhosphorIcons.byName('arrow-clockwise'),
|
||||||
|
tooltip: 'Reassign task',
|
||||||
|
color: tokens.globalTextMuted,
|
||||||
|
onTap: () => _reassign(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reassign(BuildContext context) {
|
||||||
|
final b = broker;
|
||||||
|
if (b == null || members.isEmpty) return;
|
||||||
|
final brokerMembers = b.members;
|
||||||
|
if (brokerMembers.isEmpty) return;
|
||||||
|
// Cycle to the next member after the current owner.
|
||||||
|
final currentIndex = brokerMembers.indexWhere((m) => m.name == task.owner);
|
||||||
|
final nextIndex = (currentIndex + 1) % brokerMembers.length;
|
||||||
|
b.reassignTask(task.id, brokerMembers[nextIndex].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/// The Team tab: the roster cockpit (T-171) — per-member rows with
|
||||||
|
/// controls, the TASKS section, and the MESSAGES chat feed (T-180).
|
||||||
|
/// Stateless and props-driven; the parent owns the member list, inject
|
||||||
|
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
|
||||||
|
/// (T-395).
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/meta_sidebar/task_row.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||||
|
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamTask;
|
||||||
|
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar;
|
||||||
|
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class TeamTabView extends StatelessWidget {
|
||||||
|
const TeamTabView({
|
||||||
|
super.key,
|
||||||
|
required this.members,
|
||||||
|
required this.memberStatus,
|
||||||
|
required this.orchestrator,
|
||||||
|
required this.tasks,
|
||||||
|
required this.injectingAgentId,
|
||||||
|
required this.injectController,
|
||||||
|
required this.onToggleInject,
|
||||||
|
required this.onInjectSubmit,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onSetPermissionMode,
|
||||||
|
required this.onFork,
|
||||||
|
required this.onOpenChatPane,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<TeamMemberJoined> members;
|
||||||
|
final Map<String, SessionStatus> memberStatus;
|
||||||
|
final ClaudeSessionOrchestrator? orchestrator;
|
||||||
|
final List<TeamTask> tasks;
|
||||||
|
final String? injectingAgentId;
|
||||||
|
final TextEditingController injectController;
|
||||||
|
final void Function(String memberName) onToggleInject;
|
||||||
|
final void Function(String memberName, String text) onInjectSubmit;
|
||||||
|
final void Function(String memberName) onClose;
|
||||||
|
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||||
|
final void Function(String memberName) onFork;
|
||||||
|
final VoidCallback onOpenChatPane;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
if (members.isEmpty) {
|
||||||
|
return metaPlaceholder('No team active.');
|
||||||
|
}
|
||||||
|
final children = <Widget>[
|
||||||
|
for (final m in members)
|
||||||
|
AgentRosterRow(
|
||||||
|
key: ValueKey(m.agentId),
|
||||||
|
member: m,
|
||||||
|
status: memberStatus[m.agentId],
|
||||||
|
orchestrator: orchestrator,
|
||||||
|
injectingAgentId: injectingAgentId,
|
||||||
|
injectController: injectController,
|
||||||
|
onToggleInject: onToggleInject,
|
||||||
|
onInjectSubmit: onInjectSubmit,
|
||||||
|
onClose: onClose,
|
||||||
|
onSetPermissionMode: onSetPermissionMode,
|
||||||
|
onFork: onFork,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (tasks.isNotEmpty) {
|
||||||
|
children.add(const SizedBox(height: 12));
|
||||||
|
children.add(_taskSection(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
|
||||||
|
final chatModel = orchestrator?.chatModel;
|
||||||
|
final broker = orchestrator?.broker;
|
||||||
|
if (chatModel != null && broker != null) {
|
||||||
|
children.add(const SizedBox(height: 12));
|
||||||
|
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: onOpenChatPane));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _taskSection(SurfaceTokens tokens) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ClideText('TASKS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/// The `/model` picker for the interaction zone (T-408, D-78): a bare
|
||||||
|
/// `/model` swaps this card in for the composer; picking an entry sends
|
||||||
|
/// `set_model` over the control channel and the composer returns. Esc
|
||||||
|
/// cancels. Like [ToolPromptCard], it lives in the composer zone — never
|
||||||
|
/// inline in the conversation.
|
||||||
|
///
|
||||||
|
/// Keyboard: number keys pick directly (CLI muscle memory, T-240), Up/Down
|
||||||
|
/// move the highlight, Enter picks the highlighted entry, Esc cancels.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||||
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
|
import 'package:clide/widgets/widgets.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Whether [option] is the session's current model. Options carry aliases
|
||||||
|
/// (`sonnet`) or full ids while the status holds the full id
|
||||||
|
/// (`claude-sonnet-4-6`), so match on equality or alias containment.
|
||||||
|
bool modelOptionIsCurrent(ModelOption option, String? currentModel) {
|
||||||
|
if (currentModel == null || option.value == 'default') return false;
|
||||||
|
if (option.value == currentModel) return true;
|
||||||
|
return currentModel.toLowerCase().contains(option.value.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModelPickerCard extends StatefulWidget {
|
||||||
|
const ModelPickerCard({
|
||||||
|
super.key,
|
||||||
|
required this.models,
|
||||||
|
this.currentModel,
|
||||||
|
required this.onPick,
|
||||||
|
required this.onCancel,
|
||||||
|
this.title = 'model',
|
||||||
|
this.isCurrent = modelOptionIsCurrent,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Selectable entries, in display order. Callers pass [kFallbackModels]
|
||||||
|
/// when the session hasn't reported its list yet.
|
||||||
|
final List<ModelOption> models;
|
||||||
|
|
||||||
|
/// The session's current model (full id), to mark the active entry.
|
||||||
|
final String? currentModel;
|
||||||
|
|
||||||
|
/// Called once with the picked [ModelOption.value].
|
||||||
|
final void Function(String value) onPick;
|
||||||
|
|
||||||
|
/// Called when the user dismisses the picker without choosing.
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
|
||||||
|
/// Header label. The /effort picker reuses this card with its own title
|
||||||
|
/// and an exact-match [isCurrent] (T-412).
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// Marks the active entry. The model default ([modelOptionIsCurrent]) also
|
||||||
|
/// alias-matches (`sonnet` ⊂ `claude-sonnet-4-6`); effort needs exact match
|
||||||
|
/// (`high` would falsely match inside `xhigh`).
|
||||||
|
final bool Function(ModelOption option, String? current) isCurrent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ModelPickerCard> createState() => _ModelPickerCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ModelPickerCardState extends State<ModelPickerCard> {
|
||||||
|
late int _highlight = _initialHighlight();
|
||||||
|
|
||||||
|
int _initialHighlight() {
|
||||||
|
for (var i = 0; i < widget.models.length; i++) {
|
||||||
|
if (widget.isCurrent(widget.models[i], widget.currentModel)) return i;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||||
|
if (e is! KeyDownEvent || !node.hasPrimaryFocus) return KeyEventResult.ignored;
|
||||||
|
final hw = HardwareKeyboard.instance;
|
||||||
|
if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored;
|
||||||
|
final key = e.logicalKey;
|
||||||
|
if (key == LogicalKeyboardKey.escape) {
|
||||||
|
widget.onCancel();
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (key == LogicalKeyboardKey.arrowDown) {
|
||||||
|
setState(() => _highlight = (_highlight + 1) % widget.models.length);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (key == LogicalKeyboardKey.arrowUp) {
|
||||||
|
setState(() => _highlight = (_highlight - 1 + widget.models.length) % widget.models.length);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
|
||||||
|
widget.onPick(widget.models[_highlight].value);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
final digit = _digitOf(key);
|
||||||
|
if (digit != null && digit >= 1 && digit <= widget.models.length) {
|
||||||
|
widget.onPick(widget.models[digit - 1].value);
|
||||||
|
return KeyEventResult.handled;
|
||||||
|
}
|
||||||
|
return KeyEventResult.ignored;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _digitOf(LogicalKeyboardKey key) {
|
||||||
|
const digits = [
|
||||||
|
LogicalKeyboardKey.digit1,
|
||||||
|
LogicalKeyboardKey.digit2,
|
||||||
|
LogicalKeyboardKey.digit3,
|
||||||
|
LogicalKeyboardKey.digit4,
|
||||||
|
LogicalKeyboardKey.digit5,
|
||||||
|
LogicalKeyboardKey.digit6,
|
||||||
|
LogicalKeyboardKey.digit7,
|
||||||
|
LogicalKeyboardKey.digit8,
|
||||||
|
LogicalKeyboardKey.digit9,
|
||||||
|
];
|
||||||
|
const numpad = [
|
||||||
|
LogicalKeyboardKey.numpad1,
|
||||||
|
LogicalKeyboardKey.numpad2,
|
||||||
|
LogicalKeyboardKey.numpad3,
|
||||||
|
LogicalKeyboardKey.numpad4,
|
||||||
|
LogicalKeyboardKey.numpad5,
|
||||||
|
LogicalKeyboardKey.numpad6,
|
||||||
|
LogicalKeyboardKey.numpad7,
|
||||||
|
LogicalKeyboardKey.numpad8,
|
||||||
|
LogicalKeyboardKey.numpad9,
|
||||||
|
];
|
||||||
|
var i = digits.indexOf(key);
|
||||||
|
if (i < 0) i = numpad.indexOf(key);
|
||||||
|
return i < 0 ? null : i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
return Focus(
|
||||||
|
autofocus: true,
|
||||||
|
onKeyEvent: _onKey,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tokens.panelBackground,
|
||||||
|
border: Border(top: BorderSide(color: tokens.statusInfo, width: 2)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.statusInfo),
|
||||||
|
const Spacer(),
|
||||||
|
ClideText(
|
||||||
|
'↑↓ · 1-${widget.models.length} · Enter · Esc',
|
||||||
|
fontSize: clideFontMeta,
|
||||||
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||||
|
color: tokens.globalTextMuted,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (var i = 0; i < widget.models.length; i++) _row(tokens, i),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
ClideButton(label: 'cancel', variant: ClideButtonVariant.subtle, onPressed: widget.onCancel),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(SurfaceTokens tokens, int i) {
|
||||||
|
final m = widget.models[i];
|
||||||
|
final current = widget.isCurrent(m, widget.currentModel);
|
||||||
|
final highlighted = i == _highlight;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
child: ClideButton(
|
||||||
|
label: '${i + 1}. ${current ? '●' : '○'} ${m.displayName}${m.description.isEmpty ? '' : ' — ${m.description}'}',
|
||||||
|
variant: highlighted ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
|
||||||
|
onPressed: () => widget.onPick(m.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/// Resolve the `native/<os>-<arch>/` directory name via FFI ABI introspection
|
||||||
|
/// (T-438 web fence, D-100). Desktop-only; the web build uses
|
||||||
|
/// [native_abi_stub.dart], so `dart:ffi` (here, only `Abi`) stays out of the
|
||||||
|
/// wasm graph.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:ffi' show Abi;
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
/// The `<os>-<arch>` dir name for the current process (e.g. `linux-x64`,
|
||||||
|
/// `macos-arm64`) — used to find a dev-tree `clide` binary when running
|
||||||
|
/// un-installed. [abi] is an injection seam for tests; production passes none.
|
||||||
|
String currentNativeDirName({Abi? abi}) {
|
||||||
|
switch (abi ?? Abi.current()) {
|
||||||
|
case Abi.macosArm64:
|
||||||
|
return 'macos-arm64';
|
||||||
|
case Abi.macosX64:
|
||||||
|
return 'macos-x64';
|
||||||
|
case Abi.linuxArm64:
|
||||||
|
return 'linux-arm64';
|
||||||
|
case Abi.linuxX64:
|
||||||
|
return 'linux-x64';
|
||||||
|
default:
|
||||||
|
// Windows / other — clide is desktop linux/macOS today; fall back to a
|
||||||
|
// best-effort name so the probe simply misses rather than throwing.
|
||||||
|
return Platform.isMacOS ? 'macos-x64' : 'linux-x64';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// Web stub (T-438 web fence, D-100): no FFI ABI introspection on web. The
|
||||||
|
/// native dir name only matters for locating a dev-tree `clide` binary, which
|
||||||
|
/// doesn't exist on the web target — so a harmless default suffices.
|
||||||
|
library;
|
||||||
|
|
||||||
|
String currentNativeDirName() => 'linux-x64';
|
||||||
@@ -88,13 +88,13 @@ class _PermissionModeControlState extends State<PermissionModeControl> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
return ClideAnchoredOverlay(
|
return ClideAnchoredOverlay(
|
||||||
controller: _overlay,
|
controller: _overlay,
|
||||||
side: ClideAnchorSide.above,
|
side: ClideAnchorSide.above,
|
||||||
align: ClideAnchorAlign.end,
|
align: ClideAnchorAlign.end,
|
||||||
offset: const Offset(0, -6),
|
offset: const Offset(0, -6),
|
||||||
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideTheme.of(ctx).surface)),
|
overlayBuilder: (ctx, ctrl) => ClideMenu(onClose: ctrl.close, minWidth: 180, entries: _entries(ClideSettings.theme.of(ctx).surface)),
|
||||||
anchor: ListenableBuilder(
|
anchor: ListenableBuilder(
|
||||||
listenable: _overlay,
|
listenable: _overlay,
|
||||||
builder: (ctx, _) {
|
builder: (ctx, _) {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -15,7 +15,6 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
import 'package:clide/builtin/claude/src/stream_json_session.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/tokens.dart';
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -103,7 +102,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
final (accent, label, children) = widget.prompt.isQuestion ? _question(tokens) : _permission(tokens);
|
final (accent, label, children) = widget.prompt.isQuestion ? _question(tokens) : _permission(tokens);
|
||||||
// Autofocus the card so number keys pick a button/option on appear (T-240).
|
// Autofocus the card so number keys pick a button/option on appear (T-240).
|
||||||
// _onKey self-guards via hasPrimaryFocus, so once the user clicks a note
|
// _onKey self-guards via hasPrimaryFocus, so once the user clicks a note
|
||||||
@@ -122,7 +121,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ClideText(label, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: accent),
|
ClideText(label, fontSize: clideFontSmall, fontFamily: ClideSettings.fonts.monoOf(context), color: accent),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...children,
|
...children,
|
||||||
],
|
],
|
||||||
@@ -259,7 +258,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
// or file body is visible but doesn't swamp the composer zone (D-78).
|
// or file body is visible but doesn't swamp the composer zone (D-78).
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxHeight: 200),
|
constraints: const BoxConstraints(maxHeight: 200),
|
||||||
child: SingleChildScrollView(child: _inputBody(tokens, p)),
|
child: SingleChildScrollView(child: _inputBody(tokens, ClideSettings.fonts.monoOf(context), p)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Wrap(
|
Wrap(
|
||||||
@@ -284,7 +283,7 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
|
|
||||||
/// Render the tool input in the shape that best fits the tool. Delegates to
|
/// Render the tool input in the shape that best fits the tool. Delegates to
|
||||||
/// the shared top-level helpers (also used by ConversationView — T-168).
|
/// the shared top-level helpers (also used by ConversationView — T-168).
|
||||||
Widget _inputBody(SurfaceTokens tokens, ToolPrompt p) => toolInputBody(tokens, p.toolName, p.input);
|
Widget _inputBody(SurfaceTokens tokens, String mono, ToolPrompt p) => toolInputBody(tokens, p.toolName, p.input, mono);
|
||||||
|
|
||||||
// -- AskUserQuestion: single = bare, multi = stepper + review --------------
|
// -- AskUserQuestion: single = bare, multi = stepper + review --------------
|
||||||
|
|
||||||
@@ -362,14 +361,21 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
border: Border.all(color: tokens.statusInfo),
|
border: Border.all(color: tokens.statusInfo),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground),
|
child: ClideText(text, fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalForeground),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
chips.add(ClideText(text, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: done ? tokens.statusSuccess : tokens.globalTextMuted));
|
chips.add(
|
||||||
|
ClideText(
|
||||||
|
text,
|
||||||
|
fontSize: clideFontMeta,
|
||||||
|
fontFamily: ClideSettings.fonts.monoOf(context),
|
||||||
|
color: done ? tokens.statusSuccess : tokens.globalTextMuted,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
chips.add(ClideText('Review', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted));
|
chips.add(ClideText('Review', fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted));
|
||||||
chips.add(ClideText('›', color: tokens.globalTextMuted, fontSize: 15));
|
chips.add(ClideText('›', color: tokens.globalTextMuted, fontSize: 15));
|
||||||
return Wrap(spacing: 10, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: chips);
|
return Wrap(spacing: 10, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: chips);
|
||||||
}
|
}
|
||||||
@@ -399,7 +405,8 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (q.header.isNotEmpty) ClideText(q.header.toUpperCase(), fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
if (q.header.isNotEmpty)
|
||||||
|
ClideText(q.header.toUpperCase(), fontSize: clideFontMeta, fontFamily: ClideSettings.fonts.monoOf(context), color: tokens.globalTextMuted),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
||||||
child: ClideText(q.question, color: tokens.globalForeground),
|
child: ClideText(q.question, color: tokens.globalForeground),
|
||||||
@@ -484,19 +491,19 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
///
|
///
|
||||||
/// Shared between [ToolPromptCard] (permission prompt body) and the
|
/// Shared between [ToolPromptCard] (permission prompt body) and the
|
||||||
/// [ConversationView] tool-use card bodies (T-168).
|
/// [ConversationView] tool-use card bodies (T-168).
|
||||||
Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input) {
|
Widget toolInputBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input, String mono) {
|
||||||
switch (toolName) {
|
switch (toolName) {
|
||||||
case 'Bash':
|
case 'Bash':
|
||||||
return toolBashBody(tokens, input);
|
return toolBashBody(tokens, input);
|
||||||
case 'Write':
|
case 'Write':
|
||||||
return toolWriteBody(tokens, input);
|
return toolWriteBody(tokens, input, mono);
|
||||||
case 'Edit':
|
case 'Edit':
|
||||||
case 'MultiEdit':
|
case 'MultiEdit':
|
||||||
return toolEditBody(tokens, input);
|
return toolEditBody(tokens, input, mono);
|
||||||
case 'Read':
|
case 'Read':
|
||||||
case 'Grep':
|
case 'Grep':
|
||||||
case 'LS':
|
case 'LS':
|
||||||
return toolReadLikeBody(tokens, toolName, input);
|
return toolReadLikeBody(tokens, toolName, input, mono);
|
||||||
default:
|
default:
|
||||||
return ClideCodeBlock(source: const JsonEncoder.withIndent(' ').convert(input), language: 'json');
|
return ClideCodeBlock(source: const JsonEncoder.withIndent(' ').convert(input), language: 'json');
|
||||||
}
|
}
|
||||||
@@ -522,21 +529,21 @@ Widget toolBashBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Write tool body: the file path + content with syntax highlighting.
|
/// Write tool body: the file path + content with syntax highlighting.
|
||||||
Widget toolWriteBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
Widget toolWriteBody(SurfaceTokens tokens, Map<String, dynamic> input, String mono) {
|
||||||
final path = input['file_path'] as String? ?? '';
|
final path = input['file_path'] as String? ?? '';
|
||||||
final content = input['content'] as String? ?? '';
|
final content = input['content'] as String? ?? '';
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (path.isNotEmpty) toolPathLine(tokens, path),
|
if (path.isNotEmpty) toolPathLine(tokens, path, mono),
|
||||||
ClideCodeBlock(source: content, language: grammarForPath(path)),
|
ClideCodeBlock(source: content, language: grammarForPath(path)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Edit / MultiEdit tool body: before/after diff view.
|
/// Edit / MultiEdit tool body: before/after diff view.
|
||||||
Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input, String mono) {
|
||||||
final path = input['file_path'] as String? ?? '';
|
final path = input['file_path'] as String? ?? '';
|
||||||
final oldStr = input['old_string'] as String? ?? '';
|
final oldStr = input['old_string'] as String? ?? '';
|
||||||
final newStr = input['new_string'] as String? ?? '';
|
final newStr = input['new_string'] as String? ?? '';
|
||||||
@@ -545,12 +552,12 @@ Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (path.isNotEmpty) toolPathLine(tokens, path),
|
if (path.isNotEmpty) toolPathLine(tokens, path, mono),
|
||||||
ClideText('— before', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
ClideText('— before', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: mono),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ClideCodeBlock(source: oldStr, language: lang),
|
ClideCodeBlock(source: oldStr, language: lang),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ClideText('+ after', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
ClideText('+ after', fontSize: clideFontMeta, color: tokens.globalTextMuted, fontFamily: mono),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ClideCodeBlock(source: newStr, language: lang),
|
ClideCodeBlock(source: newStr, language: lang),
|
||||||
],
|
],
|
||||||
@@ -560,7 +567,7 @@ Widget toolEditBody(SurfaceTokens tokens, Map<String, dynamic> input) {
|
|||||||
/// Read / Grep / LS body: show the file path or pattern as a one-liner label
|
/// Read / Grep / LS body: show the file path or pattern as a one-liner label
|
||||||
/// so the card stays compact. These tools produce the interesting output in the
|
/// so the card stays compact. These tools produce the interesting output in the
|
||||||
/// result card rather than their input.
|
/// result card rather than their input.
|
||||||
Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input) {
|
Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynamic> input, String mono) {
|
||||||
final path = input['file_path'] ?? input['path'] ?? input['pattern'] ?? '';
|
final path = input['file_path'] ?? input['path'] ?? input['pattern'] ?? '';
|
||||||
final extra = <String>[];
|
final extra = <String>[];
|
||||||
if (toolName == 'Grep') {
|
if (toolName == 'Grep') {
|
||||||
@@ -568,13 +575,13 @@ Widget toolReadLikeBody(SurfaceTokens tokens, String toolName, Map<String, dynam
|
|||||||
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
|
if (pat != null && pat.isNotEmpty) extra.add('"$pat"');
|
||||||
}
|
}
|
||||||
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
|
final label = [path.toString(), ...extra].where((s) => s.isNotEmpty).join(' ');
|
||||||
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalForeground);
|
return ClideText(label.isNotEmpty ? label : toolName, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalForeground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A muted file path line, shared across tool bodies.
|
/// A muted file path line, shared across tool bodies.
|
||||||
Widget toolPathLine(SurfaceTokens tokens, String path) => Padding(
|
Widget toolPathLine(SurfaceTokens tokens, String path, String mono) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
child: ClideText(path, fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
child: ClideText(path, fontSize: clideFontMeta, fontFamily: mono, color: tokens.globalTextMuted),
|
||||||
);
|
);
|
||||||
|
|
||||||
// -- shared note / free-text field -------------------------------------------
|
// -- shared note / free-text field -------------------------------------------
|
||||||
@@ -599,7 +606,7 @@ class _NoteFieldState extends State<_NoteField> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||||
|
import 'package:clide/kernel/kernel.dart';
|
||||||
|
|
||||||
|
/// Per-user defaults applied to NEW Claude sessions (T-457). Set from the
|
||||||
|
/// Settings → Claude category; read by the pane when it spawns a session.
|
||||||
|
/// Effort is applied at spawn (the `--effort` flag — there's no live
|
||||||
|
/// set_effort); model and permission mode are sent as control requests right
|
||||||
|
/// after the session starts.
|
||||||
|
const String kDefaultModelKey = 'app.claude.defaultModel';
|
||||||
|
const String kDefaultEffortKey = 'app.claude.defaultEffort';
|
||||||
|
const String kDefaultPermissionModeKey = 'app.claude.defaultPermissionMode';
|
||||||
|
|
||||||
|
/// The default effort for new sessions, or null to let the CLI's own default
|
||||||
|
/// stand. `'default'` is treated as "no override" too.
|
||||||
|
String? defaultEffortFlag(SettingsStore settings) {
|
||||||
|
final v = settings.get<String>(kDefaultEffortKey);
|
||||||
|
if (v == null || v.isEmpty || v == 'default') return null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the model + permission-mode defaults to a freshly-spawned [session].
|
||||||
|
/// A null/empty/`'default'` value is a no-op — the CLI's own default stands.
|
||||||
|
/// (Effort is handled at spawn via [defaultEffortFlag], not here.)
|
||||||
|
void applySessionDefaults(StreamJsonSession session, SettingsStore settings) {
|
||||||
|
final model = settings.get<String>(kDefaultModelKey);
|
||||||
|
if (model != null && model.isNotEmpty && model != 'default') session.setModel(model);
|
||||||
|
final perm = settings.get<String>(kDefaultPermissionModeKey);
|
||||||
|
if (perm != null && perm.isNotEmpty && perm != 'default') session.setPermissionMode(perm);
|
||||||
|
}
|
||||||
@@ -102,10 +102,16 @@ String claudeTranscriptPath(String repoRoot, String sessionId) => '${claudeProje
|
|||||||
|
|
||||||
/// Erase [sessionId]'s transcript under [projectDir] so a subsequent
|
/// 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;
|
||||||
}
|
}
|
||||||
@@ -188,7 +195,28 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
|||||||
/// (T-269): the existing session belongs to the old repo, so it is torn down
|
/// (T-269): the existing session belongs to the old repo, so it is torn down
|
||||||
/// and a fresh one spawned for the new repo — a pane must never inherit
|
/// and a fresh one spawned for the new repo — a pane must never inherit
|
||||||
/// another workspace's conversation.
|
/// another workspace's conversation.
|
||||||
Future<ManagedSession> spawn(SpawnSpec spec) async {
|
Future<ManagedSession> spawn(SpawnSpec spec) {
|
||||||
|
// Serialize concurrent spawns per id (T-374): the body check-then-acts
|
||||||
|
// on _sessions across two awaits, so two racing callers would both
|
||||||
|
// pass the check and the loser's live claude process would be orphaned.
|
||||||
|
// The first caller installs the future synchronously; the rest await
|
||||||
|
// it. (A racing different-cwd spawn for the same id also coalesces —
|
||||||
|
// the workspace-switch flow is sequential, so that pair never races.)
|
||||||
|
final inFlight = _spawning[spec.id];
|
||||||
|
if (inFlight != null) return inFlight;
|
||||||
|
final f = _spawn(spec);
|
||||||
|
_spawning[spec.id] = f;
|
||||||
|
unawaited(
|
||||||
|
f.then<void>((_) {}, onError: (Object _) {}).whenComplete(() {
|
||||||
|
if (identical(_spawning[spec.id], f)) _spawning.remove(spec.id);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Future<ManagedSession>> _spawning = {};
|
||||||
|
|
||||||
|
Future<ManagedSession> _spawn(SpawnSpec spec) async {
|
||||||
final existing = _sessions[spec.id];
|
final existing = _sessions[spec.id];
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
if (existing.cwd == spec.cwd) return existing;
|
if (existing.cwd == spec.cwd) return existing;
|
||||||
@@ -217,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();
|
||||||
@@ -279,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||||
import 'package:clide/kernel/src/theme/controller.dart';
|
|
||||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -60,7 +59,7 @@ class _SessionPickerDialogState extends State<SessionPickerDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = ClideTheme.of(context).surface;
|
final theme = ClideSettings.theme.of(context).surface;
|
||||||
return Focus(
|
return Focus(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onKeyEvent: _onKey,
|
onKeyEvent: _onKey,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:clide/builtin/claude/src/session_index.dart';
|
import 'package:clide/builtin/claude/src/session_index.dart';
|
||||||
import 'package:clide/builtin/claude/src/session_picker.dart' show relativeTime;
|
import 'package:clide/builtin/claude/src/session_picker.dart' show relativeTime;
|
||||||
import 'package:clide/kernel/src/theme/controller.dart';
|
|
||||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||||
import 'package:clide/widgets/widgets.dart';
|
import 'package:clide/widgets/widgets.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -55,7 +54,7 @@ class _SessionStorageDialogState extends State<SessionStorageDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = ClideTheme.of(context).surface;
|
final theme = ClideSettings.theme.of(context).surface;
|
||||||
return Focus(
|
return Focus(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onKeyEvent: _onKey,
|
onKeyEvent: _onKey,
|
||||||
|
|||||||
@@ -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,8 +19,12 @@ 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';
|
||||||
|
|
||||||
/// The claude subprocess, abstracted so tests drive it without spawning.
|
/// The claude subprocess, abstracted so tests drive it without spawning.
|
||||||
|
/// Fakes `extend` this and override what they drive; the defaults below
|
||||||
|
/// describe a process with no real child behind it.
|
||||||
abstract class StreamJsonProcess {
|
abstract class StreamJsonProcess {
|
||||||
/// stdout, one JSON event per line.
|
/// stdout, one JSON event per line.
|
||||||
Stream<String> get lines;
|
Stream<String> get lines;
|
||||||
@@ -30,13 +34,43 @@ abstract class StreamJsonProcess {
|
|||||||
|
|
||||||
/// Terminate the process.
|
/// Terminate the process.
|
||||||
Future<void> kill();
|
Future<void> kill();
|
||||||
|
|
||||||
|
/// The last lines of the child's stderr, drained continuously so the pipe
|
||||||
|
/// can never fill and block the child mid-turn (T-361). Default: none.
|
||||||
|
List<String> get stderrTail => const [];
|
||||||
|
|
||||||
|
/// Completes with the child's exit code, or null when there is no real
|
||||||
|
/// process to watch (fakes that never "exit").
|
||||||
|
Future<int>? get exitCode => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bounded FIFO of the most recent lines — the stderr tail kept for
|
||||||
|
/// post-mortem diagnostics while the stream itself is drained and dropped.
|
||||||
|
class BoundedLineBuffer {
|
||||||
|
BoundedLineBuffer({this.cap = 100});
|
||||||
|
|
||||||
|
final int cap;
|
||||||
|
final List<String> _lines = [];
|
||||||
|
|
||||||
|
void add(String line) {
|
||||||
|
_lines.add(line);
|
||||||
|
if (_lines.length > cap) _lines.removeAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> get lines => List.unmodifiable(_lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Production [StreamJsonProcess] backed by a real `claude` process.
|
/// Production [StreamJsonProcess] backed by a real `claude` process.
|
||||||
class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
class ClaudeStreamJsonProcess extends StreamJsonProcess {
|
||||||
ClaudeStreamJsonProcess._(this._proc);
|
ClaudeStreamJsonProcess._(this._proc) {
|
||||||
|
// Drain stderr from the moment the process exists — with --verbose the
|
||||||
|
// CLI chats on stderr, and an undrained 64KB pipe blocks the child
|
||||||
|
// mid-turn with zero diagnostics (T-361). Keep a tail for post-mortems.
|
||||||
|
_proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(_stderr.add, onError: (Object _) {});
|
||||||
|
}
|
||||||
|
|
||||||
final Process _proc;
|
final Process _proc;
|
||||||
|
final BoundedLineBuffer _stderr = BoundedLineBuffer();
|
||||||
|
|
||||||
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
|
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
|
||||||
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
|
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
|
||||||
@@ -73,10 +107,29 @@ class ClaudeStreamJsonProcess implements 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
|
||||||
|
List<String> get stderrTail => _stderr.lines;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<int> get exitCode => _proc.exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
/// An in-process MCP server clide hosts for a session, entirely over the
|
/// An in-process MCP server clide hosts for a session, entirely over the
|
||||||
/// stream-json control channel — no subprocess, no `--mcp-config`, no socket
|
/// stream-json control channel — no subprocess, no `--mcp-config`, no socket
|
||||||
/// (T-170, D-77).
|
/// (T-170, D-77).
|
||||||
@@ -108,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
|
||||||
@@ -194,6 +294,29 @@ final class DenyTool extends ToolDecision {
|
|||||||
|
|
||||||
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
|
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
|
||||||
/// answers control-channel prompts, and sends user messages.
|
/// answers control-channel prompts, and sends user messages.
|
||||||
|
/// Terminal session end: the claude process exited (crash or otherwise).
|
||||||
|
/// Carries the exit code and the drained stderr tail for diagnostics.
|
||||||
|
class SessionEnd {
|
||||||
|
const SessionEnd({required this.exitCode, required this.stderrTail});
|
||||||
|
|
||||||
|
final int exitCode;
|
||||||
|
final List<String> stderrTail;
|
||||||
|
|
||||||
|
/// The most recent non-empty stderr line — the CLI's own error message when
|
||||||
|
/// it dies (e.g. "Session ID … is already in use") — for surfacing in the
|
||||||
|
/// pane so a non-zero exit is never an opaque "code 1" (T-437). Empty when
|
||||||
|
/// stderr was silent; capped so a stray long line can't blow out the status
|
||||||
|
/// line.
|
||||||
|
String get reason {
|
||||||
|
for (final line in stderrTail.reversed) {
|
||||||
|
final t = line.trim();
|
||||||
|
if (t.isEmpty) continue;
|
||||||
|
return t.length > 200 ? '${t.substring(0, 200)}…' : t;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class StreamJsonSession {
|
class StreamJsonSession {
|
||||||
StreamJsonSession(this._proc, {List<McpServer> mcpServers = const []}) : _mcpServers = mcpServers;
|
StreamJsonSession(this._proc, {List<McpServer> mcpServers = const []}) : _mcpServers = mcpServers;
|
||||||
|
|
||||||
@@ -204,13 +327,36 @@ class StreamJsonSession {
|
|||||||
/// round-trips are answered by [_handleMcpMessage].
|
/// round-trips are answered by [_handleMcpMessage].
|
||||||
final List<McpServer> _mcpServers;
|
final List<McpServer> _mcpServers;
|
||||||
final _items = StreamController<ConversationItem>.broadcast();
|
final _items = StreamController<ConversationItem>.broadcast();
|
||||||
final _statusCtl = StreamController<SessionStatus>.broadcast();
|
// State, not events — replay-latest so a subscriber that binds after the
|
||||||
|
// init event still sees the current status (T-386; root cause of T-274).
|
||||||
|
final _statusCtl = ValueStream<SessionStatus>();
|
||||||
final _sessionIdCtl = StreamController<String>.broadcast();
|
final _sessionIdCtl = StreamController<String>.broadcast();
|
||||||
StreamSubscription<String>? _sub;
|
StreamSubscription<String>? _sub;
|
||||||
SessionStatus _status = const SessionStatus();
|
SessionStatus _status = const SessionStatus();
|
||||||
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
|
||||||
@@ -237,7 +383,7 @@ class StreamJsonSession {
|
|||||||
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
|
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
|
||||||
/// is the one currently shown in the composer zone.
|
/// is the one currently shown in the composer zone.
|
||||||
final _queue = <ToolPrompt>[];
|
final _queue = <ToolPrompt>[];
|
||||||
final _pendingCtl = StreamController<ToolPrompt?>.broadcast();
|
final _pendingCtl = ValueStream<ToolPrompt?>.seeded(null);
|
||||||
|
|
||||||
/// tool_use_ids that surfaced as a prompt — the view hides their raw
|
/// tool_use_ids that surfaced as a prompt — the view hides their raw
|
||||||
/// tool-use card while pending (it shows as a prompt) but keeps the result.
|
/// tool-use card while pending (it shows as a prompt) but keeps the result.
|
||||||
@@ -259,10 +405,24 @@ 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;
|
||||||
final _busyCtl = StreamController<bool>.broadcast();
|
final _busyCtl = ValueStream<bool>.seeded(false);
|
||||||
bool get busy => _busy;
|
bool get busy => _busy;
|
||||||
Stream<bool> get busyStream => _busyCtl.stream;
|
Stream<bool> get busyStream => _busyCtl.stream;
|
||||||
|
|
||||||
@@ -299,17 +459,34 @@ class StreamJsonSession {
|
|||||||
/// The latest known status — the current value [statusStream] last emitted.
|
/// The latest known status — the current value [statusStream] last emitted.
|
||||||
SessionStatus get status => _status;
|
SessionStatus get status => _status;
|
||||||
|
|
||||||
|
/// Non-null once the claude process has exited (T-361). Late binders read
|
||||||
|
/// this; live listeners get [endedStream]. Never set by a deliberate
|
||||||
|
/// [dispose] — only by the process dying underneath a live session.
|
||||||
|
SessionEnd? get end => _end;
|
||||||
|
SessionEnd? _end;
|
||||||
|
final _endCtl = StreamController<SessionEnd>.broadcast();
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
/// Fires once when the process exits while the session is still live —
|
||||||
|
/// a crashed/dead session must not just look thoughtful (T-361).
|
||||||
|
Stream<SessionEnd> get endedStream => _endCtl.stream;
|
||||||
|
|
||||||
/// Begin consuming the process's event stream.
|
/// Begin consuming the process's event stream.
|
||||||
void start() {
|
void start() {
|
||||||
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
|
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
|
||||||
// Declaring our in-process MCP servers in the `initialize` handshake is what
|
// Watch the process itself: stdout EOF alone is ambiguous, the exit
|
||||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
|
// code is not (T-361).
|
||||||
// when we actually host a server, so a plain session is unchanged.
|
final exit = _proc.exitCode;
|
||||||
if (_mcpServers.isNotEmpty) {
|
if (exit != null) unawaited(exit.then(_onExit));
|
||||||
|
// The `initialize` handshake is side-effect-free (verified in the protocol
|
||||||
|
// spike) and does double duty: declaring our in-process MCP servers is what
|
||||||
|
// makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
|
||||||
|
// response's `models[]` feeds the /model picker (T-408).
|
||||||
|
_initRequestId = 'init-${_localSeq++}';
|
||||||
_proc.writeLine(
|
_proc.writeLine(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'type': 'control_request',
|
'type': 'control_request',
|
||||||
'request_id': 'init-${_localSeq++}',
|
'request_id': _initRequestId,
|
||||||
'request': {
|
'request': {
|
||||||
'subtype': 'initialize',
|
'subtype': 'initialize',
|
||||||
'hooks': <String, dynamic>{},
|
'hooks': <String, dynamic>{},
|
||||||
@@ -318,7 +495,6 @@ class StreamJsonSession {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void _onLine(String line) {
|
void _onLine(String line) {
|
||||||
final trimmed = line.trim();
|
final trimmed = line.trim();
|
||||||
@@ -345,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') {
|
||||||
@@ -361,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
|
||||||
@@ -434,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).
|
||||||
@@ -570,6 +770,14 @@ class StreamJsonSession {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (decision is AllowTool) {
|
if (decision is AllowTool) {
|
||||||
|
// Approving ExitPlanMode leaves plan mode. The CLI performs the
|
||||||
|
// transition itself on the approval, so we don't send a
|
||||||
|
// set_permission_mode control request — we just sync our tracked status
|
||||||
|
// (exits to 'default', matching Claude Code) so the permission-mode
|
||||||
|
// indicator and composer reflect the change (T-337).
|
||||||
|
if (prompt.toolName == 'ExitPlanMode') {
|
||||||
|
_mergeStatus(const SessionStatus(permissionMode: 'default'));
|
||||||
|
}
|
||||||
// The prompt card is ephemeral (it vanishes once resolved), so leave a
|
// The prompt card is ephemeral (it vanishes once resolved), so leave a
|
||||||
// compact record of an answered question in the conversation log (D-78).
|
// compact record of an answered question in the conversation log (D-78).
|
||||||
if (prompt.isQuestion) {
|
if (prompt.isQuestion) {
|
||||||
@@ -668,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.
|
||||||
@@ -704,13 +924,92 @@ class StreamJsonSession {
|
|||||||
_mergeStatus(SessionStatus(permissionMode: mode));
|
_mergeStatus(SessionStatus(permissionMode: mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
/// Set the model for subsequent turns (T-408). Sends a `set_model`
|
||||||
|
/// control_request; [model] is an alias (`sonnet`, `opus`) or full id, and
|
||||||
|
/// `default` resets to the CLI's configured model. The status merges
|
||||||
|
/// optimistically (mirroring [setPermissionMode]); an error response rolls
|
||||||
|
/// it back and surfaces on [modelErrors].
|
||||||
|
void setModel(String model) {
|
||||||
|
final rid = 'set-model-${_localSeq++}';
|
||||||
|
_pendingSetModel[rid] = _status.model;
|
||||||
|
_proc.writeLine(
|
||||||
|
jsonEncode({
|
||||||
|
'type': 'control_request',
|
||||||
|
'request_id': rid,
|
||||||
|
'request': {'subtype': 'set_model', 'model': model},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// `default` resolves to a model only the CLI knows — leave the status to
|
||||||
|
// the next assistant event in that case.
|
||||||
|
if (model != 'default') _mergeStatus(SessionStatus(model: model));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `control_response` to one of our requests: capture the initialize
|
||||||
|
/// result's `models[]`, and roll back + surface a rejected set_model (T-408).
|
||||||
|
void _onControlResponse(Map<String, dynamic> ev) {
|
||||||
|
final resp = ev['response'];
|
||||||
|
if (resp is! Map) return;
|
||||||
|
final rid = resp['request_id'] as String?;
|
||||||
|
if (rid == null) return;
|
||||||
|
final isError = resp['subtype'] == 'error';
|
||||||
|
if (rid == _initRequestId && !isError) {
|
||||||
|
final result = resp['response'];
|
||||||
|
final models = result is Map ? result['models'] : null;
|
||||||
|
if (models is List) {
|
||||||
|
_availableModels = List.unmodifiable([
|
||||||
|
for (final m in models)
|
||||||
|
if (m is Map && m['value'] is String)
|
||||||
|
ModelOption(
|
||||||
|
value: m['value'] as String,
|
||||||
|
displayName: m['displayName'] as String? ?? m['value'] as String,
|
||||||
|
description: m['description'] as String? ?? '',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_pendingSetModel.containsKey(rid)) {
|
||||||
|
final previous = _pendingSetModel.remove(rid);
|
||||||
|
if (isError) {
|
||||||
|
if (previous != null) _mergeStatus(SessionStatus(model: previous));
|
||||||
|
_modelErrorCtl.add(resp['error'] as String? ?? 'model change rejected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process exited under a live session. Flip every "in flight"
|
||||||
|
/// surface off so the pane reflects reality instead of spinning forever.
|
||||||
|
void _onExit(int code) {
|
||||||
|
if (_disposed || _end != null) return;
|
||||||
|
_end = SessionEnd(exitCode: code, stderrTail: _proc.stderrTail);
|
||||||
|
_setBusy(false);
|
||||||
|
// A prompt pending against a dead process can never be answered —
|
||||||
|
// clear it so the composer comes back.
|
||||||
|
if (_queue.isNotEmpty) {
|
||||||
|
_queue.clear();
|
||||||
|
_pendingCtl.add(null);
|
||||||
|
}
|
||||||
|
_endCtl.add(_end!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idempotent: the conversation controller's [dispose] fires this
|
||||||
|
/// unawaited while a caller (the orchestrator's [ClaudeSessionOrchestrator.close])
|
||||||
|
/// awaits it to know the process is truly dead (T-437). Caching the future
|
||||||
|
/// makes both paths share one teardown rather than killing/closing twice.
|
||||||
|
Future<void> dispose() => _disposeFuture ??= _dispose();
|
||||||
|
Future<void>? _disposeFuture;
|
||||||
|
|
||||||
|
Future<void> _dispose() async {
|
||||||
|
_disposed = true; // deliberate teardown — suppress the exit-watch path
|
||||||
await _sub?.cancel();
|
await _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 _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.
|
||||||
@@ -122,7 +122,7 @@ class _TeamChatSidebarState extends State<TeamChatSidebar> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
final messages = widget.model.messages;
|
final messages = widget.model.messages;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -291,7 +291,7 @@ class _TeamChatPaneState extends State<TeamChatPane> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
final messages = widget.model.messages;
|
final messages = widget.model.messages;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class _TeamPanelHostState extends State<TeamPanelHost> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_members.isEmpty) return widget.lead;
|
if (_members.isEmpty) return widget.lead;
|
||||||
final tokens = ClideTheme.of(context).surface;
|
final tokens = ClideSettings.theme.of(context).surface;
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@@ -178,7 +178,7 @@ class _TeammateTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Container(width: 3, height: 13, color: accent),
|
Container(width: 3, height: 13, color: accent),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
ClideText(member.name, fontSize: clideFontSmall, color: accent, fontFamily: clideMonoFamily),
|
ClideText(member.name, fontSize: clideFontSmall, color: accent, fontFamily: ClideSettings.fonts.monoOf(context)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ClideText(
|
child: ClideText(
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
/// Bridges a [TranscriptReader] onto the kernel [MessageBus] (epic T-132,
|
/// Bus addressing for Claude conversation content (epic T-132, D-75).
|
||||||
/// D-75).
|
|
||||||
///
|
///
|
||||||
/// One reader tails a workspace transcript; this publisher republishes
|
/// The tmux-era `TranscriptPublisher` that used to live here (one reader
|
||||||
/// every [ConversationItem] as a bus [Message]. Any number of Claude
|
/// tailing a transcript, republished onto the bus) had no production
|
||||||
/// panels can then subscribe to the same conversation via the bus instead
|
/// constructor calls after the stream-json pivot (D-77) and was removed
|
||||||
/// of each owning its own reader — the decoupling the team panels
|
/// in the T-385 dead-code sweep. The [ClaudeConversation] channel/key
|
||||||
/// (T-139/T-140) need, where a single observer feeds the lead tile plus a
|
/// constants remain — the meta sidebar and team panel host still consume
|
||||||
/// tile per teammate.
|
/// them for member-status messages.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
|
||||||
|
|
||||||
/// Bus addressing for Claude conversation content.
|
/// Bus addressing for Claude conversation content.
|
||||||
abstract final class ClaudeConversation {
|
abstract final class ClaudeConversation {
|
||||||
@@ -29,7 +25,7 @@ abstract final class ClaudeConversation {
|
|||||||
/// Channel for a teammate's conversation (team work, T-139/T-140).
|
/// Channel for a teammate's conversation (team work, T-139/T-140).
|
||||||
static String teammateChannel(String agentId) => 'conversation/$agentId';
|
static String teammateChannel(String agentId) => 'conversation/$agentId';
|
||||||
|
|
||||||
/// Key under which the [ConversationItem] travels in a [Message]'s data.
|
/// Key under which the [ConversationItem] travels in a bus message's data.
|
||||||
static const itemKey = 'item';
|
static const itemKey = 'item';
|
||||||
|
|
||||||
/// Shared channel carrying each team member's live status (T-157). Every
|
/// Shared channel carrying each team member's live status (T-157). Every
|
||||||
@@ -44,32 +40,3 @@ abstract final class ClaudeConversation {
|
|||||||
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
|
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class TranscriptPublisher {
|
|
||||||
/// Starts republishing [reader]'s items onto [messages] under
|
|
||||||
/// [ClaudeConversation.publisher] / [channel]. The subscription is
|
|
||||||
/// attached synchronously, so a controller that subscribes before the
|
|
||||||
/// reader's first poll never misses the initial tail.
|
|
||||||
TranscriptPublisher({required MessageBus messages, required TranscriptReader reader, this.channel = ClaudeConversation.leadChannel})
|
|
||||||
: _messages = messages,
|
|
||||||
_reader = reader {
|
|
||||||
_sub = _reader.stream.listen((item) {
|
|
||||||
_messages.publish(ClaudeConversation.publisher, channel, {ClaudeConversation.itemKey: item});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
final MessageBus _messages;
|
|
||||||
final TranscriptReader _reader;
|
|
||||||
final String channel;
|
|
||||||
late final StreamSubscription<ConversationItem> _sub;
|
|
||||||
|
|
||||||
/// Live session status (model / permission-mode / context) from the
|
|
||||||
/// underlying reader — passed through for the status strip (T-145).
|
|
||||||
Stream<SessionStatus> get statusStream => _reader.statusStream;
|
|
||||||
|
|
||||||
/// Stops publishing and tears down the underlying reader.
|
|
||||||
Future<void> dispose() async {
|
|
||||||
await _sub.cancel();
|
|
||||||
await _reader.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user