Merge pull request #1 from postmeridiem/windows-support

Windows desktop support + crash-survivable observability + CI/release pipeline (v2.5.0)
This commit is contained in:
2026-06-15 12:48:41 +02:00
committed by GitHub
157 changed files with 8260 additions and 525 deletions
-91
View File
@@ -1,91 +0,0 @@
# Gitea Actions workflow for clide.
#
# NOT YET ACTIVATED. Gitea Actions must be enabled in the instance
# settings before this runs; until then the file is just a ready-made
# pipeline Claude + the user can review.
#
# When the repo eventually lands on GitHub, copy this file verbatim to
# `.github/workflows/test.yml` — Gitea Actions consumes GitHub-Actions
# syntax, so no rewrite is needed.
#
# Steps go through the make targets (the repo's tooling-discipline rule:
# the make layer sets up the environment — gen-build-info etc. — and
# stays correct if a wrapped script moves). T-384 fixed three latent
# breaks here: a `cd app` into the flattened-away app/ directory, a
# coverage gate with no coverage run before it, and raw ci/ script
# invocations that skipped build-info generation.
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
name: unit + widget + golden + a11y + coverage gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
# test-coverage runs the full fast suite WITH coverage (it includes
# the a11y suite — see the push-check note in the Makefile), which
# is what coverage-gate consumes.
- run: make test-coverage
- run: make coverage-gate
integration:
name: integration_test (xvfb)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: flutter pub get
- uses: coactions/setup-xvfb@v1
with: { run: make test-integration }
startup-bundle:
name: bundle smoke (xvfb 5s)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: flutter pub get
- run: make smoke-bundle
# The web-WASM Playwright job is withheld: `flutter build web --wasm`
# cannot compile the tree since the tree-sitter/PTY dart:ffi pivot
# (dart:ffi is unavailable on the wasm target). Whether the web target
# gets conditional-import fences or is dropped is an open question —
# see Q-50 in governance/questions/architecture.md. Re-add the job
# (steps: setup-node, npm install + playwright install in tools/ui,
# `make test-e2e`) when Q-50 resolves toward keeping it.
docs:
name: dart doc (lib API)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
- name: dart doc --validate-links (fail on warning)
run: |
set -o pipefail
dart doc --validate-links 2>&1 | tee dartdoc.log
if grep -q "^ warning:" dartdoc.log; then
echo "::error::dartdoc emitted warnings — see log above"
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: dart-api-docs
path: doc/api/
+8 -2
View File
@@ -1,3 +1,9 @@
#!/bin/sh
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout)
. "$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout"
# pql: source .pql/hooks/post-checkout (rebuild pql.db on branch checkout).
# The pql hook is untracked (a local `pql init` install), so a fresh
# `git worktree add` has no .pql/hooks — source it only when present, and
# always exit 0: post-checkout is best-effort and must never abort the
# checkout / worktree creation.
hook="$(git rev-parse --show-toplevel)/.pql/hooks/post-checkout"
if [ -f "$hook" ]; then . "$hook"; fi
exit 0
+104
View File
@@ -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
+144
View File
@@ -0,0 +1,144 @@
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
# The web-WASM Playwright job is withheld: `flutter build web --wasm` cannot
# compile the tree since the tree-sitter/PTY dart:ffi pivot (dart:ffi is
# unavailable on the wasm target). Whether the web target gets conditional-
# import fences or is dropped is an open question — see Q-50 in
# governance/questions/architecture.md. Re-add the job (setup-node, npm
# install + playwright install in tools/ui, `make test-e2e`) when Q-50
# resolves toward keeping it.
docs:
name: dart doc (lib API)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: flutter pub get
- name: dart doc --validate-links (fail on warning)
run: |
set -o pipefail
dart doc --validate-links 2>&1 | tee dartdoc.log
if grep -q "^ warning:" dartdoc.log; then
echo "::error::dartdoc emitted warnings — see log above"
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: dart-api-docs
path: doc/api/
+115
View File
@@ -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
+48
View File
@@ -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
+2
View File
@@ -58,6 +58,8 @@ tools/ui/.serve.pid
/native/linux-x64/clide
/native/macos-arm64/clide
/native/macos-x64/clide
/native/windows-x64/clide.exe
/native/windows-x64/clide.obj
# -- Test, coverage, profile output ------------------------------------
*.test
+6 -6
View File
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
revision: "c9a6c484230f8b5e408ec57be1ef71dee1e77020"
channel: "stable"
project_type: app
@@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
- platform: web
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
- platform: windows
create_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
base_revision: c9a6c484230f8b5e408ec57be1ef71dee1e77020
# User provided section
+703
View File
@@ -4385,3 +4385,706 @@ PLAN: parse system task_* into a WorkflowRun model keyed by tool_use_id in Strea
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'ready', 'in_progress', NULL, '2026-06-12 14:22:45', '2026-06-12 14:22:45', '2026-06-12 14:22:45', NULL, 'bff47e0f2b4ebfbf5a4ca6b9c7b45825', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBPQ8QNGJFFK7G24CBWQAR2C', 'status', 'in_progress', 'done', NULL, '2026-06-12 14:52:31', '2026-06-12 14:52:31', '2026-06-12 14:52:31', NULL, 'd05d502773832fcfb5c8baf19a606b1c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBP3EZC7AJANXZVF3D91QYWM', 'status', 'ready', 'done', NULL, '2026-06-12 19:44:20', '2026-06-12 19:44:20', '2026-06-12 19:44:20', NULL, '3637ac53f5fc94698f604db89fdcb7e0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'status', 'backlog', 'ready', NULL, '2026-06-12 19:54:32', '2026-06-12 19:54:32', '2026-06-12 19:54:32', NULL, 'b70e45fbed6d4ad8d2c6e0e6abc5b443', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'description', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` command:panel.focus.left; `ctrl+w l` command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` command:dock.toggle document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` focus.nextPanel; `ctrl+w shift+w` focus.previousPanel
- `ctrl+w o` command:panel.focusMode (vim "only" exact semantic match)
- `ctrl+w q` and `ctrl+w c` command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands no new services:
- `ctrl+w h` command:panel.focus.left; `ctrl+w l` command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` command:dock.toggle document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` focus.nextPanel; `ctrl+w shift+w` focus.previousPanel
- `ctrl+w o` command:panel.focusMode (vim "only" exact semantic match)
- `ctrl+w q` and `ctrl+w c` command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
ACCEPTANCE CRITERIA:
- vim.yaml gains ctrl+w bindings: `ctrl+w h`panel.focus.left, `ctrl+w l`panel.focus.right, `ctrl+w j`dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`focus.nextPanel, `ctrl+w shift+w`focus.previousPanel, `ctrl+w o`panel.focusMode, `ctrl+w q`/`ctrl+w c`editor.close, all `when: vim.normal || vim.visual`.
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
- No resolution change under default/vscode/jetbrains editor_presets_test.dart `ctrl+w editor.close` (e.g. line 60) stays green.
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
- make analyze + format + keymap suite pass; 95% coverage floor holds.
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface coordinate ordering to avoid three matcher rewires.
OPEN QUESTIONS:
- Where does the global multi-chord matcher live a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
- Should the family also fire in vim.insert (it shouldn''t ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', NULL, '2026-06-12 20:03:16', '2026-06-12 20:03:16', '2026-06-12 20:03:16', NULL, '7171f5ba9998c641743b8a0341f32364', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'description', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', NULL, '2026-06-12 20:03:43', '2026-06-12 20:03:43', '2026-06-12 20:03:43', NULL, 'e6a5c7b4074b0e097a4fc5ee1d359ae6', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'description', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, GmaxScrollExtent AND re-arm _atBottom follow-tail, gg0). Lists (tickets/git) trail in a follow-up commit. Navigation only no editing semantics outside the editor.
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability land/decide this first.
ACCEPTANCE CRITERIA:
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, ggoffset 0, G_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
OPEN QUESTIONS:
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard (b) only works because each pane owns its handler.
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', NULL, '2026-06-12 20:03:58', '2026-06-12 20:03:58', '2026-06-12 20:03:58', NULL, 'f7e4047127e864a5a20f1b3c5d7578a3', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?', NULL, '2026-06-12 20:04:07', '2026-06-12 20:04:07', '2026-06-12 20:04:07', NULL, '916ff113755b114298ca32762fa815b0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'description', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.
--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404407) ---
SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w , g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift and it must be built ONCE, in one place, not three times.
RECOMMENDED SEQUENCING:
1. T-406 (the structural child) FIRST it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability.
2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path land it anytime for immediate value across every preset.
3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists.
4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet T-407 must add one).
All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) coordinate the shared-prefix matcher tests.', NULL, '2026-06-12 20:05:16', '2026-06-12 20:05:16', '2026-06-12 20:05:16', NULL, 'a7ce9c91b076f4f1ba94691d8cbcd631', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)', NULL, '2026-06-13 11:39:41', '2026-06-13 11:39:41', '2026-06-13 11:39:41', NULL, '658b0d19c0265ea4d753c1d8e9d52dcf', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'description', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', NULL, '2026-06-13 11:41:23', '2026-06-13 11:41:23', '2026-06-13 11:41:23', NULL, '526f24177bfa0f09ca59c571d9e84705', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'status', 'backlog', 'in_progress', NULL, '2026-06-13 11:42:26', '2026-06-13 11:42:26', '2026-06-13 11:42:26', NULL, 'a4b28821b462cd1fadab05c4e159b66b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'status', 'ready', 'in_progress', NULL, '2026-06-13 11:45:19', '2026-06-13 11:45:19', '2026-06-13 11:45:19', NULL, '7467127de4d8f29ed0e76b8a01489af9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:21', '2026-06-13 11:47:21', '2026-06-13 11:47:21', NULL, 'dd46fa777ff5fbb42396fe4037288165', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:26', '2026-06-13 11:47:26', '2026-06-13 11:47:26', NULL, 'fc555a3966e167d5273a8c8338214070', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'status', 'backlog', 'ready', NULL, '2026-06-13 11:47:31', '2026-06-13 11:47:31', '2026-06-13 11:47:31', NULL, '56abf6590754059d88570072056c5e31', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'status', 'in_progress', 'done', NULL, '2026-06-13 12:45:43', '2026-06-13 12:45:43', '2026-06-13 12:45:43', NULL, 'e540413389a0c213369d83a3a75f9706', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'description', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)
--- PROGRESS (2026-06-13) ---
PART 1 DONE (commit on main): workspace.tab.next / workspace.tab.previous cycle commands with wraparound + ctrl+pagedown/ctrl+pageup defaultBindings across every preset. Tests in test/builtin/default_layout/widget_test.dart.
PART 2 (gt/gT) STILL OPEN and harder than the coordination note assumed. T-404 landed a global multi-chord matcher (root_shell), BUT it deliberately only STARTS a sequence on a MODIFIED chord (ctrl+w). Bare-key prefixes (g) are left editor/pane-local on purpose otherwise the global matcher would steal `g` before the editor sees it, breaking gg/dd. So gt/gT (bare g) CANNOT just ride the global matcher. Options for part 2: (a) the editor/pane matchers grow gt/gT and dispatch command:workspace.tab.* (but then gt only works when the editor/a pane is focused, not globally); (b) a focus-agnostic bare-g disambiguation (g g = local docStart vs g t = global tab) needs the global matcher to tentatively grab bare g AND coordinate with the editor''s matcher, which is the exact conflict T-404 avoided. Decide before building. The ctrl+pagedown/up commands already give every preset tab-cycling; gt/gT is a vim-affordance nicety on top.', NULL, '2026-06-13 16:38:16', '2026-06-13 16:38:16', '2026-06-13 16:38:16', NULL, '98c4bb26a0ac412c3c3216699555ab16', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'status', 'ready', 'done', NULL, '2026-06-13 19:55:21', '2026-06-13 19:55:21', '2026-06-13 19:55:21', NULL, '359e523cbe3e5019b291b91a92d476ba', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'description', NULL, '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).', NULL, '2026-06-14 15:29:27', '2026-06-14 15:29:27', '2026-06-14 15:29:27', NULL, 'c0706f9ad119d6c1a6efc2c36315ecf0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'description', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `` glyph) sometimes displays the branch of a *different* open clide workspace/window it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).
---
**Repro details confirmed (user, 2026-06-14):**
- The two windows were on *different repos* (distinct workspace roots distinct hashed sockets per D-70; rules out same-socket collision).
- The second window was opened from the **File menu at the top**, not from an integrated terminal.
**Refined root-cause analysis (this changes the leading hypothesis).**
The File menu has two distinct paths (`lib/builtin/menubar/src/file_actions.dart`):
- `openFolder()`/`openPath()` (l.23-63) `services.project.open()` = *in-place* switch, same process (the T-269/T-367 class). Produces ONE window, so not this report.
- `newWindow()` (l.30-32) `Process.start(Platform.resolvedExecutable, const [], mode: ProcessStartMode.detached)` = a genuinely **separate detached process**. This matches the "parallel windows" symptom.
Two facts narrow it:
1. `CLIDE_SOCK`/`CLIDE_WORKSPACE` are NOT set in clide''s own process environment they are a delta overlaid only on spawned Claude/PTY *child* processes (`lib/builtin/claude/src/agent_bootstrap.dart:57-71`, "Process.start keeps the parent environment by default, so this returns only the keys to add/override"). So a clean dock-launched window has no CLIDE_SOCK to leak.
2. `newWindow()` passes **no `environment:` override**, so the detached child inherits the parent clide process''s full environment verbatim.
**Leading hypothesis now:** environment inheritance through `newWindow()` when clide is self-hosted. If window 1 was itself launched from a clide-hosted terminal or as a clide agent, window 1''s process env already carries *that host''s* `CLIDE_SOCK`/`CLIDE_WORKSPACE`. `newWindow()` then spawns window 2 inheriting those vars so any code in window 2 that resolves its IPC endpoint (or shells out to the `clide` CLI, which keys off `CLIDE_SOCK`) can bind to the wrong workspace''s server and surface its branch. This is consistent with: different repos, opened from the File menu, intermittent.
**Caveat / not yet pinned:** the in-app status widget reportedly resolves IPC via the computed `workspaceSocketPath(root)` (`lib/main.dart:357`), NOT via `CLIDE_SOCK` so if that holds, inherited CLIDE_SOCK alone shouldn''t mislead the *in-process* status bar. The exact cross-process channel therefore still needs live confirmation. Do NOT assume; instrument.
**First diagnostic step for the fixer:**
1. Reproduce: open window 1, then File New Window, then open a *different* repo in window 2.
2. Log, in each window at branch-fetch time: the resolved socket path the status client connected to, `Platform.environment[''CLIDE_SOCK'']`, `Platform.environment[''CLIDE_WORKSPACE'']`, and `kernel.project.root`. The window showing the wrong branch will reveal whether it (a) connected to the other window''s socket, (b) read a stale/ambient env var, or (c) received a cross-process event it shouldn''t have.
**Hardening regardless of outcome:** `newWindow()` should spawn the child with an explicit, scrubbed environment strip `CLIDE_SOCK`/`CLIDE_WORKSPACE` (and not rely on inheriting them) so a fresh window always computes its own per-root socket from its own workspace. A new window must never inherit another workspace''s IPC identity.', NULL, '2026-06-14 15:41:41', '2026-06-14 15:41:41', '2026-06-14 15:41:41', NULL, 'd69c8cc3a9a120dc3e91f074921bfe7b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDKX4CVHWVGDAJC6X09602M', 'description', NULL, 'Tracks the architectural unification behind Q-51: replace the scattered, per-entry-point workspace-open logic with a single fenced primitive.
**The problem.** There is no "open workspace X" primitive only two half-primitives in different layers:
- `project.open(root)` (`lib/kernel/src/project.dart:143`) the only repo-targeting path, intrinsically *in-place*: rebuilds services in the same process reusing the shared `daemonBus` (`lib/main.dart:372-376`).
- `newWindow()` (`lib/builtin/menubar/src/file_actions.dart:30-32`) a blank detached `Process.start` with no repo argument and no env scrubbing.
To open a repo in a new window you spawn a blank window and then run the in-place switch inside it. Every fencing bug to date is a spot where one path forgets what the other remembers.
**Symptoms already filed (same root):** T-421 (status-bar branch bleeds across parallel windows), T-367 (in-place switch leaked the previous service set closed), T-269 (kept the previous repo''s Claude session closed).
**Target invariant.** `workspace root socket bus session-id`, one-to-one. Exactly one place derives IPC identity from a root. Every entry point (File menu, project switcher, `clide://` deep link, CLI, recents) routes through `WorkspaceService.open(root, {target: thisWindow | newWindow})`. New-window spawns `Process.start(exe, [''--workspace'', root], environment: <scrubbed>)` — explicit root, no inherited `CLIDE_SOCK`/`CLIDE_WORKSPACE`.
**Open decision (Q-51):** whether in-place switching survives at all, or whether a workspace is always its own window/process. If abolished, the teardown burden that T-367/T-269 patch disappears.
**Acceptance:** Q-51 resolved with a D-record fixing the in-place-vs-window stance; a single workspace-open primitive in place; all entry points routed through it; T-421 no longer reproducible; a regression test that a checkout in one workspace cannot change another''s displayed branch.
See Q-51 (governance/questions/architecture.md), D-70, D-56, D-72.', NULL, '2026-06-14 15:47:08', '2026-06-14 15:47:08', '2026-06-14 15:47:08', NULL, 'dded9e79fec698c14986e50f4b642e1e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'parent_id', NULL, 'T-422', NULL, '2026-06-14 15:47:10', '2026-06-14 15:47:10', '2026-06-14 15:47:10', NULL, '331b502dff0de7123762ab8efbf1b488', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'description', NULL, 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.', NULL, '2026-06-14 18:15:41', '2026-06-14 18:15:41', '2026-06-14 18:15:41', NULL, 'f22e1791da77972912976b1ce27cd0be', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'description', NULL, 'From the Windows test-freeze analysis (2026-06-14): the freeze left NO evidence because every log sink is volatile — stderrSink dies with the console and the in-RAM LogRing dies with the power-cycle. clide already has the logger scaffolding (lib/kernel/src/log.dart: Logger, LogLevel{trace..error}, pluggable LogSink; lib/kernel/src/log_ring.dart; the output dock + Level chip), so this epic does not add a framework — it bolts on a crash-survivable sink, FFI breadcrumbs, a watchdog, and the dev/prod verbosity toggle so the NEXT freeze (Windows or otherwise) leaves on-disk evidence that names the wedged call.
Child work (each filed as a task under this epic):
1. FileLogSink synchronous-fsync JSON-lines sink to %LOCALAPPDATA%\clide\logs (reuse ipc/paths.dart socket-dir helper); tiered flush (warn/error + any pty/ffi record flush immediately, info/debug batch on a timer); first sink in the chain so a crash cannot lose the tail; size-capped with rotation.
2. FFI breadcrumbs in windows_pty.dart inject a no-op-by-default log callback; emit BEFORE/AFTER every risky Win32 call with the return value + GetLastError read immediately; the reader/waiter SPAWNED isolates each open their OWN append handle to the log file and flushSync per breadcrumb, so the wedged isolate''s last line survives a frozen main isolate.
3. Watchdog heartbeat + resource sampler a DEDICATED isolate (NOT a main-isolate Timer, which would freeze with it) appending+fsyncing a heartbeat every ~500ms and sampling live ConPTY child count / process handle count / thread count / memory load every ~2s. A monotonically climbing child count is the leak signature; the last heartbeat bounds the freeze window to ~500ms.
4. Dev/prod verbosity toggle (the requested switch) resolve Logger.minLevel once at boot: CLIDE_LOG dart-define -> CLIDE_LOG env var -> settings.json log.level -> default warn (release) / info (debug). Level also gates FileLogSink flush-eagerness (debug = lose nothing in a repro). Live changes via a /loglevel command + `clide log level <level>` CLI (D-6 parity); the output-dock Level chip is the in-UI affordance.
5. Wire into the testmode harness + ci/test.sh attach FileLogSink in lib/test_app.dart with per-test start/end breadcrumbs; export CLIDE_LOG=debug and a log dir OUTSIDE the build tree in ci/test.sh; upload that dir as a CI artifact in an always() step so a CI freeze leaves evidence.
Verification kit for the leak this telemetry is meant to catch: tools/windows-verify/.', NULL, '2026-06-14 18:15:42', '2026-06-14 18:15:42', '2026-06-14 18:15:42', NULL, '2add8cbe3d2e0255d15010aa9fd8527b', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'description', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).', NULL, '2026-06-14 18:55:21', '2026-06-14 18:55:21', '2026-06-14 18:55:21', NULL, 'db48d5823487e2c93ecc0b1c1dd8ce25', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCGJ30V24BJB001GZCR5QKTC', 'description', NULL, 'From the PTY testability audit (2026-06-14), prompted by the question "is any of the Windows FFI code testable on a pure I/O basis without Windows, and vice-versa?" Answer: yes on both backends, but the pure fragments are entangled with the syscall layer and need extraction before they can be unit-tested. Each claim below was adversarially verified (default-reject; rejected the rest of the proposed fragments because their output IS a syscall return, e.g. GetLastError, WriteFile byte count).
Context: lib/src/pty/windows_pty.dart wraps its FFI span in `// coverage:ignore-start/end` (Linux runner has no kernel32; the syscall sites are genuinely uncoverable off-Windows, and the bindings resolve through one DynamicLibrary.open so a method touching a binding can''t be entered on Linux). That exclusion is correct for the gate, but it hides a few pure transforms at file granularity. native_pty.dart (POSIX) has the mirror problem: it runs on Linux at ~78.9% but its pure marshalling is only covered incidentally by real spawns, never unit-tested.
## Windows (windows_pty.dart) extract + unit-test on Linux
Confirmed pure (verifier-approved), currently untested:
- `_Coord` struct packing (69-74) two clamped int16s into COORD; allocate via calloc, set x/y, read back.
- `_StartupInfoExW` field assembly in start() (358-361) cb / dwFlags=STARTF_USESTDHANDLES / lpAttributeList; deterministic field writes over calloc-zeroed memory.
- `write()` empty/length guard (497-498) returns 0 when `_dead` or `bytes.isEmpty`, before any WriteFile.
Plan: pull the COORD/STARTUPINFOEXW packing into free functions (e.g. `packCoord(cols, rows)`, `buildStartupInfoEx(attrList)`) that take/return plain values and don''t reference the kernel32 bindings; assert field layout in a Linux unit test. Keep the empty-guard logic in a tiny pure predicate.
## POSIX (native_pty.dart) extract + unit-test directly (closes part of the 21% gap, adds gate margin)
Confirmed pure (verifier-approved), currently only covered incidentally by integration spawns:
- argv marshalling (222-228) String list -> native UTF8 pointer array + null terminator.
- envp marshalling (230-235) Map<String,String> -> native ''KEY=VALUE'' UTF8 array.
- write() buffer copy (406-408) bytes[i] -> buf[i].
- resize() Winsize init + clamp (432-435) cols/rows -> ws.wsCol/wsRow (clamp already tested in pty_size_test).
Plan: extract marshalling into free helpers returning the pointer structures (inject the allocator so a test can read them back and free them); unit-test the round-trip and null-termination off any real spawn.
## NOT in scope (genuinely host-bound leave excluded/uncovered)
All the raw syscalls and anything whose output is a syscall return or that has no injection seam: CreatePipe / CreatePseudoConsole / CreateProcessW / ReadFile / WaitForSingleObject / WriteFile / ResizePseudoConsole / TerminateProcess / CloseHandle / GetLastError; the attribute-list APIs; the read/wait isolate bodies; and on POSIX the openpt/grantpt/unlockpt/ptsname + posix_spawn failure paths, EINTR/EBADF/EPIPE handling, and reader-isolate EOF reaping (~32 lines that need real OS error/timing state).
## Acceptance
- New Linux unit tests for the fragments above (both backends).
- windows_pty.dart `coverage:ignore` span narrowed to only the syscall sites (struct-packing helpers move out and are measured).
- Coverage floor holds (or ratchets up from the added native_pty coverage).
## Related / separate finding (file or fold as decided)
windows.yml runs the real ConPTY suite (start/write/resize/kill/errors) on windows-latest but collects NO coverage (no --coverage flag). So the FFI spawn path has functional validation on Windows + the VM soak (tools/windows-verify/) but no line-coverage metric anywhere. Decide whether to (a) accept functional-only validation explicitly, or (b) collect coverage on the Windows runner and merge it so the FFI path is measured. Cross-platform lcov merge is non-trivial (the gate reads one file) — may warrant a Q-record.
Audit detail: full per-fragment findings + adversarial verdicts in the workflow result for run wf_a3cacb2c-2c7.', NULL, '2026-06-14 22:38:01', '2026-06-14 22:38:01', '2026-06-14 22:38:01', NULL, 'd8cafbc05c3a83c1041c29ba25e4ed83', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'description', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost it lingers until the PARENT process exits (microsoft/terminal#4050) and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).
## Soak results on GitHub windows-latest (Server 2022) orphan-accumulation NOT reproduced (2026-06-14)
Ran both halves of the windows-verify soak on GitHub-hosted Windows (no VM needed windows-latest runs the ConPTY suite green, so the soak just wraps it):
1. Clean-path soak (soak-conpty.ps1, 25 iters): orphans stayed at 0, dart handles flat ~152, threads flat at 7. Orderly close() reaps everything. NOT REPRODUCED.
2. Abrupt-death probe (soak-conpty-kill.ps1 + conpty_orphan_probe.dart, 15 iters x 2 PTYs): start real WindowsPty sessions on long-lived children, block WITHOUT close(), then taskkill /F the parent dart.exe (no /T). Every cycle reaped to baseline survivors=0, cum=0. When the parent dies the OS breaks the pipes and conhost exits on its own. NOT REPRODUCED.
**Implication:** the conhost-orphan-accumulation mechanism this ticket is premised on does NOT hold on Server 2022, under clean OR abrupt teardown. The Job Object fix may still be worthwhile as defense-in-depth, but its justification (a reproduced leak) is not confirmed.
**Caveats / what''s still untested:**
- OS mismatch: the real crashes were on desktop Win10/11; this is headless Server 2022. terminal#4050 was a desktop report. A desktop-specific behavior may be unreproducible on CI.
- Both probes let the process DIE, so within-process accumulation (culprit #2: reader isolates blocked forever in ReadFile, threads/handles climbing within one long-lived process) is reclaimed at exit and never measured. A long-lived-process probe (one dart.exe spawning + abandoning PTYs, watching its OWN handle/thread count climb) would test that the more likely freeze mode for a long-running app. Not yet built.
Diagnostics live in tools/windows-verify/ and run via .github/workflows/windows-soak.yml (workflow_dispatch). The same kill-probe will validate the fix if/when it lands (survivors should stay 0 though they already do, which is the problem).', NULL, '2026-06-15 07:11:41', '2026-06-15 07:11:41', '2026-06-15 07:11:41', NULL, 'dadb5e43a8a9b44b2c4be4b7d4528beb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 07:19:13', '2026-06-15 07:19:13', '2026-06-15 07:19:13', NULL, '55a53ab84e2a10fd52fe6853bd32dc55', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'status', 'in_progress', 'done', NULL, '2026-06-15 07:29:45', '2026-06-15 07:29:45', '2026-06-15 07:29:45', NULL, 'ab3371153c8037fe287c07c4f34d1575', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'status', 'backlog', 'done', NULL, '2026-06-15 07:56:56', '2026-06-15 07:56:56', '2026-06-15 07:56:56', NULL, 'ff37484d4920968d13bdafa85336cd66', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'status', 'backlog', 'done', NULL, '2026-06-15 08:15:52', '2026-06-15 08:15:52', '2026-06-15 08:15:52', NULL, '59248f388c7cc5fd834eb1b384b6cab1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'status', 'backlog', 'in_progress', NULL, '2026-06-15 08:49:49', '2026-06-15 08:49:49', '2026-06-15 08:49:49', NULL, '23fbf6cd85b51eebea4a6f503399df7e', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'status', 'in_progress', 'done', NULL, '2026-06-15 08:58:55', '2026-06-15 08:58:55', '2026-06-15 08:58:55', NULL, '6d8fd42bc14bd668553da75acf9a2b83', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9F446MZFXVHH65Q6CKTPM', 'status', 'backlog', 'done', NULL, '2026-06-15 10:33:01', '2026-06-15 10:33:01', '2026-06-15 10:33:01', NULL, '15ad99803d60c7fbfd396f684fa3f7fe', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'description', NULL, 'Duplicate of the T-425 breakdown — I re-filed this as T-432 (FileLogSink) and implemented + closed that. Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:06', '2026-06-15 10:34:06', '2026-06-15 10:34:06', NULL, '5815d71c306bd9d0010964be15b1437d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'description', NULL, 'Duplicate — re-filed + implemented + closed as T-434 (FFI breadcrumbs). Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:12', '2026-06-15 10:34:12', '2026-06-15 10:34:12', NULL, 'a0609a97452cb0c43377d8b9afd233ef', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'description', NULL, 'Duplicate — re-filed + implemented + closed as T-435 (watchdog). Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:16', '2026-06-15 10:34:16', '2026-06-15 10:34:16', NULL, '328d3c9842ab73ea9404361c874a7973', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'description', NULL, 'Duplicate — re-filed + implemented + closed as T-433 (verbosity toggle: dock chip + clide log level CLI). Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:20', '2026-06-15 10:34:20', '2026-06-15 10:34:20', NULL, 'ce21cfebab0ff7680e396553aa1b49a9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'description', NULL, 'Duplicate — re-filed + implemented + closed as T-436 (CI crash-evidence artifacts). Cancelling as duplicate; work is done.', NULL, '2026-06-15 10:34:25', '2026-06-15 10:34:25', '2026-06-15 10:34:25', NULL, '1b7ec591f720837b8cbc709e2beb1823', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, '27bae65c0f89d9f5b996b0872c2c3454', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, '4d001b92a3c33be245bcd3fa3ed89eef', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, '8940e515fde31e83ef1a551997a80be0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'cce7ddffbd88447be319407f9cc7ce91', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'status', 'backlog', 'cancelled', NULL, '2026-06-15 10:34:28', '2026-06-15 10:34:28', '2026-06-15 10:34:28', NULL, 'f9a33140c03031c10ca45fecb2edb277', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'status', 'backlog', 'done', NULL, '2026-06-15 10:34:31', '2026-06-15 10:34:31', '2026-06-15 10:34:31', NULL, 'b9cda82863c37a365e7aa5fc8230256a', 2) ON CONFLICT(hash) DO NOTHING;
+18
View File
@@ -243,3 +243,21 @@ 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 ('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);
+2
View File
@@ -0,0 +1,2 @@
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);
+851
View File
@@ -4842,3 +4842,854 @@ DESIGN — three layers:
3. SIDEBAR = POWER CONTROL PANEL (D-6 parity): every owned command gets a sidebar interaction and every sidebar control is reachable as a slash command. Config tab gains inline pickers (model T-408, permission T-275, effort new); Activity tab gains session controls (clear/compact/fork/resume) and a usage/cost block (/usage IS advertised in stream-json per the probes revisit T-158''s upstream blocker).
Child stories carry the implementation slices. Refs: slash_commands.dart, claude_pane._send, claude_meta_sidebar.dart (+T-395 split), claude_config.dart probe, T-408 set_model spike pattern.', 'done', 'high', NULL, NULL, NULL, '2026-06-12 08:58:29', '2026-06-12 19:44:20', NULL, 'ab48816b1f3007884c4b29ac3acd7104', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-12 19:54:32', NULL, '06a0774cf5f423cb3a7b357a34d72018', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBTTMGKSYMTF8M1KQWTG774W', 'task', '06FB0TNQM5TWC00GW0P3X02HZW', 'Workflow card: keep run details visible while collapsed', 'Follow-up polish on the T-416 workflow run card. TODAY: when the workflow collapser is collapsed it shows only the header ticker — label ''workflow'', the run name, the done/total agent counter, and the status spinner. The per-agent rows (label + model + state glyph) and the ''usage'' line (tokens · duration) only appear once expanded.
WANT (user, 2026-06-12, with screenshot): for a workflow card, keep that run detail ALWAYS visible even collapsed so progress is glanceable without expanding. From the screenshot the always-on info is the agent rows (each agent''s label, model, spinner/check state) and the usage line (e.g. ''104725 tokens · 57302 ms'').
SCOPE: workflow cards only other ClideCollapserCard users keep their current collapsed ticker. Live-updates as the run progresses; collapse still toggles any heavier detail (e.g. the script segment) if we choose to keep some behind the caret.
DESIGN NOTE: ClideCollapserCard currently renders ONLY the ticker row when collapsed (clide_collapser_card.dart: _expanded ? _expandedFrame : _tickerRow) there is no ''persistent preview'' slot. Two options: (a) the workflow card stops relying on the collapser to hide the agent rows and instead renders an always-visible mini-panel (agent rows + usage) with a collapser beneath it for the script/extras; or (b) extend ClideCollapserCard with an optional always-visible preview slot under the header. Prefer (a) unless other cards would reuse (b).
FILES: lib/builtin/claude/src/conversation_view.dart (_workflowCard / _workflowBody / _workflowAgentRow); possibly lib/widgets/src/clide_collapser_card.dart if going with option (b). Tests: test/builtin/claude/conversation_view_test.dart (assert agent rows + usage render while collapsed).
ACCEPTANCE: a collapsed workflow card shows each agent row with its live state and the usage line; the counter/spinner still summarize; non-workflow collapsers are unchanged.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 19:58:58', '2026-06-12 19:58:58', NULL, '92bba4143565e2e3626824eacd3d4793', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` command:panel.focus.left; `ctrl+w l` command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` command:dock.toggle document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` focus.nextPanel; `ctrl+w shift+w` focus.previousPanel
- `ctrl+w o` command:panel.focusMode (vim "only" exact semantic match)
- `ctrl+w q` and `ctrl+w c` command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
ACCEPTANCE CRITERIA:
- vim.yaml gains ctrl+w bindings: `ctrl+w h`panel.focus.left, `ctrl+w l`panel.focus.right, `ctrl+w j`dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`focus.nextPanel, `ctrl+w shift+w`focus.previousPanel, `ctrl+w o`panel.focusMode, `ctrl+w q`/`ctrl+w c`editor.close, all `when: vim.normal || vim.visual`.
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
- No resolution change under default/vscode/jetbrains editor_presets_test.dart `ctrl+w editor.close` (e.g. line 60) stays green.
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
- make analyze + format + keymap suite pass; 95% coverage floor holds.
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface coordinate ordering to avoid three matcher rewires.
OPEN QUESTIONS:
- Where does the global multi-chord matcher live a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
- Should the family also fire in vim.insert (it shouldn''t ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-12 20:03:16', NULL, '504a560e8f4d670770c41d4b7767a7a7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-12 20:03:43', NULL, '607534e0a940d10eb7121be4265b9c80', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, GmaxScrollExtent AND re-arm _atBottom follow-tail, gg0). Lists (tickets/git) trail in a follow-up commit. Navigation only no editing semantics outside the editor.
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability land/decide this first.
ACCEPTANCE CRITERIA:
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, ggoffset 0, G_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
OPEN QUESTIONS:
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard (b) only works because each pane owns its handler.
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-12 20:03:58', NULL, '7364d54f0bb8aa1084227b4b474bc391', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-12 20:04:07', NULL, 'f4926cd7346fa26a4301f248e8977c0c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.
--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404407) ---
SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w , g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift and it must be built ONCE, in one place, not three times.
RECOMMENDED SEQUENCING:
1. T-406 (the structural child) FIRST it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability.
2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path land it anytime for immediate value across every preset.
3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists.
4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet T-407 must add one).
All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) coordinate the shared-prefix matcher tests.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-12 20:05:16', NULL, 'c19ee14d0a7d089f7f3498e4773af343', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-13 11:39:41', NULL, 'db54855fb1bd9291941da7dc13f40242', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-13 11:41:23', NULL, '8e7a4dafca6a2689ff7ed36862209a85', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, GmaxScrollExtent AND re-arm _atBottom follow-tail, gg0). Lists (tickets/git) trail in a follow-up commit. Navigation only no editing semantics outside the editor.
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability land/decide this first.
ACCEPTANCE CRITERIA:
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, ggoffset 0, G_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
OPEN QUESTIONS:
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard (b) only works because each pane owns its handler.
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 11:42:26', NULL, 'a1258e7d47ac99671a66d47eed661939', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP67X1Y1FEE9T5R0E5DA9C', 'story', NULL, 'vim cross-pane interaction layer — window commands, tab motions, pane-local normal-mode nav', 'From the 2026-06-12 vim keybind review (user: "we are leaving opportunities on the table" for cross-pane vim interactions). Findings:
TODAY the vim layer (T-65) is editor-only. vim.normal/insert/visual scope flags are global (VimModeService), but every binding in vim.yaml either targets editor.vim.* (applied by the focused editor''s key handler, editor_view.dart _dispatchVim) or is a copy of the default preset''s app chords. Outside the editor, the vim preset offers nothing vim-shaped: no ctrl+w window family, no gt/gT, no j/k in the file tree / ticket list / git panel / conversation (those panes have NO key handling at all mouse-only), no ex command line (vim_mode_service.dart explicitly defers it as "a transient overlay").
EXISTING primitives to map onto: focus.nextPanel/previousPanel (F6/shift+F6), panel.focus.left/middle/right (ctrl+1/2/3), panel.focusMode (ctrl+. semantically EXACTLY vim''s ctrl+w o "only"), editor.open/close (ctrl+e/ctrl+w), dock.toggle (ctrl+j), sidebar.collapse/context.collapse, quickOpen, alt+1..5 sidebar sections. The D-82 sequence matcher already resolves exact-vs-longer ambiguity with a pending-exact + timeout (sequence_matcher.dart _pendingExact), so chord-prefixed sequences like "ctrl+w h" are expressible in preset YAML today.
GAP also found: no workspace tab next/prev cycling command exists for ANY preset (only direct alt+N for sidebar sections) child ticket adds the commands, vim binds gt/gT to them.
Children: T-404 (ctrl+w window-command family), T-405 (tab cycle commands + gt/gT), T-406 (normal-mode list/scroll nav intents for non-editor panes), T-407 (ex command-line overlay). 404/405 are YAML+small-command work; 406 is the structural one; 407 is the most visible.
--- COORDINATION NOTE (2026-06-12, from the parallel refinement of T-404407) ---
SHARED BLOCKER: all four children assume vim-shaped multi-chord sequences (ctrl+w , g t, g g, : ) can be matched outside the editor. They CANNOT today. The global key path (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent) is single-chord only and skips `isSequence` bindings; the only SequenceMatcher lives inside the editor (editor_view.dart) and even there drops non-shift ctrl chords. So a global/shared multi-chord matcher (D-82 pending-exact + timeout flush) is the real structural lift and it must be built ONCE, in one place, not three times.
RECOMMENDED SEQUENCING:
1. T-406 (the structural child) FIRST it establishes whether non-editor panes can run sequence matchers at all (per-pane Focus.onKeyEvent + matcher). T-404 and T-405''s gt/gT consume that capability.
2. T-405 part 1 (ctrl+pagedown/up tab-cycle commands) is independent and shippable NOW on the existing single-chord path land it anytime for immediate value across every preset.
3. T-404 (ctrl+w family) and T-405 part 2 (gt/gT) after the global matcher exists.
4. T-407 (ex `:` overlay) after T-404, so :q reuses whatever editor.close semantics T-404 settles (note: editor.close closes the whole split, not a single tab; and NO editor.save command exists yet T-407 must add one).
All four share assets/keymaps/vim.yaml and the `g`-prefix space (g g docStart vs g t) coordinate the shared-prefix matcher tests.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-12 03:20:52', '2026-06-13 11:45:19', NULL, '5518d537ba94da58e79fb00562bab03a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` command:panel.focus.left; `ctrl+w l` command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` command:dock.toggle document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` focus.nextPanel; `ctrl+w shift+w` focus.previousPanel
- `ctrl+w o` command:panel.focusMode (vim "only" exact semantic match)
- `ctrl+w q` and `ctrl+w c` command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
ACCEPTANCE CRITERIA:
- vim.yaml gains ctrl+w bindings: `ctrl+w h`panel.focus.left, `ctrl+w l`panel.focus.right, `ctrl+w j`dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`focus.nextPanel, `ctrl+w shift+w`focus.previousPanel, `ctrl+w o`panel.focusMode, `ctrl+w q`/`ctrl+w c`editor.close, all `when: vim.normal || vim.visual`.
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
- No resolution change under default/vscode/jetbrains editor_presets_test.dart `ctrl+w editor.close` (e.g. line 60) stays green.
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
- make analyze + format + keymap suite pass; 95% coverage floor holds.
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface coordinate ordering to avoid three matcher rewires.
OPEN QUESTIONS:
- Where does the global multi-chord matcher live a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
- Should the family also fire in vim.insert (it shouldn''t ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-13 11:47:21', NULL, '0a109484fa4b7e20860d503cebb1c612', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-13 11:47:26', NULL, 'b987c27388c5af5841dbf454e365de32', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPFTC7H5NY0XHBTEGF8XQ4', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ex command-line overlay (:w :q :e :N)', 'The deferred piece vim_mode_service.dart already names: "command-line is surfaced separately as a transient overlay rather than a persistent mode." Minimal ex line, not a vimscript interpreter:
- `:` (shift+semicolon) when vim.normal opens a one-line overlay (reuse the quick-open overlay chrome/widgets; it is NOT a mode Esc dismisses back to normal, no scope-flag churn beyond an exline.open flag for its own enter/escape bindings).
- v1 grammar, one table, no parsing cleverness:
:w editor save (find the editor''s save command id; check editor_commands.dart _save), :q command:editor.close, :wq / :x save then close, :e <text> quickOpen.open pre-seeded with <text> (check QuickOpenIntent for a seed param; add one if absent), :<digits> editor goto-line (editor has a goto? if not, smallest possible addition to editor.vim ops), :<unknown> shake/flash + stay open.
- ZZ ("shift+z shift+z" sequence) save-close, riding the same plumbing include it here, it''s one YAML line once :wq exists.
- Cross-pane angle: the ex line is GLOBAL under vim.normal (works with tree/conversation focused :q closes the focused tab via editor.close fallback to active workspace tab; keep v1 simple: editor-targeted only, document it).
Done when: : opens the overlay from any pane under the vim preset; the v1 table works with widget tests; unknown commands don''t execute anything; ZZ saves+closes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Build the transient ex-line overlay vim_mode_service.dart already names as deferred. `:` (shift+semicolon under vim.normal) opens a one-line overlay modeled on the quick-open chrome (lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart); it is NOT a vim mode an overlay with its own exline.open scope flag for enter/escape, Esc dismissing to normal. v1 is a fixed dispatch table, no parser. GROUNDING FINDINGS that reshape scope: (1) `:q`editor.close exists (default_layout extension _closeEditor) but closes the ENTIRE editor split via arrangement.closeEditor(), NOT a single buffer/tab document this; a true single-tab :q needs new wiring (EditorController.closeBuffer is per-id but not a registry command). (2) There is NO editor.save CommandRegistry command save exists only as an IPC verb (editor.save in lib/src/daemon/editor_commands.dart) and EditorController.save()/the editor''s ctrl+S. So `:w` cannot just dispatch command:editor.save today this ticket must ADD a save command (real work, not one YAML line). (3) :e <text>quick-open seeded with text: QuickOpenController.open() takes NO seed param (verified quick_open.dart:45) add one. (4) :<digits> goto-line: editor_commands.dart supports a `line` arg on editor.open (IPC, lines 79-90) but there''s no registry goto-line for the OPEN buffer smallest addition needed. Keep v1 editor-targeted and document it. ZZ (`shift+z shift+z`) rides the :wq plumbing once save+close exist.
ACCEPTANCE CRITERIA:
- `:` (shift+semicolon) under vim.normal opens a one-line ex overlay reusing quick-open chrome; an exline.open scope flag gates its enter/escape; Esc dismisses to normal with no vim-mode churn.
- Fixed v1 table: :w saves the active buffer, :q closes (documented: closes the editor split via editor.close), :wq/:x save then close, :e <text> opens quick-open seeded with <text>, :<digits> jumps the active buffer to that line.
- :<unknown> executes nothing and flashes/shakes + stays open (no silent command:foo dispatch).
- ZZ (`shift+z shift+z`) under vim.normal saves and closes, sharing the :wq path.
- A save command reachable from the keymap is added (none exists today), and an editor goto-line registry command is added (or the smallest editor.vim op extension).
- QuickOpenController.open() gains a seed/initialQuery parameter and the overlay honors it.
- Widget tests cover overlay open/dismiss + each table row; bindings asserted under the vim preset only; no behavior change under other presets.
FILES: assets/keymaps/vim.yaml (`:` open under vim.normal; exline enter/escape under exline.open; ZZ as `shift+z shift+z`); lib/builtin/vim/src/vim_mode_service.dart (the deferral point; may host overlay open state); lib/builtin/vim/src/extension.dart (register ex-line command(s)/overlay as CommandContributions, like _modeCommand); lib/widgets/src/quick_open_overlay.dart + lib/kernel/src/quick_open.dart (reuse chrome; ADD seed/initialQuery to open()); lib/builtin/default_layout/src/extension.dart (editor.close is here, closes the split for :q; add editor.save/goto-line registry command here or in editor ext); lib/builtin/editor/src/editor_controller.dart (save()/closeBuffer() the per-buffer ops); lib/src/daemon/editor_commands.dart (editor.save / editor.open `line` arg IPC-only reference for goto-line _offsetForLine); NEW lib/builtin/vim/src/ex_line_overlay.dart + tests under test/builtin/vim/.
DEPENDENCIES: Depends on / overlaps T-404 both reference command:editor.close. T-404 settles the bare-ctrl+w vs ctrl+w-prefix ambiguity and exercises editor.close cross-pane; T-407''s :q should reuse whatever close semantics T-404 settles (and surface the split-vs-tab close question). Shares vim.yaml. The `z` prefix (ZZ) is new and collides with nothing; `:` (shift+semicolon) is free. Independent of T-405/T-406 except the common vim.yaml. Best sequenced after T-404 so close semantics are fixed first.
OPEN QUESTIONS:
- :q todayeditor.close closes the whole split (arrangement.closeEditor), not the focused tab acceptable v1, or must :q close only the active buffer (new per-tab close command wrapping EditorController.closeBuffer)? Surprises vim users.
- No save command in CommandRegistry (only IPC editor.save + the editor''s ctrl+S). Confirm the :w mechanism a new CommandContribution reaching the active EditorController.save() vs dispatching the IPC verb and where it lives (editor ext vs vim ext).
- Cross-pane: ZZ/:w/:q only make sense with an editor buffer active. When tree/conversation is focused and no editor is open, should :w/:q no-op, flash, or close the focused workspace tab? Ticket says "editor-targeted only, document it" confirm the no-buffer behavior.
- Should the ex overlay live in the vim builtin (inert under non-vim presets), gated by VimModeService.enabled, matching how mode commands are gated?
- goto-line for the OPEN buffer: editor.open accepts a `line` arg but reopening isn''t right for an already-open buffer add an editor.vim.gotoLine op (vim_edit_ops.dart) or a registry command that sets selection on the active buffer?
--- DECISION: :q / ZZ close semantics (2026-06-12, user) ---
RESOLVED (was the open "split vs tab" question): `:q` closes the ACTIVE TAB, not the whole editor split. After closing it focuses the next editor tab, so repeated `:q` walks the tabs and the LAST `:q` ends up collapsing the split (the "ends up doing editor.close in the end" behavior the user wants).
KEY MECHANISM (verified in code) this falls out of existing wiring, so `:q` should NOT map to command:editor.close at all:
- `:q` EditorController.closeBuffer(activeId) (lib/builtin/editor/src/editor_controller.dart:90) the same per-tab close the tab-strip X already uses (editor_view.dart:306-322 onCloseRequested).
- Server registry close(id) (lib/src/editor/registry.dart:178-187) removes the buffer and, when it was active, re-activates another and emits editor.active-changed; when the LAST buffer closes it emits editor.active-changed{id:null}.
- The editor extension already turns that null-active event into arrangement.closeEditor() (lib/builtin/editor/src/extension.dart:22-45 lib/kernel/src/panels/arrangement.dart:108-112). So the split self-collapses on the final tab no explicit editor.close needed, and command:editor.close (the whole-split close, default_layout extension.dart:244-252) stays the ctrl+w binding only.
THE ONE REAL GAP: registry close() re-focuses `_buffers.values.first` (registry.dart:182), i.e. the FIRST remaining buffer, not the NEXT tab in visual order. Vim `:q` wants focus to move to the tab to the RIGHT of the closed one (else the LEFT if it was last). Two options:
(a) UI-side: before closeBuffer, compute the next tab from _tabs.entries (editor_view.dart) and activate it, then close no protocol change; keeps tab-visual-order knowledge in the view that owns it.
(b) Server-side: teach registry.close() a focus-direction (next-not-first), so the tab-strip X button also gets vim-correct next-focus. Wider blast radius (protocol + all close callers) but fixes the focus order everywhere, not just for :q.
RECOMMEND (a) for the :q scope, and file (b) separately if we want the X button to match. Confirm before building.
ACCEPTANCE CRITERIA (supersede the earlier ":q closes the split" line):
- `:q` closes the active editor tab; focus moves to the next tab (right, else left). With one tab open, `:q` closes it and the editor split collapses (via the existing null-active closeEditor path) no separate editor.close dispatch.
- N tabs open + N `:q` in a row closes them left-to-focus-order and ends with the split collapsed.
- `:wq` / `:x` / `ZZ` save the active buffer then run the same close-active-tab path.
- `:q` with no editor buffer active (tree/conversation focused, editor closed) no-ops or flashes does NOT touch other panes (still an open question below).
STILL OPEN: when no editor buffer is active, does `:q` no-op, flash, or close the focused workspace tab? (Cross-pane angle keep v1 editor-targeted.)
--- DECISION: no-active-buffer behavior (2026-06-13, user) ---
RESOLVED (was the last STILL OPEN question): when no editor buffer is active (tree/conversation focused, editor split closed), `:q` / `:w` / `:wq` / `:x` / `ZZ` NO-OP for v1 they do nothing, touch no other pane, and don''t close the focused workspace tab. (A flash/shake is optional polish, not required.) Keeps v1 strictly editor-targeted; closing non-editor workspace tabs via `:q` is explicitly out of scope and can be revisited later if wanted.', 'ready', 'low', NULL, NULL, NULL, '2026-06-12 03:22:10', '2026-06-13 11:47:31', NULL, 'e8a308082017a6bdbb79aa0cff8c9c5d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPD85PFBPJJTQ0WS3PWWXR', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'normal-mode list/scroll navigation intents for non-editor panes', 'The structural piece: make vim NORMAL mode mean something in panes that aren''t the editor. Today the file tree, ticket board, git panel, and conversation view have no keyboard handling at all (mouse-only — verified 2026-06-12); under the vim preset, j/k outside the editor are dead keys.
Mechanism (follow the ActivateIntent pattern from default.yaml intents dispatched via Actions.maybeInvoke against the FOCUSED context, so only opted-in widgets respond and there''s no global-flag confusion):
1. New typed intents in kernel/src/keymap/intents.dart: nav.down / nav.up / nav.pageDown / nav.pageUp / nav.top / nav.bottom / nav.expandOrRight / nav.collapseOrLeft / nav.activate (ids in builtinIntents).
2. vim.yaml binds them when "vim.normal && !editor.focused": j/k, ctrl+d/ctrl+u, "g g"/shift+g, l/h, [o, enter]. Needs an editor.focused scope flag if none exists check what the editor publishes today; the editor''s own key handler consumes j/k first when focused, so the guard may even be unnecessary verify dispatch order and document it.
3. Panes opt in with Actions handlers:
- file tree (lib/builtin/files/src/file_tree_view.dart): selection cursor + j/k move, h/l collapse/expand-or-step-into, o/enter open (the NERDTree idiom)
- conversation view (lib/builtin/claude/src/conversation_view.dart): j/k line scroll, ctrl+d/u half page, G jump-to-bottom AND re-arm follow-tail (_atBottom), gg top
- ticket board + git panel lists: selection cursor + activate
4. default/vscode/jetbrains presets can bind the same intents to arrows/page keys later the intents are preset-neutral; this ticket only wires vim.
Scope guard: this is keyboard NAVIGATION only no editing semantics outside the editor. Start with tree + conversation (highest value), lists can trail in a follow-up commit on the same ticket.
Done when: with the vim preset active and the tree/conversation focused, j/k/ctrl+d/ctrl+u/gg/G work as above; widget tests per pane; zero behavior change under other presets and in insert mode.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Make vim normal-mode keys mean navigation in panes that are mouse-only today (verified: file_tree_view.dart, conversation_view.dart, git_panel_view.dart, tickets_view.dart all use ClideTappable rows with no nav-key handling). Add typed nav.* intents to lib/kernel/src/keymap/intents.dart + builtinIntents, bind them in vim.yaml under vim.normal, then have each pane opt in. CRITICAL structural finding the ticket understates: the global key path (RootShell._onKey) is a passive KeyboardListener doing single-chord resolveEvent only it CANNOT consume events or run sequences. Multi-key motions (gg, disambiguating bare j/k from text) require each pane to host its OWN SequenceMatcher inside a Focus.onKeyEvent handler, exactly like the editor (editor_view.dart _onKey + _matcher, lines 169-227). So the real work per pane is a focusable key handler + matcher, with nav.* as the dispatched vocabulary; YAML bindings alone are insufficient. Start with file tree (NERDTree idiom: a NEW flat-index selection-cursor model over the recursive _Children tree + FileTreeController) and conversation (j/k scroll _scroll by a line, ctrl+d/u half-page, GmaxScrollExtent AND re-arm _atBottom follow-tail, gg0). Lists (tickets/git) trail in a follow-up commit. Navigation only no editing semantics outside the editor.
THIS IS T-403''s STRUCTURAL CHILD: it establishes whether non-editor panes can run sequence matchers at all. T-404 (ctrl+w) and T-405 part 2 (gt/gT) consume that capability land/decide this first.
ACCEPTANCE CRITERIA:
- nav.down/up/pageDown/pageUp/top/bottom/expandOrRight/collapseOrLeft/activate intent classes added to intents.dart + registered in builtinIntents by id.
- vim.yaml binds j/k/ctrl+d/ctrl+u/`g g`/shift+g/l/h/`o`,`enter` to those intents under vim.normal, with zero resolution under default/vscode/jetbrains and in vim.insert/vim.visual.
- file tree (file_tree_view.dart + file_tree_controller.dart): j/k move a visible selection cursor over the flattened expanded tree, h collapses-or-steps-out, l expands-or-steps-in, o/enter opens via openWorkspaceFile; selection/focus ring visible.
- conversation (conversation_view.dart): j/k scroll ~one line, ctrl+d/u half a viewport, ggoffset 0, G_scroll.position.maxScrollExtent and sets _atBottom=true so follow-tail re-arms.
- each opted-in pane handles motions via its own Focus.onKeyEvent + SequenceMatcher (mirroring editor_view.dart) so gg and bare j/k resolve without leaking to text or other panes.
- widget tests per pane (tree, conversation) prove the motions; editor vim tests + other-preset behavior unchanged.
- git panel + ticket board list nav delivered OR explicitly deferred to a follow-up commit on this ticket.
FILES: lib/kernel/src/keymap/intents.dart; assets/keymaps/vim.yaml (mind the `g g` docStart prefix); lib/builtin/files/src/file_tree_view.dart; lib/builtin/files/src/file_tree_controller.dart (NEW flat visible-index + selection model); lib/builtin/claude/src/conversation_view.dart (reuse _atBottom/_trackBottom/jumpTo, lines ~90-114, 280-290); lib/builtin/git/src/git_panel_view.dart + lib/builtin/tickets/src/tickets_view.dart (follow-up); test/builtin/editor/vim_preset_test.dart + new per-pane widget tests under test/builtin/files and test/builtin/claude.
DEPENDENCIES: Should land before T-404/T-405 conceptually (it decides whether non-editor panes can run matchers), but technically independent (different intents/files). Shares the vim.yaml `g`-prefix space with T-405 (g t / g shift+t) and the existing `g g` docStart coordinate the shared `g` sequence-prefix tests. No code conflict with T-404 (ctrl+w) or T-407 (`:` overlay).
OPEN QUESTIONS:
- The `vim.normal && !editor.focused` guard assumes an editor.focused scope flag VERIFIED it does NOT exist (only in comments; vscode.yaml notes it "has no producer yet"). Decide: (a) create the producer (FocusTracker.setActive in lib/kernel/src/focus.dart publishing editor.focused via KeymapService.setScopeFlag), or (b) rely on the editor''s own _onKey consuming bare j/k first when focused and drop the guard (b) only works because each pane owns its handler.
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 12:45:43', NULL, '5d7f59ef58611f7fe6c8a4cb3e755154', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FC2XY1T85A65YY9SG25VVEY4', 'bug', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Worktree-safe git hooks + WorktreeCreate bootstrap (pql hooks break git worktree add)', 'PROBLEM (diagnosed 2026-06-13): `pql init` installs .githooks/post-checkout that unconditionally sources an UNTRACKED .pql/hooks/post-checkout (which does `pql plan rebuild` on branch checkout). A fresh `git worktree add` checks out only TRACKED files, so that hook is absent in every new worktree → the `.` source fails and the post-checkout hook exits non-zero, which `git worktree add` propagates as a hard failure. This breaks Claude Code agent isolation:''worktree'' (and any clide worktree flow) in EVERY clide repo that ran pql init — and, because the bug is in pql''s init template, in every pql-using repo, not just clide.
REPRO: `git worktree add <path> -b <branch> HEAD` fails with exit 1 from .githooks/post-checkout (the source of the absent untracked hook). Confirmed both sequential and concurrent.
REPO-LOCAL FIX (DONE commits 008779c then 054eaf6 on main): .githooks/post-checkout now guards with `if [ -f "$hook" ]; then . "$hook"; fi` and forces `exit 0` (post-checkout is best-effort and must never abort a checkout/worktree). NB: the first attempt `[ -f x ] && . x` was itself buggy returns 1 when the file is absent (the script''s last statement), still aborting.
PERMANENT FIX (this ticket clide repo AND clide-the-product, since clide ships to other devs and owns workspace onboarding per T-354):
1. FIX THE TEMPLATE AT SOURCE: the worktree-safe hook (if-guard + exit 0) must be what gets INSTALLED, not a one-off patch. As clide internalizes pql (T-354/T-355), clide should own/patch the post-checkout hook install so every clide-managed workspace is worktree-safe. ALSO report upstream to pql its init hook template has this latent bug for all pql users.
2. WORKTREE BOOTSTRAP via a WorktreeCreate hook: ship a .claude/settings.json `WorktreeCreate` hook (verified real Claude Code hook fires on harness worktree creation for --worktree / EnterWorktree / agent isolation; receives {hook_event_name,cwd,name} on stdin; MUST print the new worktree''s absolute path on stdout) that copies the local .pql/hooks/ into the new worktree and runs `pql plan rebuild` so pql state is correct in agent worktrees (the guarded git hook only stops the ABORT; it doesn''t make the rebuild happen in the worktree). Pair with `WorktreeRemove` for teardown. Known caveats / open CC issues: #36205 (EnterWorktree ignores these hooks), #39281 (--worktree --tmux skips them) — so the git-hook safety net in (1) is still needed.
3. baseRef: settings schema `worktree.baseRef` defaults to ''fresh'' (branches worktrees from origin/<default-branch>), so UNPUSHED local commits are absent in agent worktrees which silently breaks foundation-first agent fan-outs (observed: 2 of 3 agents branched from origin/main without the local foundation). For clide''s commit-locally / batch-push dev flow, set `worktree.baseRef: "head"` in clide''s .claude/settings.json (or document the tradeoff).
FILES: .githooks/post-checkout (repo fix done); .claude/settings.json (add WorktreeCreate/WorktreeRemove hooks + worktree.baseRef:head); the pql-init / onboarding path clide will own (T-354/T-355); docs/CONTRIBUTING. Upstream: file a pql issue/PR for the init hook template.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-13 14:51:51', '2026-06-13 14:51:51', NULL, '8c0ffbe33ea5f675ac6bd1c1183e9afa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKPAZR4XEV8YW3PVR2XJBFC', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'workspace tab cycle commands + vim gt/gT', 'Two halves; the first benefits every preset (the review found NO tab-cycling command exists anywhere — only alt+1..5 direct sidebar-section picks):
1. New commands in the default-layout extension (or panels host): workspace.tab.next / workspace.tab.previous cycle the workspace slot''s tab strip (PanelRegistry/MultitabPane activate-next/previous with wraparound). Give them defaultBindings ctrl+pagedown / ctrl+pageup (the GTK/VS Code convention) so default/vscode/jetbrains presets gain tab cycling for free. Check lib/kernel/src/panels/registry.dart for the activation API; add one if only direct activateTab(id) exists.
2. vim.yaml: `g t` command:workspace.tab.next, `g shift+t` command:workspace.tab.previous, when vim.normal. Watch the existing `g g` (docStart) prefix the matcher already buffers `g`, so `g t` slots in beside it; add a matcher/loader test for two sequences sharing the `g` prefix with different finals.
Done when: ctrl+pagedown/up cycle workspace tabs under every preset; gt/gT cycle under vim; shared-prefix sequence test green; alt+N behavior unchanged.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Two halves. (1) Add workspace.tab.next / workspace.tab.previous commands in lib/builtin/default_layout/src/extension.dart that cycle the workspace slot''s tab strip with wraparound, with defaultBindings ctrl+pagedown / ctrl+pageup so EVERY preset gains tab cycling. PanelRegistry (lib/kernel/src/panels/registry.dart) confirms the gap only activateTab(SlotId,tabId), activeTabIn(SlotId), tabsFor(SlotId); no cycle so compute the wrapped index from tabsFor+activeTabIn, or add a cycleTab method. (2) Bind `g t`workspace.tab.next and `g shift+t`workspace.tab.previous, `when: vim.normal`. Half (1) is fully achievable TODAY (single-chord resolveEvent + InvokeCommandIntentcommands.execute bridge in root_shell.dart both exist; pagedown/pageup tokens exist in key_chord.dart). Half (2) shares T-404''s blocker: no global SequenceMatcher, so a `g`-prefixed sequence can''t buffer outside the editor. `g g` (docStart) is already bound vim.normal||vim.visual, so `g t` slots beside it but only once a matcher runs on the focused surface.
ACCEPTANCE CRITERIA:
- workspace.tab.next/previous registered in default_layout, cycling Slots.workspace tabs (tabsFor order) with wraparound; no-op at 0/1 tab.
- defaultBindings ctrl+pagedown / ctrl+pageup so default/vscode/jetbrains cycle workspace tabs without YAML edits; verified via keymap resolution test.
- vim.yaml binds `g t`workspace.tab.next, `g shift+t`workspace.tab.previous, when: vim.normal.
- gt/gT cycle workspace tabs under vim; the `g` prefix is shared with `g g` docStart without breaking either.
- Existing alt+1..5 sidebar-section behavior unchanged (those target Slots.sidebar, not workspace).
- A matcher/loader test covers two sequences sharing the `g` prefix with different finals (g g vs g t) under vim scope.
- make analyze + format + keymap/panel tests pass; coverage floor holds for default_layout / registry if touched.
FILES: lib/builtin/default_layout/src/extension.dart (two CommandContributions w/ defaultBinding ctrl+pagedown/up + handlers computing wrapped index, following the sidebar.section.N / editor.close pattern); lib/kernel/src/panels/registry.dart (optional cycleTab helper); assets/keymaps/vim.yaml (g t / g shift+t near `g g`); lib/src/shell/root_shell.dart (global path that must buffer `g` same surface as T-404); test/kernel/src/keymap/{editor_presets_test,sequence_matcher_test}.dart; test/kernel/src/panels/registry_test.dart (verify path before assuming).
DEPENDENCIES: Part 1 (ctrl+pagedown/up) is fully independent and shippable now needs only the existing single-chord path + InvokeCommandIntent bridge. Part 2 (gt/gT) shares T-404''s hard dependency on a global multi-chord matcher (the structural work T-406 owns). Recommend: land part 1 first (immediate value, every preset), gate part 2 behind whichever ticket introduces the global matcher. Coordinate matcher wiring with T-404 so it isn''t built twice. No conflict with T-407.
OPEN QUESTIONS:
- Add a cycleTab/activateNext API to PanelRegistry, or compute the wrapped index in the handler from tabsFor(Slots.workspace)+activeTabIn? Registry method is cleaner/reusable but widens coverage surface; handler-local keeps the change in default_layout.
- Cycle Slots.workspace specifically, or the currently-focused slot''s tab strip (so gt cycles whatever column has focus)? Ticket says workspace; confirm against the cross-pane intent of the epic.
- Confirm ctrl+pagedown/up don''t collide with terminal/Claude pane passthrough or an existing binding in any of the four presets before claiming "free for every preset".
- Does gt/gT need a visual-mode guard, or is vim.normal-only correct? (vim allows gt in normal; the gg precedent uses normal||visual.)
--- PROGRESS (2026-06-13) ---
PART 1 DONE (commit on main): workspace.tab.next / workspace.tab.previous cycle commands with wraparound + ctrl+pagedown/ctrl+pageup defaultBindings across every preset. Tests in test/builtin/default_layout/widget_test.dart.
PART 2 (gt/gT) STILL OPEN and harder than the coordination note assumed. T-404 landed a global multi-chord matcher (root_shell), BUT it deliberately only STARTS a sequence on a MODIFIED chord (ctrl+w). Bare-key prefixes (g) are left editor/pane-local on purpose otherwise the global matcher would steal `g` before the editor sees it, breaking gg/dd. So gt/gT (bare g) CANNOT just ride the global matcher. Options for part 2: (a) the editor/pane matchers grow gt/gT and dispatch command:workspace.tab.* (but then gt only works when the editor/a pane is focused, not globally); (b) a focus-agnostic bare-g disambiguation (g g = local docStart vs g t = global tab) needs the global matcher to tentatively grab bare g AND coordinate with the editor''s matcher, which is the exact conflict T-404 avoided. Decide before building. The ctrl+pagedown/up commands already give every preset tab-cycling; gt/gT is a vim-affordance nicety on top.', 'ready', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:31', '2026-06-13 16:38:16', NULL, '863a80e6efed7d96db4f5e960ab4e200', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBKP8KFAF526ZNXBQS98DPPG', 'task', '06FBKP67X1Y1FEE9T5R0E5DA9C', 'vim ctrl+w window-command family', 'Bind vim''s window-command prefix in assets/keymaps/vim.yaml, guarded `when: vim.normal` (and probably `|| vim.visual`), mapping onto the existing panel commands — no new services:
- `ctrl+w h` command:panel.focus.left; `ctrl+w l` command:panel.focus.right (clide''s three-column layout has no vertical pane stack, so j/k map to the dock: `ctrl+w j` command:dock.toggle document the approximation in the YAML comment)
- `ctrl+w w` and `ctrl+w ctrl+w` focus.nextPanel; `ctrl+w shift+w` focus.previousPanel
- `ctrl+w o` command:panel.focusMode (vim "only" exact semantic match)
- `ctrl+w q` and `ctrl+w c` command:editor.close
Conflict to resolve (the real work): editor.close carries defaultBinding ''ctrl+w'' globally. Verify how preset bindings + defaultBindings merge in KeymapService, and that the sequence matcher''s pending-exact path (sequence_matcher.dart, _pendingExact + timeout flush) makes bare ctrl+w wait for a possible second chord under the vim preset bare ctrl+w should still close the editor after the ambiguity timeout, prefix completions should win immediately. Add matcher tests for chord-prefixed sequences (existing tests cover `d d` letter sequences; `ctrl+w h` adds a modified first chord).
Done when: all bindings above work under the vim preset with editor focused AND with tree/conversation focused (they''re global commands, not editor.vim.*); bare ctrl+w still closes the editor after the timeout; no behavior change under default/vscode/jetbrains presets; keymap loader + matcher tests cover the new shapes.
--- REFINEMENT (2026-06-12, parallel workflow refine-t403-tickets) ---
SHARPENED: Bind the vim ctrl+w window-command family onto existing panel commands but the "YAML + small command, no new services" framing is WRONG: no surface can match a ctrl+w-prefixed sequence today. The global handler (lib/src/shell/root_shell.dart _onKey KeymapService.resolveEvent Keymap.resolve) is single-chord only and explicitly skips `b.isSequence` bindings it has no SequenceMatcher. The only SequenceMatcher lives in the editor (lib/builtin/editor/src/editor_view.dart:68), and its _onKey returns KeyEventResult.ignored for any non-shift-modified chord (lines 213-215), so even editor-focused the matcher never sees ctrl+w. The real work is a global/shared SequenceMatcher (with D-82 pending-exact + timeout flush) so ctrl+w buffers and `ctrl+w h` resolves, while bare ctrl+w still fires editor.close after the timeout. The YAML bindings + matcher tests are the small part.
ACCEPTANCE CRITERIA:
- vim.yaml gains ctrl+w bindings: `ctrl+w h`panel.focus.left, `ctrl+w l`panel.focus.right, `ctrl+w j`dock.toggle (comment the 3-column approximation), `ctrl+w w`/`ctrl+w ctrl+w`focus.nextPanel, `ctrl+w shift+w`focus.previousPanel, `ctrl+w o`panel.focusMode, `ctrl+w q`/`ctrl+w c`editor.close, all `when: vim.normal || vim.visual`.
- A global (non-editor) key path matches multi-chord sequences: `ctrl+w h` fires panel.focus.left with the file tree / conversation focused (those panes have no Focus key handler today), not just editor-focused.
- Bare ctrl+w still closes the editor after the ambiguity timeout under vim (editor.close''s contributions-layer ctrl+w binding preserved); a completed prefix (ctrl+w o) fires immediately and suppresses bare ctrl+w.
- No resolution change under default/vscode/jetbrains editor_presets_test.dart `ctrl+w editor.close` (e.g. line 60) stays green.
- sequence_matcher / loader tests cover a modified first chord (ctrl+w h) and the ctrl+w-vs-ctrl+w-h exact-plus-prefix ambiguity, paralleling the `d d` / `ctrl+k ctrl+s` cases.
- make analyze + format + keymap suite pass; 95% coverage floor holds.
FILES: assets/keymaps/vim.yaml; lib/src/shell/root_shell.dart (_onKey single-chord today, needs buffering); lib/kernel/src/keymap/keymap_service.dart (resolveEvent single-chord; may need a sequence-aware surface); lib/kernel/src/keymap/sequence_matcher.dart (reuse as-is); lib/builtin/editor/src/editor_view.dart (lines 213-215 drop ctrl chords decide intercept here vs globally); test/kernel/src/keymap/{sequence_matcher_test,editor_presets_test,shipped_presets_test}.dart.
DEPENDENCIES: Hard dependency on the global-matcher wiring that T-406 ("the structural one") is scoped to own non-editor panes have NO key handling today, so "works with tree/conversation focused" is unachievable until that lands. Build the global SequenceMatcher once, in one place; sequence with T-406. Independent of T-405/T-407 at the binding level, but all four share the global key-routing surface coordinate ordering to avoid three matcher rewires.
OPEN QUESTIONS:
- Where does the global multi-chord matcher live a buffer in root_shell._onKey, a sequence-aware KeymapService method, or is it explicitly T-406''s deliverable that T-404 consumes? Determines whether T-404 is "small" or carries the structural lift.
- ctrl+w must be intercepted before the editor''s _onKey discards it AND before the global single-chord resolveEvent fires editor.close immediately confirm timeout/pending-exact ordering so bare ctrl+w isn''t swallowed when no second chord arrives.
- No ctrl+w mapping to the middle/workspace panel though panel.focus.middle (ctrl+2) exists intentional for the 3-column model, or add `ctrl+w k`? (j is taken by dock.toggle.)
- Should the family also fire in vim.insert (it shouldn''t ctrl chords pass through there); does guarding on vim.normal||vim.visual leave insert alone correctly?', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:11', '2026-06-13 19:55:21', NULL, '210abd8257ebd45e153f8fa8329620fa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'bug', NULL, 'Git branch in status bar bleeds across parallel windows (cross-window IPC/bus fencing gap)', NULL, 'backlog', 'high', NULL, NULL, 'D-70', '2026-06-14 15:29:23', '2026-06-14 15:29:23', NULL, '98af6c651543b8584649fbc4461ddb37', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'bug', NULL, 'Git branch in status bar bleeds across parallel windows (cross-window IPC/bus fencing gap)', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).', 'backlog', 'high', NULL, NULL, 'D-70', '2026-06-14 15:29:23', '2026-06-14 15:29:27', NULL, '1dc670c4dd0837770970ff5da8c5e464', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'bug', NULL, 'Git branch in status bar bleeds across parallel windows (cross-window IPC/bus fencing gap)', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).
---
**Repro details confirmed (user, 2026-06-14):**
- The two windows were on *different repos* (distinct workspace roots distinct hashed sockets per D-70; rules out same-socket collision).
- The second window was opened from the **File menu at the top**, not from an integrated terminal.
**Refined root-cause analysis (this changes the leading hypothesis).**
The File menu has two distinct paths (`lib/builtin/menubar/src/file_actions.dart`):
- `openFolder()`/`openPath()` (l.23-63) `services.project.open()` = *in-place* switch, same process (the T-269/T-367 class). Produces ONE window, so not this report.
- `newWindow()` (l.30-32) `Process.start(Platform.resolvedExecutable, const [], mode: ProcessStartMode.detached)` = a genuinely **separate detached process**. This matches the "parallel windows" symptom.
Two facts narrow it:
1. `CLIDE_SOCK`/`CLIDE_WORKSPACE` are NOT set in clide''s own process environment they are a delta overlaid only on spawned Claude/PTY *child* processes (`lib/builtin/claude/src/agent_bootstrap.dart:57-71`, "Process.start keeps the parent environment by default, so this returns only the keys to add/override"). So a clean dock-launched window has no CLIDE_SOCK to leak.
2. `newWindow()` passes **no `environment:` override**, so the detached child inherits the parent clide process''s full environment verbatim.
**Leading hypothesis now:** environment inheritance through `newWindow()` when clide is self-hosted. If window 1 was itself launched from a clide-hosted terminal or as a clide agent, window 1''s process env already carries *that host''s* `CLIDE_SOCK`/`CLIDE_WORKSPACE`. `newWindow()` then spawns window 2 inheriting those vars so any code in window 2 that resolves its IPC endpoint (or shells out to the `clide` CLI, which keys off `CLIDE_SOCK`) can bind to the wrong workspace''s server and surface its branch. This is consistent with: different repos, opened from the File menu, intermittent.
**Caveat / not yet pinned:** the in-app status widget reportedly resolves IPC via the computed `workspaceSocketPath(root)` (`lib/main.dart:357`), NOT via `CLIDE_SOCK` so if that holds, inherited CLIDE_SOCK alone shouldn''t mislead the *in-process* status bar. The exact cross-process channel therefore still needs live confirmation. Do NOT assume; instrument.
**First diagnostic step for the fixer:**
1. Reproduce: open window 1, then File New Window, then open a *different* repo in window 2.
2. Log, in each window at branch-fetch time: the resolved socket path the status client connected to, `Platform.environment[''CLIDE_SOCK'']`, `Platform.environment[''CLIDE_WORKSPACE'']`, and `kernel.project.root`. The window showing the wrong branch will reveal whether it (a) connected to the other window''s socket, (b) read a stale/ambient env var, or (c) received a cross-process event it shouldn''t have.
**Hardening regardless of outcome:** `newWindow()` should spawn the child with an explicit, scrubbed environment strip `CLIDE_SOCK`/`CLIDE_WORKSPACE` (and not rely on inheriting them) so a fresh window always computes its own per-root socket from its own workspace. A new window must never inherit another workspace''s IPC identity.', 'backlog', 'high', NULL, NULL, 'D-70', '2026-06-14 15:29:23', '2026-06-14 15:41:41', NULL, 'a3a009799c3acada9474e9657a2a89c2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDKX4CVHWVGDAJC6X09602M', 'epic', NULL, 'Unify workspace lifecycle on a single fenced open primitive (Q-51)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 15:45:57', '2026-06-14 15:45:57', NULL, 'ca426dc83f5cf2d6d98dd92b79583cc4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDKX4CVHWVGDAJC6X09602M', 'epic', NULL, 'Unify workspace lifecycle on a single fenced open primitive (Q-51)', 'Tracks the architectural unification behind Q-51: replace the scattered, per-entry-point workspace-open logic with a single fenced primitive.
**The problem.** There is no "open workspace X" primitive only two half-primitives in different layers:
- `project.open(root)` (`lib/kernel/src/project.dart:143`) the only repo-targeting path, intrinsically *in-place*: rebuilds services in the same process reusing the shared `daemonBus` (`lib/main.dart:372-376`).
- `newWindow()` (`lib/builtin/menubar/src/file_actions.dart:30-32`) a blank detached `Process.start` with no repo argument and no env scrubbing.
To open a repo in a new window you spawn a blank window and then run the in-place switch inside it. Every fencing bug to date is a spot where one path forgets what the other remembers.
**Symptoms already filed (same root):** T-421 (status-bar branch bleeds across parallel windows), T-367 (in-place switch leaked the previous service set closed), T-269 (kept the previous repo''s Claude session closed).
**Target invariant.** `workspace root socket bus session-id`, one-to-one. Exactly one place derives IPC identity from a root. Every entry point (File menu, project switcher, `clide://` deep link, CLI, recents) routes through `WorkspaceService.open(root, {target: thisWindow | newWindow})`. New-window spawns `Process.start(exe, [''--workspace'', root], environment: <scrubbed>)` — explicit root, no inherited `CLIDE_SOCK`/`CLIDE_WORKSPACE`.
**Open decision (Q-51):** whether in-place switching survives at all, or whether a workspace is always its own window/process. If abolished, the teardown burden that T-367/T-269 patch disappears.
**Acceptance:** Q-51 resolved with a D-record fixing the in-place-vs-window stance; a single workspace-open primitive in place; all entry points routed through it; T-421 no longer reproducible; a regression test that a checkout in one workspace cannot change another''s displayed branch.
See Q-51 (governance/questions/architecture.md), D-70, D-56, D-72.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 15:45:57', '2026-06-14 15:47:08', NULL, '86160ba4a7a094bc54b36f9071c4e561', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDM61KAA3GV3CVTE8PAZ8N0', 'task', '06FCDKX4CVHWVGDAJC6X09602M', 'Build WorkspaceService.open(root, target) — single fenced workspace-open primitive; route all entry points through it', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 15:47:10', '2026-06-14 15:47:10', NULL, 'b1e5651ed4d4f3f9a59f1baa0f3a72a6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCDG3T4A7CYPTG535KVAVH6C', 'bug', '06FCDKX4CVHWVGDAJC6X09602M', 'Git branch in status bar bleeds across parallel windows (cross-window IPC/bus fencing gap)', '**Symptom.** The git branch shown in the status bar (bottom-left, next to the `⎇` glyph) sometimes displays the branch of a *different* open clide workspace/window — it "bleeds" across windows. Intermittent ("at times"). Screenshot on the originating session shows `main` while a sibling window was on another branch.
**User hypothesis.** Lack of fencing in the message bus between multiple parallel open sessions/windows events/state from one window reaching another.
**Why this matters.** Showing the wrong branch in a git-centric IDE is a footgun: the user can believe they are on a branch they are not, and act (commit/checkout) on that false premise. It also *contradicts a documented isolation invariant* see T-269: "Separate clide WINDOWS are isolated (separate process, per-root IPC socket, per-repo deterministic session id), so parallel repos in separate windows are fine." This bug is evidence that invariant is not actually holding for the status-bar branch.
**Investigation (read-only, 2026-06-14).**
- Status-bar branch widget: `lib/builtin/git/src/git_status_item.dart:8-86` subscribes to `kernel.events.on<DaemonEvent>()`, fetches branch via `ipc.request(''git.status'')` (sets `_branch = r.data[''branch'']`), and re-fetches on any `git.changed` event.
- Branch fetch path: `lib/src/git/client.dart:23-65` `lib/src/daemon/git_commands.dart:46-53` (`git.status` handler).
- Event emit: `git_commands.dart:295-296` `_emitChanged()` kernel `DaemonBus`.
- Kernel bus: `lib/kernel/src/events/bus.dart:5-20` is a single unfiltered `StreamController.broadcast()`; on project open the *same* `daemonBus` instance is reused (`lib/main.dart:110-111, 372-376`). No workspace/window id on events; no per-workspace filtering.
- Per-workspace socket IS correct: `lib/src/ipc/paths.dart:13-16` hashes (FNV-1a64) the workspace root distinct socket per root (D-70).
**Two candidate mechanisms fix work must confirm which (they are NOT the same):**
1. *Same-process / in-place bleed* the global `DaemonBus` is shared across dispatchers, so events are not workspace-scoped. This is the in-memory path and overlaps with the now-closed T-367 ("Project switch leaks the entire previous workspace service set"). Only applies if the two surfaces share one process.
2. *Cross-process / true multi-window bleed* separate windows are separate processes (per T-269), so an in-memory bus cannot cross them. A process-crossing path is required: most likely the branch widget resolving its IPC endpoint from an **inherited `CLIDE_SOCK`** (see T-215) instead of recomputing the socket from its own workspace root e.g. window B launched from window A''s integrated terminal inherits A''s `CLIDE_SOCK` and connects to A''s IPC server. Same-root windows sharing one hashed socket is a second possibility.
**Repro info still needed (please confirm):**
- Were the two windows open on the *same* repo or *different* repos?
- Was the second window launched from inside the first window''s integrated terminal (i.e. could it have inherited `CLIDE_SOCK`)?
**Proposed direction.**
- Make the status-bar branch widget resolve its IPC endpoint and filter events strictly by *its own* workspace root, never trusting an ambient/inherited socket.
- Add a workspace/window identity to `DaemonEvent` (or scope the `DaemonBus` per workspace) so events carry provenance and consumers can fence (kernel/src/events/types.dart + bus.dart).
- Add a regression test: two workspace contexts; a `git.changed`/checkout in one must not mutate the other''s displayed branch.
**Related:** T-269 (closed documents the isolation invariant this breaks), T-367 (closed shared-bus/service-set leak on in-place switch), T-215 (CLIDE_SOCK/CLIDE_WORKSPACE export), D-70 (per-workspace socket path).
---
**Repro details confirmed (user, 2026-06-14):**
- The two windows were on *different repos* (distinct workspace roots distinct hashed sockets per D-70; rules out same-socket collision).
- The second window was opened from the **File menu at the top**, not from an integrated terminal.
**Refined root-cause analysis (this changes the leading hypothesis).**
The File menu has two distinct paths (`lib/builtin/menubar/src/file_actions.dart`):
- `openFolder()`/`openPath()` (l.23-63) `services.project.open()` = *in-place* switch, same process (the T-269/T-367 class). Produces ONE window, so not this report.
- `newWindow()` (l.30-32) `Process.start(Platform.resolvedExecutable, const [], mode: ProcessStartMode.detached)` = a genuinely **separate detached process**. This matches the "parallel windows" symptom.
Two facts narrow it:
1. `CLIDE_SOCK`/`CLIDE_WORKSPACE` are NOT set in clide''s own process environment they are a delta overlaid only on spawned Claude/PTY *child* processes (`lib/builtin/claude/src/agent_bootstrap.dart:57-71`, "Process.start keeps the parent environment by default, so this returns only the keys to add/override"). So a clean dock-launched window has no CLIDE_SOCK to leak.
2. `newWindow()` passes **no `environment:` override**, so the detached child inherits the parent clide process''s full environment verbatim.
**Leading hypothesis now:** environment inheritance through `newWindow()` when clide is self-hosted. If window 1 was itself launched from a clide-hosted terminal or as a clide agent, window 1''s process env already carries *that host''s* `CLIDE_SOCK`/`CLIDE_WORKSPACE`. `newWindow()` then spawns window 2 inheriting those vars so any code in window 2 that resolves its IPC endpoint (or shells out to the `clide` CLI, which keys off `CLIDE_SOCK`) can bind to the wrong workspace''s server and surface its branch. This is consistent with: different repos, opened from the File menu, intermittent.
**Caveat / not yet pinned:** the in-app status widget reportedly resolves IPC via the computed `workspaceSocketPath(root)` (`lib/main.dart:357`), NOT via `CLIDE_SOCK` so if that holds, inherited CLIDE_SOCK alone shouldn''t mislead the *in-process* status bar. The exact cross-process channel therefore still needs live confirmation. Do NOT assume; instrument.
**First diagnostic step for the fixer:**
1. Reproduce: open window 1, then File New Window, then open a *different* repo in window 2.
2. Log, in each window at branch-fetch time: the resolved socket path the status client connected to, `Platform.environment[''CLIDE_SOCK'']`, `Platform.environment[''CLIDE_WORKSPACE'']`, and `kernel.project.root`. The window showing the wrong branch will reveal whether it (a) connected to the other window''s socket, (b) read a stale/ambient env var, or (c) received a cross-process event it shouldn''t have.
**Hardening regardless of outcome:** `newWindow()` should spawn the child with an explicit, scrubbed environment strip `CLIDE_SOCK`/`CLIDE_WORKSPACE` (and not rely on inheriting them) so a fresh window always computes its own per-root socket from its own workspace. A new window must never inherit another workspace''s IPC identity.', 'backlog', 'high', NULL, NULL, 'D-70', '2026-06-14 15:29:23', '2026-06-14 15:47:10', NULL, 'b31a1fda9bb8515f031dee671fd43e85', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'bug', NULL, 'ConPTY children leak: place each WindowsPty child in a kill-on-close Job Object', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, 'aebb39f1f9e7ce55bfae7807a997def8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'epic', NULL, 'Crash-survivable logging & observability (FileLogSink, FFI breadcrumbs, watchdog, dev/prod verbosity)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:14:36', NULL, '4f8092cb3ef7981f36e0aae2b4e6f698', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'bug', NULL, 'ConPTY children leak: place each WindowsPty child in a kill-on-close Job Object', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:15:41', NULL, '3535b2ad3cc348e9798fb315ec03cf57', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'epic', NULL, 'Crash-survivable logging & observability (FileLogSink, FFI breadcrumbs, watchdog, dev/prod verbosity)', 'From the Windows test-freeze analysis (2026-06-14): the freeze left NO evidence because every log sink is volatile — stderrSink dies with the console and the in-RAM LogRing dies with the power-cycle. clide already has the logger scaffolding (lib/kernel/src/log.dart: Logger, LogLevel{trace..error}, pluggable LogSink; lib/kernel/src/log_ring.dart; the output dock + Level chip), so this epic does not add a framework — it bolts on a crash-survivable sink, FFI breadcrumbs, a watchdog, and the dev/prod verbosity toggle so the NEXT freeze (Windows or otherwise) leaves on-disk evidence that names the wedged call.
Child work (each filed as a task under this epic):
1. FileLogSink synchronous-fsync JSON-lines sink to %LOCALAPPDATA%\clide\logs (reuse ipc/paths.dart socket-dir helper); tiered flush (warn/error + any pty/ffi record flush immediately, info/debug batch on a timer); first sink in the chain so a crash cannot lose the tail; size-capped with rotation.
2. FFI breadcrumbs in windows_pty.dart inject a no-op-by-default log callback; emit BEFORE/AFTER every risky Win32 call with the return value + GetLastError read immediately; the reader/waiter SPAWNED isolates each open their OWN append handle to the log file and flushSync per breadcrumb, so the wedged isolate''s last line survives a frozen main isolate.
3. Watchdog heartbeat + resource sampler a DEDICATED isolate (NOT a main-isolate Timer, which would freeze with it) appending+fsyncing a heartbeat every ~500ms and sampling live ConPTY child count / process handle count / thread count / memory load every ~2s. A monotonically climbing child count is the leak signature; the last heartbeat bounds the freeze window to ~500ms.
4. Dev/prod verbosity toggle (the requested switch) resolve Logger.minLevel once at boot: CLIDE_LOG dart-define -> CLIDE_LOG env var -> settings.json log.level -> default warn (release) / info (debug). Level also gates FileLogSink flush-eagerness (debug = lose nothing in a repro). Live changes via a /loglevel command + `clide log level <level>` CLI (D-6 parity); the output-dock Level chip is the in-UI affordance.
5. Wire into the testmode harness + ci/test.sh attach FileLogSink in lib/test_app.dart with per-test start/end breadcrumbs; export CLIDE_LOG=debug and a log dir OUTSIDE the build tree in ci/test.sh; upload that dir as a CI artifact in an always() step so a CI freeze leaves evidence.
Verification kit for the leak this telemetry is meant to catch: tools/windows-verify/.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:15:42', NULL, '9b7c6b83ebe8d600f59ea5eee6431bee', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink: crash-survivable fsync JSON-lines disk sink', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:42', '2026-06-14 18:15:42', NULL, '2313f7e48e20d169b2acf709a5bd5642', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs around every Win32 call in windows_pty.dart (per-isolate append handles)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:43', '2026-06-14 18:15:43', NULL, '1e68b3f5086ec0825e9d58d6630735e2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource-sampler isolate', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:44', '2026-06-14 18:15:44', NULL, '29877b59225c360ce771d026cdefecba', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Dev/prod log verbosity toggle: CLIDE_LOG, settings.json, /loglevel, clide log level CLI', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:45', '2026-06-14 18:15:45', NULL, '545a96116e3b1f2807a8baf888828760', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire crash-survivable logging into testmode harness + ci/test.sh CI artifact', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-14 18:15:46', '2026-06-14 18:15:46', NULL, 'faa3d5305686482bd7390a4d30a52fed', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'bug', NULL, 'ConPTY children leak: place each WindowsPty child in a kill-on-close Job Object', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-14 18:55:21', NULL, 'ef69bc22ce4d3a550b305cca44a43824', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCGJ30V24BJB001GZCR5QKTC', 'task', NULL, 'Extract pure PTY logic from FFI methods into Linux-testable helpers; shrink coverage-ignore to raw syscalls', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-14 22:37:27', '2026-06-14 22:37:27', NULL, 'b5f6cb297222325f80e292f780926a24', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCGJ30V24BJB001GZCR5QKTC', 'task', NULL, 'Extract pure PTY logic from FFI methods into Linux-testable helpers; shrink coverage-ignore to raw syscalls', 'From the PTY testability audit (2026-06-14), prompted by the question "is any of the Windows FFI code testable on a pure I/O basis without Windows, and vice-versa?" Answer: yes on both backends, but the pure fragments are entangled with the syscall layer and need extraction before they can be unit-tested. Each claim below was adversarially verified (default-reject; rejected the rest of the proposed fragments because their output IS a syscall return, e.g. GetLastError, WriteFile byte count).
Context: lib/src/pty/windows_pty.dart wraps its FFI span in `// coverage:ignore-start/end` (Linux runner has no kernel32; the syscall sites are genuinely uncoverable off-Windows, and the bindings resolve through one DynamicLibrary.open so a method touching a binding can''t be entered on Linux). That exclusion is correct for the gate, but it hides a few pure transforms at file granularity. native_pty.dart (POSIX) has the mirror problem: it runs on Linux at ~78.9% but its pure marshalling is only covered incidentally by real spawns, never unit-tested.
## Windows (windows_pty.dart) extract + unit-test on Linux
Confirmed pure (verifier-approved), currently untested:
- `_Coord` struct packing (69-74) two clamped int16s into COORD; allocate via calloc, set x/y, read back.
- `_StartupInfoExW` field assembly in start() (358-361) cb / dwFlags=STARTF_USESTDHANDLES / lpAttributeList; deterministic field writes over calloc-zeroed memory.
- `write()` empty/length guard (497-498) returns 0 when `_dead` or `bytes.isEmpty`, before any WriteFile.
Plan: pull the COORD/STARTUPINFOEXW packing into free functions (e.g. `packCoord(cols, rows)`, `buildStartupInfoEx(attrList)`) that take/return plain values and don''t reference the kernel32 bindings; assert field layout in a Linux unit test. Keep the empty-guard logic in a tiny pure predicate.
## POSIX (native_pty.dart) extract + unit-test directly (closes part of the 21% gap, adds gate margin)
Confirmed pure (verifier-approved), currently only covered incidentally by integration spawns:
- argv marshalling (222-228) String list -> native UTF8 pointer array + null terminator.
- envp marshalling (230-235) Map<String,String> -> native ''KEY=VALUE'' UTF8 array.
- write() buffer copy (406-408) bytes[i] -> buf[i].
- resize() Winsize init + clamp (432-435) cols/rows -> ws.wsCol/wsRow (clamp already tested in pty_size_test).
Plan: extract marshalling into free helpers returning the pointer structures (inject the allocator so a test can read them back and free them); unit-test the round-trip and null-termination off any real spawn.
## NOT in scope (genuinely host-bound leave excluded/uncovered)
All the raw syscalls and anything whose output is a syscall return or that has no injection seam: CreatePipe / CreatePseudoConsole / CreateProcessW / ReadFile / WaitForSingleObject / WriteFile / ResizePseudoConsole / TerminateProcess / CloseHandle / GetLastError; the attribute-list APIs; the read/wait isolate bodies; and on POSIX the openpt/grantpt/unlockpt/ptsname + posix_spawn failure paths, EINTR/EBADF/EPIPE handling, and reader-isolate EOF reaping (~32 lines that need real OS error/timing state).
## Acceptance
- New Linux unit tests for the fragments above (both backends).
- windows_pty.dart `coverage:ignore` span narrowed to only the syscall sites (struct-packing helpers move out and are measured).
- Coverage floor holds (or ratchets up from the added native_pty coverage).
## Related / separate finding (file or fold as decided)
windows.yml runs the real ConPTY suite (start/write/resize/kill/errors) on windows-latest but collects NO coverage (no --coverage flag). So the FFI spawn path has functional validation on Windows + the VM soak (tools/windows-verify/) but no line-coverage metric anywhere. Decide whether to (a) accept functional-only validation explicitly, or (b) collect coverage on the Windows runner and merge it so the FFI path is measured. Cross-platform lcov merge is non-trivial (the gate reads one file) — may warrant a Q-record.
Audit detail: full per-fragment findings + adversarial verdicts in the workflow result for run wf_a3cacb2c-2c7.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-14 22:37:27', '2026-06-14 22:38:01', NULL, '43890d3049d818cea0acd681a191bc94', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXW1VF7VXQX64982X171R', 'bug', NULL, 'ConPTY children leak: place each WindowsPty child in a kill-on-close Job Object', 'Rank-1 culprit from the Windows test-freeze analysis (2026-06-14). On Windows, each WindowsPty.start() (lib/src/pty/windows_pty.dart) pairs the child with its own conhost.exe/OpenConsole.exe. TerminateProcess + ClosePseudoConsole do NOT reliably reap that conhost — it lingers until the PARENT process exits (microsoft/terminal#4050) — and the child is not placed in a Windows Job Object, so dart:io children survive parent death (dart-lang/sdk#49234). Across repeated test runs the orphaned conhost/cmd processes accumulate at the session level and are a credible bridge to the whole-OS resource starvation that ends in a hard power-cycle (cf. anthropics/claude-code#63043: ~1088 leaked conhost.exe).
Fix: create a Windows Job Object per WindowsPty (CreateJobObject) with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject(job, hProcess) immediately after CreateProcessW; close the job handle on teardown so the child AND its conhost die when the session (or the test process) exits. Add a tearDownAll backstop in test/pty/windows_pty_test.dart that sweeps stray cmd.exe/conhost.exe children of the test pid.
Verify with tools/windows-verify/soak-conpty.ps1 the orphaned ConPTY-host count must stop climbing across iterations.
Sibling ConPTY-teardown fixes surfaced by the same analysis (fold in here or file separately): CancelIoEx/overlapped reader instead of Isolate.kill (the reader isolate blocks forever in ReadFile and Isolate.kill cannot interrupt FFI dart-lang/sdk#46680); clamp cols/rows >= 2 before CreatePseudoConsole/ResizePseudoConsole (microsoft/terminal#19922 narrow-terminal CRLF spin); fix close()/_closeConsole() teardown order (terminate -> drain to EOF -> ClosePseudoConsole -> release); add --timeout to the dart-test pty line in ci/test.sh.
Progress (commit 606d3df, pre-VM hardening): two sibling quick-wins landed on the branch cols/rows clamped to >= 2 in both PTY backends (lib/src/pty/pty_size.dart; microsoft/terminal#19922) and --timeout 60s on the dart-test pty line in ci/test.sh. Also made windows_pty.dart''s pure helpers (quoteArg / composeEnvironmentBlock / resolveExecutable) public + unit-tested off-Windows.
Still open and VM-gated (new/changed FFI, can''t validate off-Windows): the Job Object reaping (this ticket''s core), CancelIoEx/overlapped reader, and the close()/_closeConsole() teardown reorder. Do these in the Windows VM session and validate each with tools/windows-verify/soak-conpty.ps1 (orphan host count must go flat).
## Soak results on GitHub windows-latest (Server 2022) orphan-accumulation NOT reproduced (2026-06-14)
Ran both halves of the windows-verify soak on GitHub-hosted Windows (no VM needed windows-latest runs the ConPTY suite green, so the soak just wraps it):
1. Clean-path soak (soak-conpty.ps1, 25 iters): orphans stayed at 0, dart handles flat ~152, threads flat at 7. Orderly close() reaps everything. NOT REPRODUCED.
2. Abrupt-death probe (soak-conpty-kill.ps1 + conpty_orphan_probe.dart, 15 iters x 2 PTYs): start real WindowsPty sessions on long-lived children, block WITHOUT close(), then taskkill /F the parent dart.exe (no /T). Every cycle reaped to baseline survivors=0, cum=0. When the parent dies the OS breaks the pipes and conhost exits on its own. NOT REPRODUCED.
**Implication:** the conhost-orphan-accumulation mechanism this ticket is premised on does NOT hold on Server 2022, under clean OR abrupt teardown. The Job Object fix may still be worthwhile as defense-in-depth, but its justification (a reproduced leak) is not confirmed.
**Caveats / what''s still untested:**
- OS mismatch: the real crashes were on desktop Win10/11; this is headless Server 2022. terminal#4050 was a desktop report. A desktop-specific behavior may be unreproducible on CI.
- Both probes let the process DIE, so within-process accumulation (culprit #2: reader isolates blocked forever in ReadFile, threads/handles climbing within one long-lived process) is reclaimed at exit and never measured. A long-lived-process probe (one dart.exe spawning + abandoning PTYs, watching its OWN handle/thread count climb) would test that the more likely freeze mode for a long-running app. Not yet built.
Diagnostics live in tools/windows-verify/ and run via .github/workflows/windows-soak.yml (workflow_dispatch). The same kill-probe will validate the fix if/when it lands (survivors should stay 0 though they already do, which is the problem).', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-15 07:11:41', NULL, '7103394bfdac9685026b652023a7569e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink + logDirectory + boot-time verbosity resolver (CLIDE_LOG → env → setting → release/debug default)', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 07:18:58', '2026-06-15 07:18:58', NULL, '7f08c492c5eb3f6642719f0b6990f7cd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9F446MZFXVHH65Q6CKTPM', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Live verbosity toggle: clide log level CLI + /loglevel command + sync output-dock Level chip to kernel Logger + persist app.log.level', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:01', '2026-06-15 07:19:01', NULL, '7801e4323815cf95bdc78fa005611cf0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs in windows_pty.dart (+native_pty): injectable log callback, before/after each risky syscall with return + GetLastError; reader/waiter isolates flushSync their own append handle', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-06-15 07:19:04', '2026-06-15 07:19:04', NULL, 'd796d50acb2751cc8732a20d3805a77f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource sampler in a dedicated isolate (heartbeat ~500ms; sample ConPTY child / handle / thread / memory ~2s)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:08', '2026-06-15 07:19:08', NULL, 'a51aac4a65ec7589c8181655aeeb73e9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire FileLogSink into test harness + ci/test.sh (CLIDE_LOG=debug, log dir outside build tree, upload as CI artifact in always() step)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:11', '2026-06-15 07:19:11', NULL, '1c35b3eea276c8d7d442e8b09f2c7ad8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink + logDirectory + boot-time verbosity resolver (CLIDE_LOG → env → setting → release/debug default)', NULL, 'in_progress', 'high', NULL, NULL, NULL, '2026-06-15 07:18:58', '2026-06-15 07:19:13', NULL, 'f0ac1b130b28ff2e01db483a5fa47ab7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9ER04JVFW8CN3JW1AWYA8', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink + logDirectory + boot-time verbosity resolver (CLIDE_LOG → env → setting → release/debug default)', NULL, 'done', 'high', NULL, NULL, NULL, '2026-06-15 07:18:58', '2026-06-15 07:29:45', NULL, '2a0f0ae1b6fad09cdbf46e9469be0ec9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FHC8VX50759X35VNER1R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs in windows_pty.dart (+native_pty): injectable log callback, before/after each risky syscall with return + GetLastError; reader/waiter isolates flushSync their own append handle', NULL, 'done', 'high', NULL, NULL, NULL, '2026-06-15 07:19:04', '2026-06-15 07:56:56', NULL, '8c16b539c378e46174b52197a1a26bf0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9FYDEXCM15FXTER032K84', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource sampler in a dedicated isolate (heartbeat ~500ms; sample ConPTY child / handle / thread / memory ~2s)', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:08', '2026-06-15 08:15:52', NULL, 'c72bedb8cc95ae2232767537f7832087', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire FileLogSink into test harness + ci/test.sh (CLIDE_LOG=debug, log dir outside build tree, upload as CI artifact in always() step)', NULL, 'in_progress', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:11', '2026-06-15 08:49:49', NULL, '21a64df388e4f8de2ed217fe93b29207', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9GAQ2G0KCVMZS67SK3324', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire FileLogSink into test harness + ci/test.sh (CLIDE_LOG=debug, log dir outside build tree, upload as CI artifact in always() step)', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:11', '2026-06-15 08:58:55', NULL, 'fccce682fade959d7f1e6063c7b760e4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCM9F446MZFXVHH65Q6CKTPM', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Live verbosity toggle: clide log level CLI + /loglevel command + sync output-dock Level chip to kernel Logger + persist app.log.level', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-15 07:19:01', '2026-06-15 10:33:01', NULL, 'b06c2bea9e87240bf8c61d116383588a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink: crash-survivable fsync JSON-lines disk sink', 'Duplicate of the T-425 breakdown — I re-filed this as T-432 (FileLogSink) and implemented + closed that. Cancelling as duplicate; work is done.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:42', '2026-06-15 10:34:06', NULL, '1875bab57b32f5894fe7ed170079b3c6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs around every Win32 call in windows_pty.dart (per-isolate append handles)', 'Duplicate — re-filed + implemented + closed as T-434 (FFI breadcrumbs). Cancelling as duplicate; work is done.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:43', '2026-06-15 10:34:12', NULL, '03387ed3cdb73f0236bd1f059d51c8b6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource-sampler isolate', 'Duplicate — re-filed + implemented + closed as T-435 (watchdog). Cancelling as duplicate; work is done.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:44', '2026-06-15 10:34:16', NULL, 'c7bb0abc8e7f23d3f7d6ae96048154d6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Dev/prod log verbosity toggle: CLIDE_LOG, settings.json, /loglevel, clide log level CLI', 'Duplicate — re-filed + implemented + closed as T-433 (verbosity toggle: dock chip + clide log level CLI). Cancelling as duplicate; work is done.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-14 18:15:45', '2026-06-15 10:34:20', NULL, '551e05509fc447a2c09c659933cd0a9b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire crash-survivable logging into testmode harness + ci/test.sh CI artifact', 'Duplicate — re-filed + implemented + closed as T-436 (CI crash-evidence artifacts). Cancelling as duplicate; work is done.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-14 18:15:46', '2026-06-15 10:34:25', NULL, '59c1410dae4995e6c7c479775548d0ca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP642C8ZZ1T20RXQQ3143M', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FFI breadcrumbs around every Win32 call in windows_pty.dart (per-isolate append handles)', 'Duplicate — re-filed + implemented + closed as T-434 (FFI breadcrumbs). Cancelling as duplicate; work is done.', 'cancelled', 'high', NULL, NULL, NULL, '2026-06-14 18:15:43', '2026-06-15 10:34:28', NULL, '06f807d2c4b08f10c32489d90b7d8886', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP67ZHMBFW0GRH9JKDMQ7R', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Watchdog heartbeat + resource-sampler isolate', 'Duplicate — re-filed + implemented + closed as T-435 (watchdog). Cancelling as duplicate; work is done.', 'cancelled', 'high', NULL, NULL, NULL, '2026-06-14 18:15:44', '2026-06-15 10:34:28', NULL, '5a716101aced1d744f80079918c09b83', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6BDBHGMK9VCRV6JQ00TW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Dev/prod log verbosity toggle: CLIDE_LOG, settings.json, /loglevel, clide log level CLI', 'Duplicate — re-filed + implemented + closed as T-433 (verbosity toggle: dock chip + clide log level CLI). Cancelling as duplicate; work is done.', 'cancelled', 'high', NULL, NULL, NULL, '2026-06-14 18:15:45', '2026-06-15 10:34:28', NULL, 'cb5429b84bf6ea732c4cc9eea0e0f79a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP60AS6AF654SWA189A5ZR', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'FileLogSink: crash-survivable fsync JSON-lines disk sink', 'Duplicate of the T-425 breakdown — I re-filed this as T-432 (FileLogSink) and implemented + closed that. Cancelling as duplicate; work is done.', 'cancelled', 'high', NULL, NULL, NULL, '2026-06-14 18:15:42', '2026-06-15 10:34:28', NULL, 'd6929a4bf9421e939e1b195b7f4047b9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCEP6EVN9S35T02MHA2AS7YW', 'task', '06FCENXXZFBZ0HVD1VCW4ZASCC', 'Wire crash-survivable logging into testmode harness + ci/test.sh CI artifact', 'Duplicate — re-filed + implemented + closed as T-436 (CI crash-evidence artifacts). Cancelling as duplicate; work is done.', 'cancelled', 'medium', NULL, NULL, NULL, '2026-06-14 18:15:46', '2026-06-15 10:34:28', NULL, 'ff037583f22cbef60a5b23c201c2b7ae', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FCENXXZFBZ0HVD1VCW4ZASCC', 'epic', NULL, 'Crash-survivable logging & observability (FileLogSink, FFI breadcrumbs, watchdog, dev/prod verbosity)', 'From the Windows test-freeze analysis (2026-06-14): the freeze left NO evidence because every log sink is volatile — stderrSink dies with the console and the in-RAM LogRing dies with the power-cycle. clide already has the logger scaffolding (lib/kernel/src/log.dart: Logger, LogLevel{trace..error}, pluggable LogSink; lib/kernel/src/log_ring.dart; the output dock + Level chip), so this epic does not add a framework — it bolts on a crash-survivable sink, FFI breadcrumbs, a watchdog, and the dev/prod verbosity toggle so the NEXT freeze (Windows or otherwise) leaves on-disk evidence that names the wedged call.
Child work (each filed as a task under this epic):
1. FileLogSink synchronous-fsync JSON-lines sink to %LOCALAPPDATA%\clide\logs (reuse ipc/paths.dart socket-dir helper); tiered flush (warn/error + any pty/ffi record flush immediately, info/debug batch on a timer); first sink in the chain so a crash cannot lose the tail; size-capped with rotation.
2. FFI breadcrumbs in windows_pty.dart inject a no-op-by-default log callback; emit BEFORE/AFTER every risky Win32 call with the return value + GetLastError read immediately; the reader/waiter SPAWNED isolates each open their OWN append handle to the log file and flushSync per breadcrumb, so the wedged isolate''s last line survives a frozen main isolate.
3. Watchdog heartbeat + resource sampler a DEDICATED isolate (NOT a main-isolate Timer, which would freeze with it) appending+fsyncing a heartbeat every ~500ms and sampling live ConPTY child count / process handle count / thread count / memory load every ~2s. A monotonically climbing child count is the leak signature; the last heartbeat bounds the freeze window to ~500ms.
4. Dev/prod verbosity toggle (the requested switch) resolve Logger.minLevel once at boot: CLIDE_LOG dart-define -> CLIDE_LOG env var -> settings.json log.level -> default warn (release) / info (debug). Level also gates FileLogSink flush-eagerness (debug = lose nothing in a repro). Live changes via a /loglevel command + `clide log level <level>` CLI (D-6 parity); the output-dock Level chip is the in-UI affordance.
5. Wire into the testmode harness + ci/test.sh attach FileLogSink in lib/test_app.dart with per-test start/end breadcrumbs; export CLIDE_LOG=debug and a log dir OUTSIDE the build tree in ci/test.sh; upload that dir as a CI artifact in an always() step so a CI freeze leaves evidence.
Verification kit for the leak this telemetry is meant to catch: tools/windows-verify/.', 'done', 'high', NULL, NULL, NULL, '2026-06-14 18:14:36', '2026-06-15 10:34:31', NULL, 'ecac06a5aebea9665c50a2cacaceb825', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+55
View File
@@ -18,6 +18,49 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **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)
## [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
@@ -51,6 +94,18 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
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
+1 -1
View File
@@ -9,7 +9,7 @@ An IDE for Claude Code CLI. Single Flutter package at the repo root.
- **`lib/`** — all Dart code. Subsystem handlers (`lib/src/daemon/`, `lib/src/pty/`, `lib/src/ipc/`, `lib/src/git/`, `lib/src/pql/`), kernel services (`lib/kernel/`), UI widgets (`lib/widgets/`), built-in extensions (`lib/builtin/`), and the extension framework (`lib/extension/`). The Flutter app hosts the IPC server in-process (D-56). PTY spawning uses Dart FFI `posix_openpt()` + `posix_spawn()` directly.
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
tmux owns Claude session persistence (D-41) — the app re-attaches on restart via `tmux new-session -A`. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
Claude session persistence is `--resume <session-id>` against Claude Code's transcript files (D-77, superseding the original tmux-backed D-41) — the app re-attaches on restart, no tmux required. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`governance/`](governance/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`governance/README.md`](governance/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
+17 -2
View File
@@ -148,7 +148,7 @@ changelog-gate: ## Changelog concision gate — fails on `## [Unreleased]` bulle
ci/changelog_gate.sh
.PHONY: smoke-bundle
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
smoke-bundle: gen-build-info ## Build Linux release bundle and run it under xvfb for 5s.
ci/smoke_bundle.sh
# -- web UI harness ------------------------------------------------------
@@ -182,6 +182,10 @@ build-linux: gen-build-info ## flutter build linux (desktop bundle).
build-macos: gen-build-info ## flutter build macos (desktop bundle).
flutter build macos
.PHONY: build-windows
build-windows: gen-build-info ## flutter build windows (desktop bundle).
flutter build windows
# -- install / uninstall -----------------------------------------------------
# Install prefix. Bundle lands at $(INSTALL_PREFIX)/clide/ with a
@@ -196,6 +200,9 @@ ifeq ($(FLUTTER_OS),linux)
else ifeq ($(FLUTTER_OS),macos)
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli
else ifeq ($(FLUTTER_OS),windows)
BUNDLE_DIR := build/windows/x64/runner/Release
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/clide-cli.exe
endif
ICON_SIZES := 16 32 48 128 192 256 512
@@ -291,7 +298,11 @@ dugite-clean: ## Remove the dugite-native directory.
# target picks up whatever `cc` is on PATH.
CLIDE_CLI_SRC := native/clide-cli/clide.c
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
ifeq ($(FLUTTER_OS),windows)
CLIDE_CLI_BIN := native/windows-x64/clide.exe
else
CLIDE_CLI_BIN := native/$(if $(filter Darwin,$(shell uname -s)),macos,linux)-$(shell uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/')/clide
endif
CC ?= cc
.PHONY: clide-cli
@@ -299,7 +310,11 @@ clide-cli: $(CLIDE_CLI_BIN) ## Compile the C `clide` shell client.
$(CLIDE_CLI_BIN): $(CLIDE_CLI_SRC)
@mkdir -p $(dir $(CLIDE_CLI_BIN))
ifeq ($(FLUTTER_OS),windows)
ci/build_cli_windows.sh
else
$(CC) -std=c99 -O2 -Wall -Wextra -o $(CLIDE_CLI_BIN) $(CLIDE_CLI_SRC)
endif
@echo "==> built $(CLIDE_CLI_BIN)"
.PHONY: clide-cli-clean
+65
View File
@@ -63,6 +63,71 @@ bindings:
- intent: text.scaleReset
keys: [ctrl+0, meta+0]
# ---- Pane navigation (non-editor panes) ------------------------------
# When a non-editor pane holds focus (file tree, conversation, lists), the
# same motion keys mean NAVIGATION, not buffer edits (T-406). The
# `!editor.focused` guard keeps these out of the editor's way; the editor
# publishes `editor.focused` while it has focus. These MUST precede the
# editor motions below — the resolver takes the first matching binding in
# file order, so with a pane focused (editor.focused false) nav wins, and
# with the editor focused the `!editor.focused` clause fails and the buffer
# motion below wins. Each pane runs its own SequenceMatcher (PaneKeyNav).
- intent: nav.down
keys: j
when: "vim.normal && !editor.focused"
- intent: nav.up
keys: k
when: "vim.normal && !editor.focused"
- intent: nav.pageDown
keys: ctrl+d
when: "vim.normal && !editor.focused"
- intent: nav.pageUp
keys: ctrl+u
when: "vim.normal && !editor.focused"
- intent: nav.top
keys: "g g" # gg
when: "vim.normal && !editor.focused"
- intent: nav.bottom
keys: shift+g # G
when: "vim.normal && !editor.focused"
- intent: nav.expandOrRight
keys: l
when: "vim.normal && !editor.focused"
- intent: nav.collapseOrLeft
keys: h
when: "vim.normal && !editor.focused"
- intent: nav.activate
keys: [o, enter]
when: "vim.normal && !editor.focused"
# ---- ctrl+w window-command family (T-404) ----------------------------
# Multi-chord sequences resolved by the GLOBAL matcher (root_shell), so they
# work from any focus. Bare ctrl+w still closes the editor after the ambiguity
# timeout (the editor.close binding below / contributions layer). The 3-column
# clide layout approximates vim's window grid: h/l focus left/right panels,
# j toggles the dock, o is "only" (focus mode), q/c close the editor.
- intent: command:panel.focus.left
keys: ctrl+w h
when: "vim.normal || vim.visual"
- intent: command:panel.focus.right
keys: ctrl+w l
when: "vim.normal || vim.visual"
- intent: command:dock.toggle
keys: ctrl+w j
when: "vim.normal || vim.visual"
- intent: focus.nextPanel
keys: [ctrl+w w, ctrl+w ctrl+w]
when: "vim.normal || vim.visual"
- intent: focus.previousPanel
keys: ctrl+w shift+w # ctrl+w W
when: "vim.normal || vim.visual"
- intent: command:panel.focusMode
keys: ctrl+w o
when: "vim.normal || vim.visual"
- intent: command:editor.close
keys: [ctrl+w q, ctrl+w c]
when: "vim.normal || vim.visual"
# ---- Mode transitions ------------------------------------------------
- intent: command:vim.mode.visual
keys: v
+1 -1
View File
@@ -39,7 +39,7 @@ self:
# Auto-synced from pubspec.yaml `version:` by `make gen-build-info`
# (runs implicitly on every build/run/test). Don't hand-edit; bump
# pubspec instead.
version: "2.4.1"
version: "2.5.0"
homepage: https://github.com/postmeridiem/clide
license: MIT
license_file: assets/LICENSE
+33
View File
@@ -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
View File
@@ -35,7 +35,12 @@ echo "==> dart test (pty — unreliable under the flutter test runner; serial)"
# --concurrency=1: these spawn real PTYs and compete for fds when run in
# parallel, which flaked them (registry/session). Serialize — the proper fix
# for resource-bound tests, vs. the old per-test `retry:` band-aid. (T-193)
dart test -r "$REPORTER" --concurrency=1 --tags pty test/pty/session_test.dart test/panes/registry_test.dart
# windows_pty_test is the ConPTY sibling of session_test; each suite
# self-skips off-platform, so the union always contributes tests.
# --timeout 60s matches the flutter lines below: a wedged PTY test (e.g. a
# ConPTY reader blocked forever in ReadFile) fails fast instead of hanging the
# whole serial run.
dart test -r "$REPORTER" --concurrency=1 --timeout 60s --tags pty test/pty/session_test.dart test/panes/registry_test.dart test/pty/windows_pty_test.dart
# The parallel pool excludes both pty (runs under dart test, above) and
# serial-tagged tests (concurrency-vulnerable — run in their own --concurrency=1
+7 -1
View File
@@ -3,10 +3,16 @@
# start" regression gate. Flutter integration tests prefer one file at
# a time on desktop; we iterate to avoid the "Unable to start the app"
# error that hits when they run as a batch.
#
# -d linux pins the desktop device explicitly: the GitHub ubuntu-latest
# runner exposes BOTH a linux desktop AND a chrome web device, so a bare
# `flutter test integration_test/...` aborts with "More than one device
# connected" before it ever compiles (the dev box / old Gitea runner only
# had the one device, so this was latent until CI moved to GitHub).
set -euo pipefail
cd "$(dirname "$0")/.."
for f in integration_test/*_test.dart; do
echo "==> integration_test: $f"
flutter test "$f"
flutter test -d linux "$f"
done
+17
View File
@@ -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
+1
View File
@@ -183,6 +183,7 @@ You might also want, project-permitting:
- [Q-48: Sealed-workspace mode — implement?](questions/design.md#q-48-sealed-workspace-mode--implement) — _design_
- [Q-49: Review honorable mentions — which, if any, get promoted?](questions/design.md#q-49-review-honorable-mentions--which-if-any-get-promoted) — _design_
- [Q-50: Web/WASM target after the dart:ffi pivot — fence, fix, or drop?](questions/architecture.md#q-50-webwasm-target-after-the-dartffi-pivot--fence-fix-or-drop) — _architecture_
- [Q-51: Unify workspace lifecycle on a single fenced open primitive](questions/architecture.md#q-51-unify-workspace-lifecycle-on-a-single-fenced-open-primitive) — _architecture_
## Resolved questions
+6
View File
@@ -165,4 +165,10 @@ ticket persistence.
- **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.
---
+1 -1
View File
@@ -7,7 +7,7 @@
/// This file is pure (no Flutter): it turns a flat [ConversationItem] list
/// into a list of [RenderGroup]s — each either a first-class [StickyItem] or
/// a foldable [FoldedCluster]. The widget layer renders sticky items as
/// before and clusters as one [activity card]. Kept separate + unit-tested
/// before and clusters as one `activity card`. Kept separate + unit-tested
/// because the fold rules are the load-bearing part.
library;
@@ -257,6 +257,7 @@ class ClaudeConfig extends ChangeNotifier {
if (probe == null) return; // stay on the static fallback
_probe = probe;
await _writeProbeCache(probe);
if (_disposed) return; // a slow probe racing a teardown mustn't notify a disposed notifier
notifyListeners();
} finally {
_probing = false;
@@ -268,6 +269,7 @@ class ClaudeConfig extends ChangeNotifier {
/// not re-resolved (the binary doesn't change under us at runtime).
Future<void> refresh() async {
await _loadDiskConfig();
if (_disposed) return; // a watcher-driven refresh racing a teardown mustn't notify a disposed notifier
notifyListeners();
}
@@ -278,6 +280,7 @@ class ClaudeConfig extends ChangeNotifier {
_stopWatching();
_projectDir = dir;
await _loadDiskConfig();
if (_disposed) return; // a project switch racing a teardown mustn't notify a disposed notifier
_startWatchers();
notifyListeners();
}
+2 -2
View File
@@ -53,8 +53,8 @@ String nextSafePermissionMode(String current) {
}
/// Status-line segments split around the permission-mode badge so the UI can
/// render the mode as an interactive control between them (T-226). [leading]
/// is the model; [trailing] joins context / cost / rate-limit. Either may be
/// render the mode as an interactive control between them (T-226). `leading`
/// is the model; `trailing` joins context / cost / rate-limit. Either may be
/// null when there's nothing to show.
({String? leading, String? trailing}) statusSegmentsAroundMode(SessionStatus s) {
final trailing = [
@@ -28,8 +28,8 @@ class ConversationController extends ChangeNotifier {
}
/// Build a controller fed from the kernel [MessageBus] — it consumes
/// the [ConversationItem]s a [TranscriptPublisher] writes onto
/// [publisher]/[channel]. Decouples the view from the reader so several
/// the [ConversationItem]s a `TranscriptPublisher` writes onto
/// `publisher`/[channel]. Decouples the view from the reader so several
/// panels can render the same conversation (team work, T-139/T-140).
factory ConversationController.fromBus({required MessageBus messages, String channel = ClaudeConversation.leadChannel, Future<void> Function()? onDispose}) {
final stream = messages
+41 -1
View File
@@ -24,6 +24,8 @@ import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/keymap/intents.dart';
import 'package:clide/kernel/src/keymap/pane_key_nav.dart';
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/theme/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
@@ -387,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
return list;
},
);
return ColoredBox(
final body = ColoredBox(
color: tokens.panelBackground,
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized,
);
// Vim nav scrolls the conversation while this region holds focus under the
// vim preset (T-406): j/k by a line, ctrl+d/u by half a viewport, gg/G to
// the ends — G also re-arms follow-tail so new output keeps it pinned.
return PaneKeyNav(onNav: _onNav, child: body);
}
/// One "line" of scroll for j/k — a few text rows' worth.
static const double _lineScroll = 48;
void _onNav(NavIntent intent, int count) {
if (!_scroll.hasClients) return;
final p = _scroll.position;
final half = p.viewportDimension / 2;
switch (intent) {
case NavDownIntent():
_scrollBy(_lineScroll * count);
case NavUpIntent():
_scrollBy(-_lineScroll * count);
case NavPageDownIntent():
_scrollBy(half);
case NavPageUpIntent():
_scrollBy(-half);
case NavTopIntent():
_scroll.jumpTo(0);
_atBottom = false;
case NavBottomIntent():
_scroll.jumpTo(p.maxScrollExtent);
_atBottom = true; // re-arm follow-tail (T-297)
case NavExpandOrRightIntent() || NavCollapseOrLeftIntent() || NavActivateIntent():
break; // a reader pane has no expand/activate semantics
}
}
void _scrollBy(double delta) {
final p = _scroll.position;
final target = (p.pixels + delta).clamp(0.0, p.maxScrollExtent);
_scroll.jumpTo(target);
_atBottom = (p.maxScrollExtent - target) <= _bottomEpsilon;
}
}
@@ -4,7 +4,7 @@
/// process), so to "watch the same output" we open our OWN read-only follower
/// on the file the command tails. This never spawns a process and never
/// touches Claude's command — it just reads the file as it grows, like
/// `tail -f`, and hands new bytes to [onData].
/// `tail -f`, and hands new bytes to `onData`.
///
/// Pure dart:io/dart:async (no Flutter) so it's unit-testable. Polls rather
/// than using a watcher so it works uniformly across platforms and survives
@@ -51,7 +51,7 @@ class AgentRosterRow extends StatefulWidget {
final void Function(String memberName, String text) onInjectSubmit;
final void Function(String memberName) onClose;
/// Called when the badge cycles to a new [mode] string for this member.
/// Called when the badge cycles to a new `mode` string for this member.
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
final void Function(String memberName, String mode) onSetPermissionMode;
+1 -1
View File
@@ -5,7 +5,7 @@
/// Rendered in the composer zone (not inline in the conversation) so
/// interaction and conversation widgets don't mix — the pane swaps it in for
/// the text input while a prompt is open. The decision is returned via
/// [onResolve]; the pane then removes the card.
/// `onResolve`; the pane then removes the card.
///
/// Plain [ClideButton]s (Semantics buttons → keyboard/AT reachable), no
/// hover-revealed chrome that would fight the buttons.
@@ -2,8 +2,8 @@
///
/// A session is a `claude` stream-json process clide spawns and renders; a
/// pane is just a *view* on one. The orchestrator decouples a session's
/// lifecycle from any pane: [spawn] starts and registers it, [show]/[hide]
/// toggle visibility WITHOUT tearing the process down, and [close] kills it.
/// lifecycle from any pane: `spawn` starts and registers it, `show`/`hide`
/// toggle visibility WITHOUT tearing the process down, and `close` kills it.
/// This is the one primitive behind teammate / secondary tab / forked branch
/// (Phase 2): they are all just managed sessions shown as panes.
///
+2 -2
View File
@@ -5,8 +5,8 @@
/// and the full workspace pane read from this one model — they share state,
/// they do NOT each hold their own copy.
///
/// [postAsUser] is the user's write path: it routes by @tag (one agent or
/// broadcast) and, when the interrupt flag is set, calls [interrupt()] on the
/// `postAsUser` is the user's write path: it routes by @tag (one agent or
/// broadcast) and, when the interrupt flag is set, calls `interrupt()` on the
/// target session THEN delivers the message.
///
/// Flutter-free on purpose: this module (like [TeamBroker]) runs under
@@ -2,7 +2,7 @@
///
/// Displays the live broker chat timeline as colour-coded rows and provides a
/// quick @-post composer. Tapping the pop-out icon opens the full chat pane
/// ([claude.team-chat] workspace tab).
/// (`claude.team-chat` workspace tab).
///
/// Both this widget and [TeamChatPane] read from the same [TeamChatModel] —
/// there is one model, two surfaces.
@@ -24,7 +24,7 @@
///
/// # Version drift-guard
/// If the envelope `version` field has an unfamiliar major version the reader
/// warns via [onWarn] (or stderr if omitted) and degrades gracefully — it
/// warns via `onWarn` (or stderr if omitted) and degrades gracefully — it
/// parses whatever it can and skips the rest rather than crashing.
library;
@@ -227,7 +227,7 @@ class TranscriptReader {
/// [pollInterval] controls how often the reader polls for new data and
/// session switches (default 500 ms).
///
/// [onWarn] receives warning messages from the version drift-guard.
/// `onWarn` receives warning messages from the version drift-guard.
/// If omitted, warnings are written to stderr.
TranscriptReader(
this.workspacePath, {
@@ -419,7 +419,7 @@ class TranscriptReader {
}
/// Parse a single JSONL line into its items (forwarding any version
/// warnings to [onWarn]). Public so tests exercise the real parser.
/// warnings to `onWarn`). Public so tests exercise the real parser.
List<ConversationItem> parseLine(String line) {
final parsed = parseTranscriptChunk(line);
for (final w in parsed.warnings) {
@@ -53,6 +53,23 @@ class DefaultLayoutExtension extends ClideExtension {
// Editor split (D-049, D-054)
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
// Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every
// preset; the vim preset additionally binds gt/gT to these (T-405 part 2,
// once a global multi-chord matcher lands — see T-404).
CommandContribution(
id: 'workspace.tab.next',
command: 'workspace.tab.next',
title: 'Next Workspace Tab',
defaultBinding: 'ctrl+pagedown',
run: _nextWorkspaceTab,
),
CommandContribution(
id: 'workspace.tab.previous',
command: 'workspace.tab.previous',
title: 'Previous Workspace Tab',
defaultBinding: 'ctrl+pageup',
run: _prevWorkspaceTab,
),
// Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++)
CommandContribution(
@@ -195,6 +212,27 @@ class DefaultLayoutExtension extends ClideExtension {
return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
}
Future<IpcResponse> _nextWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: true);
Future<IpcResponse> _prevWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: false);
/// Cycle the workspace tab strip with wraparound (T-405). A no-op when there
/// are fewer than two tabs. Activating a tab also focuses the workspace slot
/// so the newly-shown pane takes keyboard focus.
Future<IpcResponse> _cycleWorkspaceTab({required bool forward}) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
final tabs = ctx.panels.tabsFor(Slots.workspace);
if (tabs.length < 2) return IpcResponse.ok(id: '', data: const {'cycled': false});
final active = ctx.panels.activeTabIn(Slots.workspace);
final cur = tabs.indexWhere((t) => t.id == active);
final start = cur < 0 ? 0 : cur;
final next = (start + (forward ? 1 : -1) + tabs.length) % tabs.length;
final nextId = tabs[next].id;
ctx.panels.activateTab(Slots.workspace, nextId);
ctx.focus.setActive(slot: Slots.workspace, contributionId: nextId);
return IpcResponse.ok(id: '', data: {'active': nextId});
}
Future<IpcResponse> _focusRight(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
+9 -1
View File
@@ -13,7 +13,7 @@ import 'vim_edit_ops.dart';
/// Tier-2 editor pane. Shows one tab per open buffer via the shared
/// [MultitabPane] (the same strip the Claude pane uses); the body
/// reflects the daemon's active buffer. The daemon ([EditorRegistry])
/// reflects the daemon's active buffer. The daemon (`EditorRegistry`)
/// is the source of truth for which buffers are open and which is
/// active — the local [MultitabController] is reconciled from it, and
/// tab gestures (select / close) are routed back as `editor.activate`
@@ -54,10 +54,16 @@ class _EditorViewState extends State<EditorView> {
super.initState();
_text = SyntaxTextController(syntax: _syntax);
_focus = FocusNode();
_focus.addListener(_onFocusChanged);
_text.addListener(_onTextChanged);
_tabs.addListener(_onTabsChanged);
}
/// Publish `editor.focused` so non-editor panes can guard their vim nav
/// bindings (`!editor.focused`) — when the editor holds focus, j/k/h/l/gg/G
/// stay buffer motions; when a pane holds focus they become nav (T-406).
void _onFocusChanged() => _keymap?.setScopeFlag('editor.focused', _focus.hasFocus);
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -75,12 +81,14 @@ class _EditorViewState extends State<EditorView> {
void dispose() {
_text.removeListener(_onTextChanged);
_text.dispose();
_focus.removeListener(_onFocusChanged);
_focus.dispose();
_tabs.removeListener(_onTabsChanged);
_tabs.dispose();
_controller?.removeListener(_onControllerChanged);
_controller?.dispose();
_keymap?.removeListener(_onModeChanged);
_keymap?.clearScopeFlag('editor.focused');
super.dispose();
}
@@ -9,11 +9,26 @@
library;
import 'dart:async';
import 'dart:io' show Platform;
import 'package:clide/clide.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/foundation.dart';
/// One row in the flattened, currently-visible tree (T-406). The visible set is
/// a pre-order walk of the root plus the children of every expanded directory —
/// the same order the tree renders — so a selection cursor can move over it with
/// j/k.
@immutable
class TreeNode {
const TreeNode({required this.path, required this.name, required this.isDirectory, required this.depth});
final String path;
final String name;
final bool isDirectory;
final int depth;
}
class FileTreeController extends ChangeNotifier {
FileTreeController({required this.ipc, required this.events}) {
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
@@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier {
final Map<String, List<FileEntry>> _entries = {};
List<FileEntry>? entriesFor(String path) => _entries[path];
/// Display name of the workspace root row ('' path).
String get rootName => _rootPath?.split(Platform.pathSeparator).last ?? '';
// -- Keyboard selection cursor (T-406) -------------------------------------
/// The path of the currently selected row, or null when nothing is selected.
/// '' is the workspace-root row.
String? _selectedPath;
String? get selectedPath => _selectedPath;
/// The flattened, currently-visible rows in render order: the root, then the
/// children of every expanded directory, depth-first.
List<TreeNode> visibleNodes() {
final out = <TreeNode>[];
if (_rootPath == null) return out;
out.add(TreeNode(path: '', name: rootName, isDirectory: true, depth: 0));
if (isExpanded('')) _appendChildren('', 1, out);
return out;
}
void _appendChildren(String path, int depth, List<TreeNode> out) {
final entries = _entries[path];
if (entries == null) return;
for (final e in entries) {
out.add(TreeNode(path: e.path, name: e.name, isDirectory: e.isDirectory, depth: depth));
if (e.isDirectory && _expanded.contains(e.path)) _appendChildren(e.path, depth + 1, out);
}
}
TreeNode? _selectedNode([List<TreeNode>? nodes]) {
final list = nodes ?? visibleNodes();
for (final n in list) {
if (n.path == _selectedPath) return n;
}
return null;
}
/// Move the selection cursor [delta] rows (negative = up), clamped to the
/// visible list. A first move with nothing selected lands on the first row
/// (down) or last row (up).
void moveSelection(int delta) {
final nodes = visibleNodes();
if (nodes.isEmpty) return;
final cur = nodes.indexWhere((n) => n.path == _selectedPath);
final next = cur < 0 ? (delta > 0 ? 0 : nodes.length - 1) : (cur + delta).clamp(0, nodes.length - 1);
if (nodes[next].path == _selectedPath) return;
_selectedPath = nodes[next].path;
notifyListeners();
}
/// Select the first ([top]) or last visible row — vim gg / G.
void selectEdge({required bool top}) {
final nodes = visibleNodes();
if (nodes.isEmpty) return;
final path = (top ? nodes.first : nodes.last).path;
if (path == _selectedPath) return;
_selectedPath = path;
notifyListeners();
}
/// Collapse the selected directory, or — if it's already collapsed (or a
/// file) — step the selection out to its parent row (vim `h`).
Future<void> collapseOrOut() async {
final node = _selectedNode();
if (node == null) return;
if (node.isDirectory && node.path != '' && _expanded.contains(node.path)) {
await toggle(node.path); // collapse in place; selection stays on the dir
return;
}
if (node.path == '') return; // already at root
_selectedPath = _parentOf(node.path);
notifyListeners();
}
/// Expand the selected directory, or — if it's already expanded — step the
/// selection into its first child (vim `l`). A file is a no-op.
Future<void> expandOrInto() async {
final node = _selectedNode();
if (node == null || !node.isDirectory) return;
if (!_expanded.contains(node.path)) {
await toggle(node.path); // expand
return;
}
final children = _entries[node.path];
if (children != null && children.isNotEmpty) {
_selectedPath = children.first.path;
notifyListeners();
}
}
/// Resolve the selected row to an action target for the view: a directory to
/// toggle, or a file path to open (vim `o` / `enter`). Returns null when
/// nothing is selected.
({bool isDirectory, String path})? activateTarget() {
final node = _selectedNode();
if (node == null) return null;
return (isDirectory: node.isDirectory, path: node.path);
}
List<FileEntry> allLoadedEntries() {
final out = <FileEntry>[];
for (final list in _entries.values) {
+101 -23
View File
@@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget {
class _FileTreeViewState extends State<FileTreeView> {
FileTreeController? _controller;
String _filter = '';
final ScrollController _scroll = ScrollController();
/// Key on the currently-selected row, so a keyboard move can scroll it into
/// view (T-406).
final GlobalKey _selectedKey = GlobalKey();
/// Half-page step for ctrl+d / ctrl+u over the flattened tree.
static const int _pageStep = 10;
@override
void didChangeDependencies() {
@@ -39,9 +47,52 @@ class _FileTreeViewState extends State<FileTreeView> {
@override
void dispose() {
_controller?.dispose();
_scroll.dispose();
super.dispose();
}
void _onNav(NavIntent intent, int count, FileTreeController c) {
switch (intent) {
case NavDownIntent():
c.moveSelection(count);
case NavUpIntent():
c.moveSelection(-count);
case NavPageDownIntent():
c.moveSelection(_pageStep);
case NavPageUpIntent():
c.moveSelection(-_pageStep);
case NavTopIntent():
c.selectEdge(top: true);
case NavBottomIntent():
c.selectEdge(top: false);
case NavExpandOrRightIntent():
unawaited(c.expandOrInto());
case NavCollapseOrLeftIntent():
unawaited(c.collapseOrOut());
case NavActivateIntent():
_activateSelected(c);
}
}
void _activateSelected(FileTreeController c) {
final t = c.activateTarget();
if (t == null) return;
if (t.isDirectory) {
unawaited(c.toggle(t.path));
} else {
openWorkspaceFile(ClideKernel.of(context), t.path);
}
}
/// Scroll the selected row into view after the frame it's laid out in.
void _ensureSelectedVisible() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _selectedKey.currentContext;
if (ctx == null) return;
Scrollable.ensureVisible(ctx, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: const Duration(milliseconds: 80));
});
}
@override
Widget build(BuildContext context) {
final c = _controller;
@@ -57,6 +108,23 @@ class _FileTreeViewState extends State<FileTreeView> {
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
}
final rootName = root.split(Platform.pathSeparator).last;
final selected = c.selectedPath;
if (_filter.isEmpty && selected != null) _ensureSelectedVisible();
final scroller = SingleChildScrollView(
controller: _scroll,
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_filter.isEmpty) ...[
_DirRow(name: rootName, path: '', controller: c, depth: 0, selectedPath: selected, selectedKey: _selectedKey),
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1, selectedPath: selected, selectedKey: _selectedKey),
] else
..._filteredEntries(c),
],
),
);
return Column(
children: [
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
@@ -65,20 +133,10 @@ class _FileTreeViewState extends State<FileTreeView> {
label: 'file tree — $rootName',
container: true,
explicitChildNodes: true,
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_filter.isEmpty) ...[
_DirRow(name: rootName, path: '', controller: c, depth: 0),
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
] else
..._filteredEntries(c),
],
),
),
// Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
// region holds focus under the vim preset (T-406). The filter
// box sits outside it, so typing a filter is never intercepted.
child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller,
),
),
],
@@ -97,11 +155,13 @@ class _FileTreeViewState extends State<FileTreeView> {
}
class _Children extends StatelessWidget {
const _Children({required this.path, required this.controller, required this.depth});
const _Children({required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
final String path;
final FileTreeController controller;
final int depth;
final String? selectedPath;
final Key? selectedKey;
@override
Widget build(BuildContext context) {
@@ -117,58 +177,67 @@ class _Children extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
if (controller.isExpanded(e.path))
_Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey),
],
)
else
_FileRow(name: e.name, path: e.path, depth: depth),
_FileRow(name: e.name, path: e.path, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
],
);
}
}
class _DirRow extends StatelessWidget {
const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
const _DirRow({required this.name, required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
final String name;
final String path;
final FileTreeController controller;
final int depth;
final String? selectedPath;
final Key? selectedKey;
@override
Widget build(BuildContext context) {
final expanded = controller.isExpanded(path);
final tokens = ClideTheme.of(context).surface;
final selected = path == selectedPath;
return Semantics(
button: true,
label: '${expanded ? 'Collapse' : 'Expand'} $name',
onTap: () => controller.toggle(path),
child: _Row(
key: selected ? selectedKey : null,
depth: depth,
onTap: () => controller.toggle(path),
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
label: name,
rotateLeading: expanded,
selected: selected,
),
);
}
}
class _FileRow extends StatelessWidget {
const _FileRow({required this.name, required this.path, required this.depth});
const _FileRow({required this.name, required this.path, required this.depth, this.selectedPath, this.selectedKey});
final String name;
final String path;
final int depth;
final String? selectedPath;
final Key? selectedKey;
@override
Widget build(BuildContext context) {
final selected = path == selectedPath;
return Semantics(
button: true,
label: 'Open $name',
onTap: () => _openFile(context, path),
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected),
);
}
@@ -180,7 +249,7 @@ class _FileRow extends StatelessWidget {
}
class _Row extends StatelessWidget {
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
const _Row({super.key, required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false, this.selected = false});
final int depth;
final VoidCallback onTap;
@@ -188,6 +257,10 @@ class _Row extends StatelessWidget {
final Widget? leading;
final bool rotateLeading;
/// True when the keyboard selection cursor is on this row (T-406) — draws a
/// persistent highlight + accent ring, distinct from transient hover.
final bool selected;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
@@ -195,7 +268,12 @@ class _Row extends StatelessWidget {
return ClideTappable(
onTap: onTap,
builder: (context, hovered, _) => Container(
color: hovered ? tokens.sidebarItemHover : null,
decoration: selected
? BoxDecoration(
color: tokens.sidebarItemHover,
border: Border.all(color: tokens.globalFocus, width: 1),
)
: (hovered ? BoxDecoration(color: tokens.sidebarItemHover) : null),
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
child: Row(
children: [
+16 -1
View File
@@ -2,6 +2,8 @@
/// merged health/toggle status-bar widget, and the `dock.toggle` command.
library;
import 'dart:async';
import 'package:clide/builtin/output/src/dock_status_item.dart';
import 'package:clide/builtin/output/src/output_view.dart';
import 'package:clide/clide.dart';
@@ -35,7 +37,20 @@ class OutputExtension extends ClideExtension {
slot: Slots.dock,
title: 'Output',
priority: -100, // sort before Problems in the dock tab bar
build: (ctx) => OutputView(ring: ClideKernel.of(ctx).logRing),
build: (ctx) {
// The Level chip is the live verbosity toggle (T-433): drive the kernel
// logger + persist app.log.level so the choice survives restart and
// matches the `clide log level` CLI (D-6 parity).
final k = ClideKernel.of(ctx);
return OutputView(
ring: k.logRing,
initialLevel: k.log.minLevel,
onMinLevelChanged: (level) {
k.log.minLevel = level;
unawaited(k.settings.set('app.log.level', level.name));
},
);
},
),
StatusItemContribution(
id: 'output.dock-toggle',
+12 -3
View File
@@ -10,15 +10,23 @@ import 'package:clide/kernel/src/log_ring.dart';
import 'package:flutter/foundation.dart';
class OutputController extends ChangeNotifier {
OutputController(this.ring) {
OutputController(this.ring, {LogLevel? initialLevel, this.onMinLevelChanged}) : minLevel = initialLevel ?? LogLevel.debug {
_sub = ring.changes.listen((_) => notifyListeners());
}
final LogRing ring;
late final StreamSubscription<void> _sub;
/// Minimum level shown. Defaults to debug (trace is firehose-noise).
LogLevel minLevel = LogLevel.debug;
/// Invoked when the Level chip changes the level — the dock chip is the live
/// dev/prod verbosity toggle (T-433), not just a view filter. The owner wires
/// this to set the kernel `Logger.minLevel` and persist `app.log.level`. Null
/// in tests / when no kernel is attached, leaving the chip a pure view filter.
final void Function(LogLevel)? onMinLevelChanged;
/// Minimum level shown — initialized from the kernel logger's level so the
/// chip reflects the real verbosity (which a `clide log level` CLI may have
/// already set), defaulting to debug (trace is firehose-noise).
LogLevel minLevel;
/// Source filter; null = all sources.
String? source;
@@ -29,6 +37,7 @@ class OutputController extends ChangeNotifier {
void setMinLevel(LogLevel level) {
if (minLevel == level) return;
minLevel = level;
onMinLevelChanged?.call(level);
notifyListeners();
}
+7 -2
View File
@@ -10,18 +10,23 @@ import 'package:flutter/widgets.dart';
import 'output_controller.dart';
class OutputView extends StatefulWidget {
const OutputView({super.key, required this.ring});
const OutputView({super.key, required this.ring, this.initialLevel, this.onMinLevelChanged});
/// The retained log buffer to render. The view owns a controller over it
/// but never the ring itself (the app owns that).
final LogRing ring;
/// Initial verbosity (the kernel logger's current level) + the sink that
/// applies a chip change to the kernel + persists it (T-433).
final LogLevel? initialLevel;
final void Function(LogLevel)? onMinLevelChanged;
@override
State<OutputView> createState() => _OutputViewState();
}
class _OutputViewState extends State<OutputView> {
late final OutputController _c = OutputController(widget.ring);
late final OutputController _c = OutputController(widget.ring, initialLevel: widget.initialLevel, onMinLevelChanged: widget.onMinLevelChanged);
final ScrollController _scroll = ScrollController();
/// Follow the tail until the user scrolls up; resumes when they return to
+6 -8
View File
@@ -77,20 +77,18 @@ class _TerminalPaneState extends State<TerminalPane> {
return;
}
final shell = Platform.environment['SHELL'] ?? '/bin/bash';
// Windows has no $SHELL convention and no login-shell flag —
// PowerShell 7 first, classic PowerShell as the always-there
// fallback.
final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash');
final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo'];
// The open workspace, not Directory.current — a desktop launch starts
// in $HOME and a project switch doesn't move the process CWD (T-381).
final cwd = _kernel?.project.current?.path ?? Directory.current.path;
final response = await ipc.request(
'pane.spawn',
args: {
'argv': [shell, '-l'],
'kind': PaneKind.terminal.wire,
'cwd': cwd,
'cols': _terminal.viewWidth,
'rows': _terminal.viewHeight,
},
args: {'argv': argv, 'kind': PaneKind.terminal.wire, 'cwd': cwd, 'cols': _terminal.viewWidth, 'rows': _terminal.viewHeight},
);
if (!mounted) return;
if (!response.ok) {
+2 -2
View File
@@ -9,7 +9,7 @@
/// the `vim.yaml` preset (T-65) guards its bindings with `when: vim.normal`
/// etc. Nothing reaches into this object across the builtin boundary.
///
/// The whole layer is gated by [enabled], which the Vim extension ties to
/// The whole layer is gated by `enabled`, which the Vim extension ties to
/// the active preset: under a non-Vim preset the flags are cleared so they
/// can never affect another preset's bindings.
library;
@@ -47,7 +47,7 @@ class VimModeService extends ChangeNotifier {
/// Whether the Vim layer is live. False under non-Vim presets.
bool get enabled => _enabled;
/// The active mode. Meaningful only while [enabled]; defaults to
/// The active mode. Meaningful only while `enabled`; defaults to
/// [VimMode.normal] and resets to it whenever the layer is enabled.
VimMode get mode => _mode;
+3
View File
@@ -17,6 +17,8 @@ export 'src/events/message_bus.dart';
export 'src/events/types.dart';
export 'src/ipc/client.dart';
export 'src/log.dart';
export 'src/file_log_sink.dart';
export 'src/watchdog.dart';
export 'src/settings.dart';
export 'src/facade.dart';
export 'src/clipboard.dart';
@@ -28,6 +30,7 @@ export 'src/keymap/key_chord.dart';
export 'src/keymap/keymap.dart';
export 'src/keymap/keymap_service.dart';
export 'src/keymap/modifier_tap.dart';
export 'src/keymap/pane_key_nav.dart';
export 'src/keymap/sequence_matcher.dart';
export 'src/keymap/when_clause.dart';
export 'src/dialog.dart';
+27 -9
View File
@@ -127,7 +127,7 @@ class CliInstaller {
'`make build` so the C client ships inside the app bundle.',
);
}
final dest = '${_normalize(installDir)}/clide';
final dest = '${_normalize(installDir)}/clide${Platform.isWindows ? '.exe' : ''}';
try {
Directory(installDir).createSync(recursive: true);
// Delete any existing entry first so a stale symlink (e.g. one into
@@ -180,42 +180,60 @@ class CliInstaller {
bool _dirOnPath(String dir) {
final norm = _normalize(dir);
return _expandedPath().split(':').any((d) => d.isNotEmpty && _normalize(d) == norm);
return _expandedPath().split(_pathSep).any((d) => d.isNotEmpty && _normalize(d) == norm);
}
String _normalize(String p) => p.length > 1 && p.endsWith('/') ? p.substring(0, p.length - 1) : p;
/// Trim a trailing slash; on Windows also fold separators and case so
/// `C:\Users\x/.local/bin` and `c:\users\x\.local\bin` compare equal.
String _normalize(String p) {
var s = p;
if (Platform.isWindows) s = s.replaceAll('\\', '/').toLowerCase();
return s.length > 1 && s.endsWith('/') ? s.substring(0, s.length - 1) : s;
}
String? _findOnPath(String name) {
for (final dir in _expandedPath().split(':')) {
for (final dir in _expandedPath().split(_pathSep)) {
if (dir.isEmpty) continue;
if (Platform.isWindows) {
for (final ext in const ['.exe', '.bat', '.cmd', '']) {
final f = File('$dir\\$name$ext');
if (f.existsSync()) return f.path;
}
} else {
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
}
return null;
}
static String get _pathSep => Platform.isWindows ? ';' : ':';
String _expandedPath() => expandedPath(env['PATH'] ?? '', macOS: Platform.isMacOS, home: env['HOME'] ?? '');
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin';
/// `~/.local/bin` on every platform — on Windows that is
/// `%USERPROFILE%\.local\bin`, the same convention the claude and
/// pql installers use there.
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? env['USERPROFILE'] ?? ''}/.local/bin';
/// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev
/// override first, then `<exe-dir>/clide-cli` — where `make build` drops it
/// inside the bundle (next to the GUI runner on Linux, in
/// inside the bundle (next to the GUI runner on Linux and Windows, in
/// `Contents/MacOS/` on macOS).
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
final exeDir = File(resolvedExecutable).parent.path;
return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, '$exeDir/clide-cli'];
return [if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!, if (Platform.isWindows) '$exeDir/clide-cli.exe' else '$exeDir/clide-cli'];
}
}
final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos)-(x64|arm64)/clide$');
final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos|windows)-(x64|arm64)/clide(\.exe)?$');
/// True when [path] is a dev-tree C-client build artifact —
/// `native/<platform>/clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide
/// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on
/// PATH; it's a working client but not a packaged production install, so it's
/// classified separately (T-256) rather than as a clean install.
bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path);
bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path.replaceAll('\\', '/'));
/// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps
/// launch with a sparse PATH that omits the usual user/homebrew bins, so on
+5 -1
View File
@@ -137,9 +137,13 @@ class KernelServices {
Future<void> Function(String path)? onProjectOpen,
Future<String?> Function(String path)? onValidateProject,
DaemonBus? sharedBus,
List<LogSink> additionalSinks = const [],
LogLevel? minLogLevel,
}) async {
final logRing = LogRing();
final log = Logger(sinks: [stderrSink, logRing.add]);
// additionalSinks lead the chain so a crash-survivable sink (FileLogSink,
// T-425) records the tail before the volatile stderr/ring sinks run.
final log = Logger(minLevel: minLogLevel ?? LogLevel.info, sinks: [...additionalSinks, stderrSink, logRing.add]);
final events = sharedBus ?? DaemonBus();
final messages = MessageBus();
final filterStates = FilterStateCache(messages: messages);
+165
View File
@@ -0,0 +1,165 @@
/// Crash-survivable [LogSink] (T-425).
///
/// Every other sink in clide is volatile: [stderrSink] dies with the console,
/// the [LogRing] dies with the process. The Windows freeze that motivated this
/// (a hard power-cycle, no dumps, no logs) left no evidence for exactly that
/// reason. [FileLogSink] is the durable tail: it appends each [LogRecord] as
/// one JSON line to a size-rotated file under a persistent per-platform log
/// dir, and — crucially — fsyncs the records most likely to immediately
/// precede a crash, so the last breadcrumb is on disk before the box dies.
///
/// Design choices that matter for a CRASH logger:
/// - Synchronous I/O only. No async buffering / no IOSink — a hard death
/// between an `await` and its flush would lose the tail, which is the one
/// thing this sink exists to keep.
/// - Tiered flush. `warn`/`error` and records from inherently-risky sources
/// (pty/ffi/conpty/watchdog) `flushSync` immediately. High-volume
/// `info`/`debug` write through to the OS (surviving a process crash) and
/// are fsynced on a low-frequency timer — enough to bound power-loss to a
/// couple of seconds without an fsync per line.
/// - Never throws. A disk-full / permission error must not take logging — or
/// the app — down; every operation swallows its own failure.
///
/// Flutter-free (only `dart:io`/`dart:async`/`dart:convert` + [LogRecord]) so
/// it unit-tests under `dart test` against a temp dir, and so the PTY/FFI
/// layer (also Flutter-free) can route breadcrumbs through it.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'log.dart';
class FileLogSink {
FileLogSink({
required Directory dir,
String baseName = 'clide',
int maxBytes = 5 * 1024 * 1024,
int maxFiles = 5,
Set<String> eagerSources = const {'pty', 'ffi', 'conpty', 'watchdog'},
LogLevel eagerLevel = LogLevel.warn,
Duration flushInterval = const Duration(seconds: 2),
bool startFlushTimer = true,
}) : _dir = dir,
_baseName = baseName,
_maxBytes = maxBytes,
_maxFiles = maxFiles < 1 ? 1 : maxFiles,
_eagerSources = eagerSources,
_eagerLevel = eagerLevel {
_open();
if (startFlushTimer && flushInterval > Duration.zero) {
_timer = Timer.periodic(flushInterval, (_) => _flush());
}
}
final Directory _dir;
final String _baseName;
final int _maxBytes;
final int _maxFiles;
final Set<String> _eagerSources;
final LogLevel _eagerLevel;
RandomAccessFile? _raf;
int _size = 0;
bool _dirty = false;
Timer? _timer;
String get _sep => Platform.pathSeparator;
File get _active => File('${_dir.path}$_sep$_baseName.log');
File _archive(int i) => File('${_dir.path}$_sep$_baseName.$i.log');
/// The active log file's path — handy for the caller to surface (e.g. an
/// "open log folder" affordance) or to add to a CI artifact upload.
String get activePath => _active.path;
void _open() {
try {
_dir.createSync(recursive: true);
final f = _active;
_size = f.existsSync() ? f.lengthSync() : 0;
_raf = f.openSync(mode: FileMode.append);
} catch (_) {
_raf = null; // a disk problem must never kill logging
}
}
/// The [LogSink] entry point: `logger.addSink(fileSink.call)`.
void call(LogRecord r) {
if (_raf == null) return;
try {
final bytes = utf8.encode('${jsonEncode(_encode(r))}\n');
// Rotate BEFORE writing when this line would push the file past the cap,
// so the newest entries always live in the active file (and a single
// oversized line still lands rather than spinning rotations on an empty
// file).
if (_size > 0 && _size + bytes.length > _maxBytes) _rotate();
final raf = _raf;
if (raf == null) return;
raf.writeFromSync(bytes);
_size += bytes.length;
_dirty = true;
if (r.level.index >= _eagerLevel.index || _eagerSources.contains(r.source)) {
raf.flushSync();
_dirty = false;
}
} catch (_) {
// swallow — a logging failure must never propagate to the app
}
}
Map<String, Object?> _encode(LogRecord r) => {
'ts': r.timestamp.toIso8601String(),
'lvl': r.level.name,
'src': r.source,
'msg': r.message,
if (r.error != null) 'err': r.error.toString(),
if (r.stackTrace != null) 'stack': r.stackTrace.toString(),
};
void _flush() {
if (!_dirty) return;
try {
_raf?.flushSync();
_dirty = false;
} catch (_) {}
}
/// Roll `<base>.log` → `<base>.1.log`, shifting older archives up and
/// dropping the oldest past [maxFiles]. With `maxFiles == 1` the active file
/// is simply truncated (no archives kept).
void _rotate() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
try {
if (_maxFiles <= 1) {
if (_active.existsSync()) _active.deleteSync();
} else {
final oldest = _archive(_maxFiles - 1);
if (oldest.existsSync()) oldest.deleteSync();
for (var i = _maxFiles - 2; i >= 1; i--) {
final src = _archive(i);
if (src.existsSync()) src.renameSync(_archive(i + 1).path);
}
if (_active.existsSync()) _active.renameSync(_archive(1).path);
}
} catch (_) {}
_size = 0;
_open();
}
/// Flush + close. Call on orderly shutdown; a crash is covered by the eager
/// fsync above, not by this.
Future<void> close() async {
_timer?.cancel();
_timer = null;
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
+67
View File
@@ -98,6 +98,63 @@ class TextScaleResetIntent extends Intent {
const TextScaleResetIntent();
}
// -- Pane navigation (vim normal-mode motions outside the editor) ------------
/// Base for the preset-neutral navigation intents (T-406). A focused non-editor
/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and
/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds
/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys
/// to the same ids. Marker base so a pane's key handler can tell a nav motion
/// apart from any other fired intent.
sealed class NavIntent extends Intent {
const NavIntent();
}
/// Move the selection / scroll down one step (vim `j`).
class NavDownIntent extends NavIntent {
const NavDownIntent();
}
/// Move the selection / scroll up one step (vim `k`).
class NavUpIntent extends NavIntent {
const NavUpIntent();
}
/// Scroll down half a viewport (vim `ctrl+d`).
class NavPageDownIntent extends NavIntent {
const NavPageDownIntent();
}
/// Scroll up half a viewport (vim `ctrl+u`).
class NavPageUpIntent extends NavIntent {
const NavPageUpIntent();
}
/// Jump to the first item / top (vim `gg`).
class NavTopIntent extends NavIntent {
const NavTopIntent();
}
/// Jump to the last item / bottom (vim `G`).
class NavBottomIntent extends NavIntent {
const NavBottomIntent();
}
/// Expand the focused node, or step into it / move right (vim `l`).
class NavExpandOrRightIntent extends NavIntent {
const NavExpandOrRightIntent();
}
/// Collapse the focused node, or step out of it / move left (vim `h`).
class NavCollapseOrLeftIntent extends NavIntent {
const NavCollapseOrLeftIntent();
}
/// Activate the focused item — open the file, run the row (vim `o` / `enter`).
class NavActivateIntent extends NavIntent {
const NavActivateIntent();
}
// -- Command bridge ---------------------------------------------------------
/// Generic "invoke this CommandRegistry command id" intent. Used for
@@ -136,6 +193,16 @@ final Map<String, Intent Function()> builtinIntents = {
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
'findInFiles.open': () => const FindInFilesIntent(),
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
'nav.down': () => const NavDownIntent(),
'nav.up': () => const NavUpIntent(),
'nav.pageDown': () => const NavPageDownIntent(),
'nav.pageUp': () => const NavPageUpIntent(),
'nav.top': () => const NavTopIntent(),
'nav.bottom': () => const NavBottomIntent(),
'nav.expandOrRight': () => const NavExpandOrRightIntent(),
'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(),
'nav.activate': () => const NavActivateIntent(),
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
'text.scaleReset': () => const TextScaleResetIntent(),
+1 -1
View File
@@ -13,7 +13,7 @@
///
/// Scope context is a `Map<String, bool>` keyed by named flags (e.g.
/// `palette.open`, `editor.focused`). Producing services call
/// [setScopeFlag] when their state changes; consumers reference the
/// `setScopeFlag` when their state changes; consumers reference the
/// flag name in when-clauses.
library;
+2 -2
View File
@@ -9,8 +9,8 @@
///
/// Headless and clock-injected: the caller (the root shell's raw-keyboard
/// handler) passes the event time so it neither reads a clock nor consumes
/// events. Feed every [KeyDownEvent] to [down] and every [KeyUpEvent] to
/// [up], passing the event's [KeyModifier] (null for non-modifier keys).
/// events. Feed every [KeyDownEvent] to `down` and every [KeyUpEvent] to
/// `up`, passing the event's [KeyModifier] (null for non-modifier keys).
library;
import 'key_chord.dart';
+122
View File
@@ -0,0 +1,122 @@
/// A reusable vim normal-mode navigation key handler for non-editor panes
/// (T-406).
///
/// The passive global key path is single-chord only and can't run sequences or
/// consume events (D-82), so — exactly like the editor's command-mode handler —
/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a
/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file
/// tree, conversation, and lists share one implementation.
///
/// While a `vim.normal` scope flag is set and this region holds focus, bare and
/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed
/// the matcher against the live keymap; a fired [NavIntent] is handed to
/// `onNav` with its repeat count. Everything else under `vim.normal` is
/// swallowed (vim normal mode is inert for unbound keys), except other-modifier
/// chords (palette, quick-open, …) which bubble to the global handler. Under a
/// non-vim preset or in insert mode the region is transparent — keys pass
/// straight through.
///
/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key
/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav
/// intent here and to the editor motion in the editor — see vim.yaml.
library;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../facade.dart';
import 'intents.dart';
import 'key_chord.dart';
import 'keymap.dart';
import 'sequence_matcher.dart';
/// Signature for a fired navigation motion: the [intent] and its repeat
/// [count] (>= 1, from a leading digit prefix like `5j`).
typedef NavHandler = void Function(NavIntent intent, int count);
class PaneKeyNav extends StatefulWidget {
const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true});
final Widget child;
/// Called when a `nav.*` motion resolves while this region has focus.
final NavHandler onNav;
/// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that
/// want to move focus here programmatically (a row tap, F6) pass their own.
final FocusNode? focusNode;
final bool autofocus;
/// Whether the region can take focus at all. False makes it a pure pass-through
/// (used when a pane temporarily routes keys elsewhere, e.g. a filter box).
final bool canRequestFocus;
@override
State<PaneKeyNav> createState() => _PaneKeyNavState();
}
class _PaneKeyNavState extends State<PaneKeyNav> {
FocusNode? _ownNode;
SequenceMatcher? _matcher;
FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav'));
/// The half-page scroll chords are the only modified chords this handler
/// claims; every other modified chord bubbles to the global shortcut path.
static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD);
static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU);
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_matcher != null) return;
final kernel = ClideKernel.of(context);
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
}
@override
void dispose() {
_ownNode?.dispose();
super.dispose();
}
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
final kernel = ClideKernel.of(context);
// Only vim normal mode drives pane navigation. Insert/visual or a non-vim
// preset → transparent, keys pass through to whatever's below.
if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored;
final hw = HardwareKeyboard.instance;
final chord = KeyChord.fromKeyEvent(event, hw);
if (chord == null) return KeyEventResult.ignored;
// Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only
// modified chords we claim (half-page scroll). Any other modified chord is
// an app shortcut (palette, quick-open) — let it bubble to the global path.
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored;
final r = _matcher!.feed(chord);
switch (r.outcome) {
case SeqOutcome.fired:
// The vim preset also binds these keys to editor.vim.* motions; in a
// pane only nav.* applies. A non-nav fired intent (e.g. a stray
// editor.vim.* with no focus guard) is swallowed, never executed here.
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count);
return KeyEventResult.handled;
case SeqOutcome.pending:
return KeyEventResult.handled;
case SeqOutcome.unmatched:
// Vim normal mode beeps on unbound keys — swallow so a bare key never
// leaks to text input or the global handler.
return KeyEventResult.handled;
}
}
@override
Widget build(BuildContext context) {
return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
/// and := unary ('&&' unary)*
/// unary := '!' unary | atom
/// atom := IDENT | '(' expr ')'
/// IDENT := [a-zA-Z_][a-zA-Z0-9._-]*
/// IDENT := `[a-zA-Z_][a-zA-Z0-9._-]*`
///
/// Identifiers resolve against a `Map<String, bool>` context. A missing
/// identifier evaluates to `false` — bindings can assume any required
+29
View File
@@ -62,3 +62,32 @@ void stderrSink(LogRecord r) {
stderr.writeln(r);
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
}
/// Parse a level name (case-insensitive, trimmed) to a [LogLevel], or null if
/// it is absent/blank/unknown — so an invalid source falls through to the next
/// one in [resolveLogLevel] rather than crashing the boot.
LogLevel? parseLogLevel(String? name) {
if (name == null) return null;
final n = name.trim().toLowerCase();
if (n.isEmpty) return null;
for (final l in LogLevel.values) {
if (l.name == n) return l;
}
return null;
}
/// Resolve the effective [Logger.minLevel] at boot — the dev/prod verbosity
/// toggle (T-425). Highest precedence first:
///
/// 1. `--dart-define=CLIDE_LOG=<level>` (baked into the build)
/// 2. the `CLIDE_LOG` environment variable
/// 3. the `app.log.level` setting
/// 4. a build-mode default: `warn` in release (a shipped app stays quiet),
/// `info` in debug.
///
/// Each named source is parsed leniently; an unknown name is skipped, not
/// fatal. The build-mode flag is passed in (rather than read here) to keep
/// this Flutter-free — `main.dart` supplies `kReleaseMode`.
LogLevel resolveLogLevel({required bool isRelease, String? dartDefine, String? envVar, String? settingValue}) {
return parseLogLevel(dartDefine) ?? parseLogLevel(envVar) ?? parseLogLevel(settingValue) ?? (isRelease ? LogLevel.warn : LogLevel.info);
}
+3 -3
View File
@@ -2,9 +2,9 @@
/// output dock (T-54 / D-87) reads on open.
///
/// The [Logger] only live-broadcasts to its stream; a panel that opens late
/// would see nothing. [LogRing] is a sink that keeps the last [capacity]
/// would see nothing. [LogRing] is a sink that keeps the last `capacity`
/// records (drop-oldest, same shape as the D-85 event ring) plus enough
/// bookkeeping to drive the panel's filter dropdown (distinct [sources]) and
/// bookkeeping to drive the panel's filter dropdown (distinct `sources`) and
/// the status-bar health badge (level counts).
///
/// Flutter-free (only `dart:async`/`dart:collection` + the [LogRecord] type)
@@ -42,7 +42,7 @@ class LogRing {
int get length => _records.length;
bool get isEmpty => _records.isEmpty;
/// Append a record (the [Logger] sink). Drops the oldest past [capacity].
/// Append a record (the [Logger] sink). Drops the oldest past `capacity`.
void add(LogRecord r) {
_records.addLast(r);
_sourceCounts.update(r.source, (n) => n + 1, ifAbsent: () => 1);
@@ -1,7 +1,7 @@
/// Snapshots the kernel's live tabs into [ViewPane]s so `pane list` reflects
/// the panes the user actually sees in the GUI (T-219, D-6 parity / D-83).
///
/// Read-at-request-time: no state is mirrored into the IPC [PaneRegistry], so
/// Read-at-request-time: no state is mirrored into the IPC `PaneRegistry`, so
/// nothing can drift from the live UI. Lives in the kernel (not `lib/src/panes/`)
/// because it reads Flutter-coupled kernel state; it produces the Flutter-free
/// [ViewPane] the pane command serialises.
+3 -6
View File
@@ -10,6 +10,7 @@
library;
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
@@ -20,7 +21,6 @@ export 'toolchain_paths.dart';
class Toolchain extends ChangeNotifier implements ToolchainView {
String? _git;
String? _pql;
String? _tmux;
String? _shell;
Map<String, String>? _gitEnv;
bool _resolved = false;
@@ -30,9 +30,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
@override
String get pql => _pql ?? 'pql';
@override
String get tmux => _tmux ?? 'tmux';
@override
String get shell => _shell ?? '/bin/bash';
String get shell => _shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
/// Extra environment variables for git (e.g. GIT_EXEC_PATH for dugite).
@override
@@ -44,7 +42,7 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
bool get allOk => _resolved && missing.isEmpty;
@override
List<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql', if (_tmux == null) 'tmux'];
List<String> get missing => [if (_git == null) 'git', if (_pql == null) 'pql'];
/// Returns a Future that completes when resolution finishes.
Future<void> waitForResolution() {
@@ -65,7 +63,6 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
void applyResolved(ResolvedPaths p) {
_git = p.git;
_pql = p.pql;
_tmux = p.tmux;
_shell = p.shell;
_gitEnv = p.gitEnv;
_resolved = true;
+28 -15
View File
@@ -12,11 +12,10 @@ import 'dart:io';
/// Serializable result of tool resolution (crosses isolate boundary).
class ResolvedPaths {
const ResolvedPaths({this.git, this.pql, this.tmux, this.shell, this.gitEnv});
const ResolvedPaths({this.git, this.pql, this.shell, this.gitEnv});
final String? git;
final String? pql;
final String? tmux;
final String? shell;
final Map<String, String>? gitEnv;
}
@@ -32,7 +31,6 @@ abstract class ToolchainView {
String get git;
String get pql;
String get tmux;
String get shell;
Map<String, String>? get gitEnv;
bool get resolved;
@@ -50,9 +48,7 @@ class _StaticToolchain implements ToolchainView {
@override
String get pql => _paths.pql ?? 'pql';
@override
String get tmux => _paths.tmux ?? 'tmux';
@override
String get shell => _paths.shell ?? '/bin/bash';
String get shell => _paths.shell ?? (Platform.isWindows ? 'powershell.exe' : '/bin/bash');
@override
Map<String, String>? get gitEnv => _paths.gitEnv;
@override
@@ -60,7 +56,7 @@ class _StaticToolchain implements ToolchainView {
@override
bool get allOk => missing.isEmpty;
@override
List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql', if (_paths.tmux == null) 'tmux'];
List<String> get missing => [if (_paths.git == null) 'git', if (_paths.pql == null) 'pql'];
}
/// Top-level function for compute/isolate use. Returns a plain-data
@@ -83,13 +79,17 @@ ResolvedPaths resolveToolchainPaths() {
git = _findOnPath('git');
}
return ResolvedPaths(
git: git,
pql: _findOnPath('pql'),
tmux: _findOnPath('tmux'),
shell: _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash'),
gitEnv: gitEnv,
);
return ResolvedPaths(git: git, pql: _findOnPath('pql'), shell: _resolveShell(), gitEnv: gitEnv);
}
/// The user's interactive shell. POSIX honours `$SHELL`; Windows has
/// no such convention — prefer PowerShell 7 (`pwsh`), fall back to
/// Windows PowerShell (present on every supported Windows).
String? _resolveShell() {
if (Platform.isWindows) {
return _findOnPath('pwsh') ?? _findOnPath('powershell');
}
return _findOnPath(Platform.environment['SHELL']?.split('/').last ?? 'bash');
}
/// Locate the dugite-bundled git binary in trusted install locations
@@ -104,6 +104,10 @@ ResolvedPaths resolveToolchainPaths() {
///
/// Returns null if no dugite is found; caller falls back to PATH git.
String? _resolveDugiteGit() {
// dugite-native's Windows layout differs (cmd\git.exe, mingw64
// libexec) and isn't wired up yet — PATH git serves Windows until
// the bundle work lands.
if (Platform.isWindows) return null;
final candidates = <String>[];
final envDir = Platform.environment['CLIDE_DUGITE_DIR'];
if (envDir != null && envDir.isNotEmpty) {
@@ -116,11 +120,20 @@ String? _resolveDugiteGit() {
}
String? _findOnPath(String name) {
for (final dir in _expandedPath().split(':')) {
final sep = Platform.isWindows ? ';' : ':';
for (final dir in _expandedPath().split(sep)) {
if (dir.isEmpty) continue;
if (Platform.isWindows) {
// PATHEXT-style probe — a bare `pql` on PATH is really pql.exe.
for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) {
final f = File('$dir\\$name$ext');
if (f.existsSync()) return f.path;
}
} else {
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
}
return null;
}
+201
View File
@@ -0,0 +1,201 @@
/// Crash-diagnostic watchdog (T-435, under the T-425 observability epic).
///
/// The Windows freeze leaves no evidence partly because it is a *whole-process*
/// stall: a main-isolate `Timer` heartbeat would freeze WITH the main isolate
/// and tell us nothing. So the watchdog runs in a DEDICATED isolate that:
///
/// - appends + fsyncs a heartbeat every ~500ms, so the last heartbeat on disk
/// bounds a freeze to ~500ms ("it was alive at T, dead by T+0.5s"); and
/// - every ~2s samples this process's resource counts — threads, open
/// handles/fds, child/ConPTY-host processes, RSS — and appends + fsyncs
/// them. A monotonically climbing child/thread/handle count is the leak
/// signature the soak couldn't reproduce on CI but a real freeze would show.
///
/// Output is JSON-lines in `logDirectory()/clide-watchdog.log`, matching
/// [FileLogSink] so it greps/parses the same way, bounded by the same
/// truncate-on-cap scheme as `IsolateCrumbFile`. Everything is synchronous and
/// swallows its own errors — the watchdog must never add a second hang or take
/// the app down.
///
/// Flutter-free (dart:io / dart:isolate / dart:convert + a thin FFI sampler on
/// Windows) so the entry point is `Isolate.spawn`-able and it unit-tests under
/// `dart test`.
library;
import 'dart:convert';
import 'dart:io';
import 'watchdog_windows.dart';
/// One resource sample of the current process. A field of `-1` means "not
/// available on this platform or the probe failed" — never an error.
class ResourceSample {
const ResourceSample({this.threads = -1, this.handles = -1, this.children = -1, this.rssBytes = -1});
/// Live OS thread count (culprit #2: blocked-FFI isolate threads piling up).
final int threads;
/// Open handle count (Windows) / open fd count (POSIX).
final int handles;
/// Child / ConPTY-host process count (the orphan-accumulation leak signature).
final int children;
/// Resident set size in bytes.
final int rssBytes;
Map<String, Object?> toJson() => {
if (threads >= 0) 'threads': threads,
if (handles >= 0) 'handles': handles,
if (children >= 0) 'children': children,
if (rssBytes >= 0) 'rssMB': (rssBytes / (1024 * 1024)).round(),
};
}
/// Samples the CURRENT process's resource counts. Cheap, synchronous, never
/// throws (returns `-1` fields on failure).
abstract class ResourceSampler {
ResourceSample sample();
/// The backend for the running OS — `/proc` on POSIX, a thin Win32 FFI
/// snapshot on Windows.
static ResourceSampler forPlatform() => Platform.isWindows ? WindowsResourceSampler() : PosixResourceSampler();
}
/// POSIX sampler — reads `/proc/self`. Runs (and is tested) on the Linux CI box.
class PosixResourceSampler implements ResourceSampler {
@override
ResourceSample sample() => ResourceSample(threads: _threads(), handles: _fdCount(), children: _childCount(), rssBytes: _rss());
int _threads() {
try {
for (final line in File('/proc/self/status').readAsLinesSync()) {
if (line.startsWith('Threads:')) return int.parse(line.split(RegExp(r'\s+'))[1]);
}
} catch (_) {}
return -1;
}
int _fdCount() {
try {
return Directory('/proc/self/fd').listSync().length;
} catch (_) {
return -1;
}
}
int _childCount() {
// Sum each thread's direct-children list (`/proc/<pid>/task/<tid>/children`,
// Linux 5.3+). Best-effort: absent file / old kernel → 0 from that thread.
try {
var n = 0;
for (final task in Directory('/proc/self/task').listSync()) {
final f = File('${task.path}/children');
if (!f.existsSync()) continue;
final s = f.readAsStringSync().trim();
if (s.isNotEmpty) n += s.split(RegExp(r'\s+')).length;
}
return n;
} catch (_) {
return -1;
}
}
int _rss() {
try {
return ProcessInfo.currentRss;
} catch (_) {
return -1;
}
}
}
/// The watchdog's on-disk writer: bounded, synchronously-fsynced JSON-lines
/// (heartbeats + samples). Separated from the loop so it unit-tests in
/// isolation. Mirrors `IsolateCrumbFile`'s truncate-on-cap bound.
class WatchdogFile {
WatchdogFile(String? path, {int capBytes = 256 * 1024}) : _capBytes = capBytes {
if (path == null) return;
try {
final f = File(path);
f.parent.createSync(recursive: true);
final raf = f.openSync(mode: FileMode.append);
_raf = raf;
_size = raf.lengthSync();
} catch (_) {
_raf = null;
}
}
final int _capBytes;
RandomAccessFile? _raf;
int _size = 0;
bool get enabled => _raf != null;
void heartbeat() => _write({'ts': _now(), 'evt': 'hb'});
void sample(ResourceSample s) => _write({'ts': _now(), 'evt': 'sample', 'pid': pid, ...s.toJson()});
String _now() => DateTime.now().toUtc().toIso8601String();
void _write(Map<String, Object?> json) {
final raf = _raf;
if (raf == null) return;
try {
if (_size >= _capBytes) {
raf.truncateSync(0);
raf.setPositionSync(0);
_size = 0;
}
final bytes = utf8.encode('${jsonEncode(json)}\n');
raf.writeFromSync(bytes);
raf.flushSync();
_size += bytes.length;
} catch (_) {}
}
void close() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
/// The watchdog loop. Extracted from [watchdogEntry] so a test can bound it
/// with [maxTicks]; production passes null and the loop runs until the isolate
/// is killed at shutdown. Heartbeats fire every [hbIntervalMs], samples every
/// [sampleIntervalMs]; a short sleep between keeps the cadence without spinning.
void runWatchdog(WatchdogFile file, ResourceSampler sampler, {required int hbIntervalMs, required int sampleIntervalMs, int? maxTicks}) {
if (!file.enabled) return;
final sw = Stopwatch()..start();
// Seed both "last" markers a full interval in the past so the first tick
// emits an immediate heartbeat + sample (a baseline at startup).
var lastHb = -hbIntervalMs;
var lastSample = -sampleIntervalMs;
var ticks = 0;
while (maxTicks == null || ticks < maxTicks) {
final e = sw.elapsedMilliseconds;
if (e - lastHb >= hbIntervalMs) {
file.heartbeat();
lastHb = e;
}
if (e - lastSample >= sampleIntervalMs) {
file.sample(sampler.sample());
lastSample = e;
}
ticks++;
if (maxTicks != null && ticks >= maxTicks) break;
sleep(const Duration(milliseconds: 25));
}
file.close();
}
/// Top-level entry for `Isolate.spawn`. Args are a sendable tuple — the log
/// path (not a Logger; isolates can't share one) and the two intervals in ms.
void watchdogEntry((String, int, int) msg) {
final (logPath, hbMs, sampleMs) = msg;
runWatchdog(WatchdogFile(logPath), ResourceSampler.forPlatform(), hbIntervalMs: hbMs, sampleIntervalMs: sampleMs);
}
+141
View File
@@ -0,0 +1,141 @@
// coverage:ignore-file
//
// Windows-only resource sampler for the watchdog (T-435). All of it is Win32
// FFI through kernel32/psapi, so it cannot execute on the Linux CI runner that
// produces the coverage report (PosixResourceSampler is used there). It is
// validated only when the app actually runs on Windows — which is acceptable
// because it is a DIAGNOSTIC that reads, never mutates, and is exhaustively
// defensive: every probe is wrapped so any failure yields a `-1` field rather
// than an exception, and the toolhelp snapshot handle is always closed. A
// missing sample is fine; a sampler that throws or leaks would not be.
library;
import 'dart:ffi' as ffi;
import 'dart:io' show ProcessInfo;
import 'package:ffi/ffi.dart';
import 'watchdog.dart';
const int _kTh32csSnapprocess = 0x00000002;
final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll');
final ffi.DynamicLibrary _psapi = ffi.DynamicLibrary.open('psapi.dll');
final _getCurrentProcess = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(), ffi.Pointer<ffi.Void> Function()>('GetCurrentProcess');
final _getCurrentProcessId = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetCurrentProcessId');
final _createToolhelp32Snapshot = _k32.lookupFunction<ffi.Pointer<ffi.Void> Function(ffi.Uint32, ffi.Uint32), ffi.Pointer<ffi.Void> Function(int, int)>(
'CreateToolhelp32Snapshot',
);
final _process32First = _k32
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
'Process32First',
);
final _process32Next = _k32
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<_ProcessEntry32>)>(
'Process32Next',
);
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>), int Function(ffi.Pointer<ffi.Void>)>('CloseHandle');
final _getProcessHandleCount = _psapi
.lookupFunction<ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>), int Function(ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Uint32>)>(
'GetProcessHandleCount',
);
/// Win32 `PROCESSENTRY32` (ANSI). szExeFile is `CHAR[MAX_PATH]`.
final class _ProcessEntry32 extends ffi.Struct {
@ffi.Uint32()
external int dwSize;
@ffi.Uint32()
external int cntUsage;
@ffi.Uint32()
external int th32ProcessID;
@ffi.IntPtr()
external int th32DefaultHeapID;
@ffi.Uint32()
external int th32ModuleID;
@ffi.Uint32()
external int cntThreads;
@ffi.Uint32()
external int th32ParentProcessID;
@ffi.Int32()
external int pcPriClassBase;
@ffi.Uint32()
external int dwFlags;
@ffi.Array<ffi.Uint8>(260)
external ffi.Array<ffi.Uint8> szExeFile;
}
/// Samples the current process via a single toolhelp snapshot (thread count +
/// ConPTY-host children) plus GetProcessHandleCount and ProcessInfo.currentRss.
class WindowsResourceSampler implements ResourceSampler {
@override
ResourceSample sample() {
final (threads, children) = _snapshotThreadsAndHosts();
return ResourceSample(threads: threads, children: children, handles: _handleCount(), rssBytes: _rss());
}
/// One toolhelp snapshot → (this process's thread count, count of its direct
/// conhost/OpenConsole children). Both `-1`/unavailable on any failure.
(int, int) _snapshotThreadsAndHosts() {
var threads = -1;
var conhosts = 0;
var sawAny = false;
ffi.Pointer<ffi.Void>? snap;
final entry = calloc<_ProcessEntry32>();
try {
final myPid = _getCurrentProcessId();
snap = _createToolhelp32Snapshot(_kTh32csSnapprocess, 0);
entry.ref.dwSize = ffi.sizeOf<_ProcessEntry32>();
var ok = _process32First(snap, entry);
while (ok != 0) {
sawAny = true;
if (entry.ref.th32ProcessID == myPid) threads = entry.ref.cntThreads;
if (entry.ref.th32ParentProcessID == myPid) {
final name = _exeName(entry.ref.szExeFile).toLowerCase();
if (name == 'conhost.exe' || name == 'openconsole.exe') conhosts++;
}
ok = _process32Next(snap, entry);
}
} catch (_) {
// any FFI failure → unavailable, not a crash
} finally {
if (snap != null) {
try {
_closeHandle(snap);
} catch (_) {}
}
calloc.free(entry);
}
return (threads, sawAny ? conhosts : -1);
}
int _handleCount() {
final out = calloc<ffi.Uint32>();
try {
final ok = _getProcessHandleCount(_getCurrentProcess(), out);
return ok != 0 ? out.value : -1;
} catch (_) {
return -1;
} finally {
calloc.free(out);
}
}
int _rss() {
try {
return ProcessInfo.currentRss;
} catch (_) {
return -1;
}
}
String _exeName(ffi.Array<ffi.Uint8> arr) {
final bytes = <int>[];
for (var i = 0; i < 260; i++) {
final b = arr[i];
if (b == 0) break;
bytes.add(b);
}
return String.fromCharCodes(bytes);
}
}
+57 -6
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:isolate';
import 'package:clide/app.dart';
import 'package:clide/test_app.dart';
@@ -39,6 +40,7 @@ import 'package:clide/src/daemon/editor_commands.dart';
import 'package:clide/src/daemon/files_commands.dart';
import 'package:clide/src/daemon/git_commands.dart';
import 'package:clide/src/daemon/image_commands.dart';
import 'package:clide/src/daemon/log_commands.dart';
import 'package:clide/src/daemon/pane_commands.dart';
import 'package:clide/src/daemon/status_command.dart';
import 'package:clide/src/daemon/ui_command.dart';
@@ -51,7 +53,8 @@ import 'package:clide/src/git/client.dart';
import 'package:clide/src/cli/argv_dispatch.dart';
import 'package:clide/src/ipc/envelope.dart';
import 'package:clide/src/ipc/mcp_server.dart';
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath, logDirectory;
import 'package:clide/src/pty/pty_log.dart';
import 'package:clide/src/ipc/server.dart';
import 'package:clide/src/panes/event_sink.dart';
import 'package:clide/src/panes/registry.dart';
@@ -89,6 +92,11 @@ Future<void> main() async {
// at the last project instead so the daemon targets the real repo from the
// first request. (T-352)
Directory startupWorkRoot = resolveWorkspaceRoot(Directory.current);
// Crash-survivable logging (T-425): resolve the dev/prod verbosity once and
// attach a FileLogSink as the leading sink so a freeze leaves on-disk
// breadcrumbs. Desktop-only — the sink uses dart:io.
LogLevel bootLogLevel = kReleaseMode ? LogLevel.warn : LogLevel.info;
List<LogSink> bootLogSinks = const [];
if (!kIsWeb) {
final bootSettings = SettingsStore(appDir: appDir);
await bootSettings.load();
@@ -97,6 +105,21 @@ Future<void> main() async {
lastProject: bootSettings.get<String>('app.lastProject'),
isGitRepo: (d) => Directory('${d.path}/.git').existsSync(),
);
bootLogLevel = resolveLogLevel(
isRelease: kReleaseMode,
dartDefine: const String.fromEnvironment('CLIDE_LOG'),
envVar: Platform.environment['CLIDE_LOG'],
settingValue: bootSettings.get<String>('app.log.level'),
);
bootLogSinks = [FileLogSink(dir: Directory(logDirectory())).call];
// Crash-diagnostic watchdog in its own isolate (T-435): heartbeats +
// resource samples that survive a frozen main isolate. Non-fatal — a
// leak-detector that breaks startup is worse than a missing one. The OS
// reaps the isolate on exit; every line is fsynced, so abrupt death loses
// nothing.
try {
await Isolate.spawn(watchdogEntry, ('${logDirectory()}/clide-watchdog.log', 500, 2000));
} catch (_) {}
}
// Resolve toolchain + boot daemon inline — same as Linux.
@@ -119,6 +142,12 @@ Future<void> main() async {
// The filter-state cache, captured post-boot so `ui.filter` can read a
// box's current value back — the observe-half of D-6 (T-270).
FilterStateCache? kernelFilterStates;
// The kernel Logger, captured in the factory so a post-boot project switch
// can rebuild the dispatcher with PTY breadcrumbs wired (T-434).
Logger? kernelLog;
// The kernel settings store, captured post-boot so `clide log level` can
// persist app.log.level (T-433).
SettingsStore? kernelSettings;
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per
// workspace; restarted when the active project switches because the
// socket path is workspace-derived. The local DaemonClient connects
@@ -228,15 +257,33 @@ Future<void> main() async {
Toolchain tc,
Directory workRoot,
LayoutArrangement arrangement,
PanelRegistry panels,
) {
PanelRegistry panels, {
Logger? log,
}) {
final dispatcher = DaemonDispatcher();
final eventSink = _BusEventSink(events);
final paneRegistry = PaneRegistry(events: eventSink);
// FFI breadcrumbs (T-434): route PTY crumbs to the kernel Logger (source
// 'conpty', an eager FileLogSink source) and a sendable crumb file the
// reader/waiter isolates open themselves. Verbose (per-syscall) crumbs only
// when the log level is debug/trace.
final ptyLog = (log == null || kIsWeb)
? PtyLog.none
: PtyLog(
onCrumb: (m) => log.trace('conpty', m),
crumbPath: '${logDirectory()}/clide-pty.crumbs.log',
verbose: log.minLevel.index <= LogLevel.debug.index,
);
final paneRegistry = PaneRegistry(events: eventSink, ptyLog: ptyLog);
// D-6 parity (T-219, D-83): make the tabs the user sees in the GUI
// visible to `pane list` by snapshotting the kernel PanelRegistry +
// LayoutArrangement at request time — no mirrored state to drift.
registerPaneCommands(dispatcher, paneRegistry, viewPanes: () => snapshotViewPanes(panels, arrangement));
// `clide log level [<level>]` — the live verbosity toggle's CLI half (T-433,
// D-6 parity with the output-dock Level chip). Persists via the kernel
// settings, captured post-boot.
if (log != null) {
registerLogCommands(dispatcher, log, (name) async => await kernelSettings?.set<String>('app.log.level', name));
}
// Trusted read-only roots beyond the workspace: the global Claude
// config dir (~/.claude), so the reader can open user-scope skill /
// agent / command markdown the Config tab surfaces (D-80, T-195).
@@ -341,14 +388,17 @@ Future<void> main() async {
preloadNamespaces: _tier0Namespaces,
autoStartDaemonClient: false,
toolchain: toolchain,
minLogLevel: bootLogLevel,
additionalSinks: bootLogSinks,
daemonClientFactory: kIsWeb
? null
: (log, events, arrangement, panels) {
daemonBus = events;
kernelArrangement = arrangement;
kernelPanels = panels;
kernelLog = log;
final workRoot = startupWorkRoot;
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels);
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels, log: log);
// Build the client at the workspace's socket path. The
// server is started below (swapBackend) which the
// client will then auto-connect to via its reconnect
@@ -373,7 +423,7 @@ Future<void> main() async {
final arrangement = kernelArrangement;
final panels = kernelPanels;
if (bus == null || arrangement == null || panels == null) return;
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels);
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels, log: kernelLog);
await swapBackend(dispatcher, teardown, Directory(path));
},
);
@@ -383,6 +433,7 @@ Future<void> main() async {
kernelReaderNav = services.readerNav;
kernelMessages = services.messages;
kernelFilterStates = services.filterStates;
kernelSettings = services.settings;
// Tee the IPC/MCP logger into the shared ring so the output dock (T-54)
// shows socket-side logs alongside kernel/extension ones.
ipcLog.addSink(services.logRing.add);
+37
View File
@@ -0,0 +1,37 @@
/// Registers `log.level` — the live dev/prod verbosity toggle (T-433).
///
/// With no arg it reports the running [Logger]'s minimum level; with
/// `level=<name>` it sets the Logger AND persists `app.log.level` so the
/// choice survives a restart (resolved at boot by `resolveLogLevel`). The UI
/// half is the output dock's Level chip — D-6 parity. Flutter-free + trivially
/// testable: the handler takes the Logger and a persist callback.
library;
import 'package:clide/kernel/src/log.dart';
import '../ipc/envelope.dart';
import 'dispatcher.dart';
/// Persists the chosen level name (the owner wires this to
/// `settings.set('app.log.level', name)`).
typedef LogLevelPersist = Future<void> Function(String levelName);
void registerLogCommands(DaemonDispatcher d, Logger log, LogLevelPersist persist) {
d.register('log.level', (req) async {
final raw = req.args['level'];
if (raw == null) {
// Read: report the current level + the vocabulary.
return IpcResponse.ok(id: req.id, data: {'level': log.minLevel.name, 'levels': LogLevel.values.map((l) => l.name).toList()});
}
final level = parseLogLevel(raw is String ? raw : raw.toString());
if (level == null) {
return IpcResponse.err(
id: req.id,
error: IpcError(code: 64, kind: 'bad_arg', message: 'unknown log level: $raw', hint: 'one of: ${LogLevel.values.map((l) => l.name).join(', ')}'),
);
}
log.minLevel = level;
await persist(level.name);
return IpcResponse.ok(id: req.id, data: {'level': level.name});
});
}
+63 -3
View File
@@ -6,19 +6,45 @@ import 'dart:io';
///
/// Linux: `$XDG_RUNTIME_DIR/clide/<hash>.sock`
/// macOS: `$HOME/Library/Caches/clide/<hash>.sock`
/// Windows: `%LOCALAPPDATA%\clide\<hash>.sock` (AF_UNIX — supported
/// by winsock since Windows 10 1803 and by dart:io)
///
/// The C `clide` client and any other consumer derive the same path
/// from the same workspace root, so server + client always agree
/// without configuration.
String workspaceSocketPath(String workspaceRoot) {
final dir = socketDirectory();
return '$dir/${_hash(workspaceRoot)}.sock';
return '$dir/${_hash(canonicalWorkspaceKey(workspaceRoot))}.sock';
}
/// Canonical form of the workspace root used as the FNV hash input.
///
/// On Windows one directory has many spellings — either slash kind,
/// any letter case (NTFS is case-insensitive and getcwd preserves
/// whatever the shell typed) — so the server and the C client could
/// derive different hashes for the same workspace. Backslash +
/// ASCII-lower-case is the canonical spelling; the C client applies
/// the same byte-level fold (which is why this is NOT Unicode
/// `toLowerCase()` — the fold must be reproducible over raw UTF-8
/// bytes in C). POSIX paths pass through untouched.
String canonicalWorkspaceKey(String workspaceRoot) {
if (!Platform.isWindows) return workspaceRoot;
final folded = workspaceRoot.replaceAll('/', r'\');
final units = folded.codeUnits.map((u) => (u >= 0x41 && u <= 0x5a) ? u + 0x20 : u).toList();
return String.fromCharCodes(units);
}
/// Parent directory that holds every per-workspace socket for this
/// user. Created with `0700` on bind (see D-71). Exposed separately
/// so the server can prepare/perm-fix the directory before binding.
/// user. Created with `0700` on bind (see D-71; on Windows the
/// per-user ACL on `%LOCALAPPDATA%` is the equivalent gate). Exposed
/// separately so the server can prepare/perm-fix the directory before
/// binding.
String socketDirectory() {
if (Platform.isWindows) {
final local = Platform.environment['LOCALAPPDATA'];
final base = (local != null && local.isNotEmpty) ? local : '${Platform.environment['USERPROFILE'] ?? r'C:\'}\\AppData\\Local';
return '$base\\clide';
}
if (Platform.isMacOS) {
final home = Platform.environment['HOME'] ?? '/tmp';
return '$home/Library/Caches/clide';
@@ -28,6 +54,40 @@ String socketDirectory() {
return '$base/clide';
}
/// Persistent per-platform directory for crash-survivable logs (T-425).
///
/// Linux: `$XDG_STATE_HOME/clide/logs` (else `$HOME/.local/state/...`)
/// macOS: `$HOME/Library/Logs/clide`
/// Windows: `%LOCALAPPDATA%\clide\logs`
///
/// Unlike [socketDirectory] — which intentionally lives in an EPHEMERAL
/// runtime dir (`$XDG_RUNTIME_DIR`, `~/Library/Caches`) that the OS may wipe
/// on logout/reboot — this is a DURABLE location. The whole point of the
/// FileLogSink is that a freeze's last breadcrumbs survive the power-cycle, so
/// the log dir must outlive a reboot.
///
/// `CLIDE_LOG_DIR` overrides everything: CI points it at a workspace dir
/// outside the build tree so a wedged run's logs can be uploaded as an
/// artifact (T-436), and tests redirect it to a temp dir.
/// [env] defaults to [Platform.environment]; injectable for tests.
String logDirectory([Map<String, String>? env]) {
final e = env ?? Platform.environment;
final override = e['CLIDE_LOG_DIR'];
if (override != null && override.isNotEmpty) return override;
if (Platform.isWindows) {
final local = e['LOCALAPPDATA'];
final base = (local != null && local.isNotEmpty) ? local : '${e['USERPROFILE'] ?? r'C:\'}\\AppData\\Local';
return '$base\\clide\\logs';
}
if (Platform.isMacOS) {
final home = e['HOME'] ?? '/tmp';
return '$home/Library/Logs/clide';
}
final state = e['XDG_STATE_HOME'];
final base = (state != null && state.isNotEmpty) ? state : '${e['HOME'] ?? '/tmp'}/.local/state';
return '$base/clide/logs';
}
/// FNV-1a 64-bit hash of [s] as a 16-char lower-case hex string.
/// The C client (T-126) reproduces the same algorithm byte-for-byte
/// so server + client always agree on socket path. Not cryptographic
+5 -1
View File
@@ -403,8 +403,12 @@ class IpcServer {
// -- internals ------------------------------------------------------------
/// `chmod` via `chmod(1)` because dart:io doesn't expose the
/// syscall on unix. Cheap; only runs at start/stop.
/// syscall on unix. Cheap; only runs at start/stop. No-op on
/// Windows: POSIX modes don't exist there, and the socket lives
/// under `%LOCALAPPDATA%`, whose per-user ACL already provides the
/// user-only gate D-71 wants.
Future<void> _chmod(String path, int modeBits) async {
if (Platform.isWindows) return;
final octal = modeBits.toRadixString(8).padLeft(3, '0');
final r = await Process.run('chmod', [octal, path]);
if (r.exitCode != 0) {
+18 -5
View File
@@ -1,6 +1,6 @@
/// [PaneRegistry] — backend-side state for all live panes.
///
/// Owns the [NativePty] per pane, generates `p_N` ids, and forwards
/// Owns the [PtySession] per pane, generates `p_N` ids, and forwards
/// pty output + lifecycle changes as IPC events via a [DaemonEventSink].
/// Pane commands (pane.spawn / list / write / resize / close) resolve
/// against this registry; extension UIs subscribe to the emitted events.
@@ -12,16 +12,21 @@ import 'dart:io' show Platform;
import 'dart:typed_data';
import '../ipc/envelope.dart';
import '../pty/native_pty.dart';
import '../pty/pty_log.dart';
import '../pty/pty_session.dart';
import 'event_sink.dart';
import 'pane.dart';
class PaneRegistry {
PaneRegistry({required this.events});
PaneRegistry({required this.events, this.ptyLog = PtyLog.none});
final DaemonEventSink events;
/// Breadcrumb hook handed to every PTY this registry spawns (T-434). Default
/// no-op; production wires it to the kernel Logger + a crumb file.
final PtyLog ptyLog;
final Map<String, Pane> _panes = {};
final Map<String, NativePty> _sessions = {};
final Map<String, PtySession> _sessions = {};
final Map<String, StreamSubscription<Uint8List>> _subs = {};
int _nextId = 1;
@@ -55,7 +60,15 @@ class PaneRegistry {
...?env,
};
final session = NativePty.start(executable: executable, arguments: arguments, columns: cols, rows: rows, workingDirectory: cwd, environment: fullEnv);
final session = startPtySession(
executable: executable,
arguments: arguments,
columns: cols,
rows: rows,
workingDirectory: cwd,
environment: fullEnv,
log: ptyLog,
);
final pane = Pane(id: id, kind: kind, pid: session.pid, argv: argv, cwd: cwd, title: title);
_panes[id] = pane;
_sessions[id] = session;
+1 -1
View File
@@ -41,7 +41,7 @@ const Map<String, String> clidePtyEnvDefaults = {
'COLORTERM': 'truecolor',
// Encourages 24-bit emission from tooling that checks this:
'CLICOLOR_FORCE': '1',
// tmux inherits these when clide spawns tmux; safe to propagate.
// UTF-8 locale for the child and anything it spawns; safe to propagate.
'LANG': 'en_US.UTF-8',
'LC_ALL': 'en_US.UTF-8',
};
+51 -15
View File
@@ -25,8 +25,11 @@ import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'errors.dart';
import 'pty_log.dart';
import 'pty_size.dart';
import '../ipc/errno_mapping.dart' show PosixErrno;
import 'ffi/libc.dart' as libc;
import 'pty_session.dart';
// -- structs ----------------------------------------------------------------
@@ -137,8 +140,9 @@ const _kWnohang = 1;
// -- NativePty --------------------------------------------------------------
/// A pseudo-terminal backed by forkpty() via Dart FFI.
class NativePty {
class NativePty implements PtySession {
final int _fd;
@override
final int pid;
final _out = StreamController<Uint8List>.broadcast();
bool _dead = false;
@@ -150,11 +154,18 @@ class NativePty {
ReceivePort? _readerPort;
Completer<void>? _readerExited;
NativePty._(this._fd, this.pid);
/// Breadcrumb file path + verbosity threaded into the reader isolate (T-434).
/// Plain values so they survive `Isolate.spawn`.
final String? _crumbPath;
final bool _verbose;
NativePty._(this._fd, this.pid, this._crumbPath, this._verbose);
/// Byte stream of data produced by the child.
@override
Stream<Uint8List> get output => _out.stream;
@override
bool get isClosed => _dead;
/// Spawn a new PTY running [executable] with [arguments].
@@ -172,7 +183,9 @@ class NativePty {
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) {
log.crumb('native: start exe=$executable');
// Resolve bare command names via PATH (posix_spawn requires an absolute
// or relative path — posix_spawnp would search PATH for us but we want
// resolution to be visible/debuggable from Dart).
@@ -291,8 +304,10 @@ class NativePty {
}
// ---- Spawn -------------------------------------------------------
log.crumb('native: posix_spawn enter');
final spawnRc = _posixSpawn(pidOut, exeN, fa, attr, argvN, envpN);
final pid = pidOut.value;
log.crumb('native: posix_spawn -> rc=$spawnRc pid=$pid');
_faDestroy(fa);
_spawnattrDestroy(attr);
@@ -306,14 +321,14 @@ class NativePty {
// ---- Set initial winsize on the master ---------------------------
final ws = calloc<_Winsize>()
..ref.wsRow = rows
..ref.wsCol = columns;
..ref.wsRow = clampPtyDimension(rows)
..ref.wsCol = clampPtyDimension(columns);
_ioctl(masterFd, _kTiocsWinsz, ws);
calloc.free(ws);
freeAllInputs();
final pty = NativePty._(masterFd, pid);
final pty = NativePty._(masterFd, pid, log.crumbPath, log.verbose);
pty._spawnReader();
return pty;
}
@@ -344,7 +359,7 @@ class NativePty {
}
});
try {
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd));
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _fd, _crumbPath, _verbose));
} catch (e) {
// Surface the spawn failure instead of leaving the PTY in a
// half-alive state where output never flows but isClosed=false.
@@ -358,8 +373,13 @@ class NativePty {
}
/// Isolate entry — polls then reads until EOF/error/fd-closed.
static void _readLoop((SendPort, int) msg) {
final (port, fd) = msg;
static void _readLoop((SendPort, int, String?, bool) msg) {
final (port, fd, crumbPath, verbose) = msg;
// The reader runs in a SPAWNED isolate with no Logger; it opens its own
// append handle so a wedge in read()/poll() leaves its last crumb on disk
// even if the main isolate is frozen too (T-434).
final crumbs = IsolateCrumbFile(crumbPath, 'pty.reader');
crumbs.crumb('reader started fd=$fd');
final dl = ffi.DynamicLibrary.process();
final rd = dl.lookupFunction<ffi.IntPtr Function(ffi.Int32, ffi.Pointer<ffi.Void>, ffi.IntPtr), int Function(int, ffi.Pointer<ffi.Void>, int)>('read');
final poll = dl.lookupFunction<ffi.Int32 Function(ffi.Pointer<_Pollfd>, ffi.Uint32, ffi.Int32), int Function(ffi.Pointer<_Pollfd>, int, int)>('poll');
@@ -369,30 +389,43 @@ class NativePty {
pfd.ref.fd = fd;
pfd.ref.events = libc.pollin;
var reason = 'eof';
try {
while (true) {
final ready = poll(pfd, 1, 100);
if (ready < 0) break;
if (ready < 0) {
reason = 'poll<0';
break;
}
if (ready == 0) continue;
// Slave closed (POLLHUP / POLLERR / POLLNVAL) with no buffered
// bytes left to read — caller loop exits and we send EOF.
if (pfd.ref.revents & libc.pollAnyErr != 0 && pfd.ref.revents & libc.pollin == 0) {
reason = 'pollhup';
break;
}
if (verbose) crumbs.crumb('read enter fd=$fd');
final n = rd(fd, buf.cast(), 65536);
if (n <= 0) break;
if (verbose) crumbs.crumb('read -> n=$n');
if (n <= 0) {
reason = 'read<=0 ($n)';
break;
}
port.send(Uint8List.fromList(buf.asTypedList(n)));
}
} finally {
calloc.free(pfd);
malloc.free(buf);
}
crumbs.crumb('reader exiting ($reason)');
crumbs.close();
port.send(null);
}
/// Write bytes to the child's stdin. Loops on short writes; throws
/// [PtyException] (with errno) on failure. Returns the total bytes
/// written, which is always [bytes.length] on success.
@override
int write(List<int> bytes) {
if (_dead || bytes.isEmpty) return 0;
final buf = malloc<ffi.Uint8>(bytes.length);
@@ -420,11 +453,12 @@ class NativePty {
/// Resize the terminal. Silently no-ops if the fd is already
/// closed; flips [_dead] on EBADF so subsequent calls short-circuit.
@override
void resize({required int cols, required int rows}) {
if (_dead) return;
final ws = calloc<_Winsize>()
..ref.wsRow = rows
..ref.wsCol = cols;
..ref.wsRow = clampPtyDimension(rows)
..ref.wsCol = clampPtyDimension(cols);
final rc = _ioctl(_fd, _kTiocsWinsz, ws);
calloc.free(ws);
if (rc < 0 && libc.errno == PosixErrno.ebadf) {
@@ -435,10 +469,11 @@ class NativePty {
_nativeKill(pid, libc.sigwinch);
}
/// Send a signal to the child.
bool kill([int signal = libc.sighup]) {
/// Send a signal to the child. Null means SIGHUP.
@override
bool kill([int? signal]) {
if (_dead) return false;
return _nativeKill(pid, signal) == 0;
return _nativeKill(pid, signal ?? libc.sighup) == 0;
}
void _reap() {
@@ -462,6 +497,7 @@ class NativePty {
/// closing it before the isolate exits creates a window where the
/// fd number could be reused and the isolate would briefly poll
/// the wrong file.
@override
Future<void> close() async {
if (_dead) return;
_dead = true;
+6 -4
View File
@@ -1,8 +1,10 @@
/// PTY subsystem — spawn child processes under a PTY via posix_openpt()
/// + posix_spawn(), expose their master fd as a byte stream. Desktop
/// IDE's pane model (terminal / Claude / future tmux wrappers) rides on
/// this.
/// PTY subsystem — spawn child processes under a PTY and expose their
/// output as a byte stream. POSIX uses posix_openpt() + posix_spawn();
/// Windows uses ConPTY. Desktop IDE's pane model (terminal / Claude /
/// future PTY-backed panes) rides on this.
library;
export 'env.dart' show clidePtyEnvDefaults, mergePtyEnv;
export 'native_pty.dart' show NativePty;
export 'pty_session.dart' show PtySession, startPtySession;
export 'windows_pty.dart' show WindowsPty;
+116
View File
@@ -0,0 +1,116 @@
/// Breadcrumb logging for the PTY backends (T-434, under the T-425 epic).
///
/// The Windows freeze hypothesis is a wedged FFI call — a reader isolate
/// blocked forever in `ReadFile`, a waiter stuck in `WaitForSingleObject`,
/// `Isolate.kill` unable to interrupt either (dart-lang/sdk#46680). To NAME
/// the wedge after a power-cycle, each backend drops a breadcrumb before and
/// after every risky syscall. There are two delivery paths because the
/// reader/waiter run in SPAWNED isolates that cannot see the main isolate's
/// [Logger] — only sendable values cross `Isolate.spawn`:
///
/// - Main isolate → [PtyLog.crumb], a callback the kernel wires to its
/// Logger (source `pty`/`conpty`, an eager FileLogSink source, so each
/// crumb is fsynced).
/// - Spawned isolates → [IsolateCrumbFile], opened from a plain file path
/// (sendable) so the isolate writes with its OWN append handle and
/// flushSync per line. That is the whole point: a reader wedged in
/// `ReadFile` leaves its last "ReadFile enter" crumb on disk even though
/// the main isolate (and its Logger) may be frozen too.
///
/// Default is fully no-op: callers that pass nothing ([PtyLog.none]) get
/// exactly today's behaviour and zero I/O. Everything swallows its own errors
/// — a logging failure must never perturb the PTY it is observing.
library;
import 'dart:convert';
import 'dart:io';
/// Main-isolate breadcrumb hook handed to a PTY backend.
class PtyLog {
const PtyLog({this.onCrumb, this.crumbPath, this.verbose = false});
/// Called on the main isolate for each lifecycle/syscall breadcrumb. The
/// kernel wires this to `(m) => logger.trace('pty', m)`.
final void Function(String message)? onCrumb;
/// File path the SPAWNED reader/waiter isolates open for their own crumbs.
/// A String (not a closure/Logger) so it survives `Isolate.spawn`. Null
/// disables isolate crumbs.
final String? crumbPath;
/// When true, the high-frequency per-syscall crumbs fire too (debug/trace
/// level). When false, only low-frequency lifecycle crumbs are written, so
/// production wiring stays cheap.
final bool verbose;
/// The no-op default — zero I/O, today's behaviour.
static const none = PtyLog();
/// Emit a main-isolate breadcrumb. Never throws.
void crumb(String message) {
final cb = onCrumb;
if (cb == null) return;
try {
cb(message);
} catch (_) {}
}
}
/// An append-only breadcrumb file for use INSIDE a spawned isolate, where no
/// [Logger] is reachable. Opens [path] once and flushSync per line so a wedge
/// leaves its last crumb on disk. Bounded: truncates back to empty once it
/// passes [capBytes] (we only ever need the tail before a wedge), so a chatty
/// session can't grow it without limit. Every operation swallows its own error.
class IsolateCrumbFile {
IsolateCrumbFile(String? path, this.source, {int capBytes = 256 * 1024}) : _capBytes = capBytes {
if (path == null) return;
try {
final f = File(path);
// Create the parent dir ourselves — a standalone caller (the soak probe)
// may point us at a dir nothing else has made yet. In the app the
// FileLogSink already created logDirectory(), so this is a no-op there.
f.parent.createSync(recursive: true);
final raf = f.openSync(mode: FileMode.append);
_raf = raf;
_size = raf.lengthSync();
} catch (_) {
_raf = null; // a disk problem must never perturb the reader/waiter
}
}
final String source;
final int _capBytes;
RandomAccessFile? _raf;
int _size = 0;
bool get enabled => _raf != null;
/// Append one breadcrumb line, fsynced immediately.
void crumb(String message) {
final raf = _raf;
if (raf == null) return;
try {
if (_size >= _capBytes) {
// Reset to empty: truncate AND rewind. truncateSync alone leaves the
// write position at the old high-water mark, so the next write would
// land past the hole and the file would keep growing (sparse) instead
// of shrinking — rewind to 0 so we actually reclaim the space.
raf.truncateSync(0);
raf.setPositionSync(0);
_size = 0;
}
final bytes = utf8.encode('${DateTime.now().toUtc().toIso8601String()} [$source] $message\n');
raf.writeFromSync(bytes);
raf.flushSync();
_size += bytes.length;
} catch (_) {}
}
void close() {
try {
_raf?.flushSync();
_raf?.closeSync();
} catch (_) {}
_raf = null;
}
}
+75
View File
@@ -0,0 +1,75 @@
/// Platform-neutral PTY session contract + factory.
///
/// The pane registry (and anything else that spawns PTY children)
/// programs against [PtySession]; [startPtySession] picks the
/// platform backend — `posix_openpt` + `posix_spawn` on Linux/macOS
/// ([NativePty]), ConPTY on Windows ([WindowsPty]). Both backends
/// share the same lifecycle: spawn → byte stream out → write/resize
/// in → EOF on child exit → close() reaps.
library;
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'native_pty.dart';
import 'pty_log.dart';
import 'windows_pty.dart';
abstract interface class PtySession {
/// OS process id of the spawned child.
int get pid;
/// Byte stream of data produced by the child.
Stream<Uint8List> get output;
bool get isClosed;
/// Write bytes to the child's stdin. Returns the bytes written.
int write(List<int> bytes);
/// Resize the terminal.
void resize({required int cols, required int rows});
/// Signal the child. [signal] is a POSIX signal number; backends
/// without signals (Windows) treat any value as terminate. Null
/// means the backend's default hang-up behaviour.
bool kill([int? signal]);
/// Kill the child and release resources.
Future<void> close();
}
/// Spawn a child under a PTY using the platform backend.
///
/// [environment] must be the complete environment — it goes straight
/// to the child. Merge `Platform.environment` before calling.
PtySession startPtySession({
required String executable,
List<String> arguments = const [],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) {
if (Platform.isWindows) {
return WindowsPty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
return NativePty.start(
executable: executable,
arguments: arguments,
columns: columns,
rows: rows,
workingDirectory: workingDirectory,
environment: environment,
log: log,
);
}
+15
View File
@@ -0,0 +1,15 @@
/// Minimum dimension handed to any PTY backend.
///
/// A 1-column ConPTY makes the Windows conhost spin emitting CRLF forever
/// (microsoft/terminal#19922), and a 0 in either axis is invalid on both
/// platforms. Every backend clamps its spawn + resize through this, so a
/// degenerate size from the UI — a pane measured at zero width during a
/// transient layout pass — can never wedge a child. The floor (2) is below
/// any real terminal, so the clamp is invisible in normal use.
library;
/// Smallest column/row count a PTY backend will accept.
const int minPtyDimension = 2;
/// Clamp a column or row count up to [minPtyDimension].
int clampPtyDimension(int value) => value < minPtyDimension ? minPtyDimension : value;
+737
View File
@@ -0,0 +1,737 @@
/// Native PTY on Windows via ConPTY (`CreatePseudoConsole`).
///
/// Mirrors the POSIX [NativePty] lifecycle (see `native_pty.dart`):
/// spawn a child attached to a pseudo-console, surface its output as
/// a byte stream, accept writes / resizes / kills, reap on close.
///
/// The Win32 sequence:
///
/// 1. Two anonymous pipes — one ConPTY reads child input from, one
/// it writes rendered VT output to.
/// 2. `CreatePseudoConsole(size, inRead, outWrite)` → `HPCON`. The
/// conpty-side ends (`inRead` / `outWrite`) must stay open for
/// the pseudo console's whole lifetime: on current Windows 11
/// the conpty host runs IN-PROCESS and uses these very handles
/// (the old "conhost dups them, close immediately" advice from
/// the EchoCon sample era silently breaks output — the freed
/// handle slot gets recycled and conhost writes land wherever
/// it now points, observed empirically as output appearing on
/// the parent's console).
/// 3. `CreateProcessW` with `EXTENDED_STARTUPINFO_PRESENT`, the
/// `HPCON` attached via `PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE`,
/// and `STARTF_USESTDHANDLES` with NULL std handles — without
/// that a console parent's std handles leak into the child and
/// its stdout bypasses the conpty entirely (also empirical; the
/// conpty handshake still fires, which makes it look attached).
/// 4. A reader isolate blocks on `ReadFile(outRead)`; a waiter
/// isolate blocks on `WaitForSingleObject(hProcess, INFINITE)`.
/// On child exit the waiter reports back and the main isolate
/// calls `ClosePseudoConsole` and closes the conpty-side pipe
/// ends — that breaks the output pipe, so the reader drains
/// whatever is still buffered, sees `ERROR_BROKEN_PIPE`, and
/// sends EOF.
///
/// Requires Windows 10 1809+ (first ConPTY release). All symbols
/// live in kernel32.dll. Errors carry `GetLastError()` in
/// [PtyException.errno] (a Win32 error code, not a POSIX errno).
library;
import 'dart:async';
import 'dart:ffi' as ffi;
import 'dart:io' show File, Platform;
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'errors.dart';
import 'pty_log.dart';
import 'pty_session.dart';
import 'pty_size.dart';
// coverage:ignore-start
//
// Everything from here to the `resolveExecutable` helper below is the
// Windows-only ConPTY FFI path: Win32 structs, kernel32 bindings, and the
// `WindowsPty` session that calls CreatePseudoConsole / CreateProcessW /
// WaitForSingleObject. The kernel32 symbols all resolve through one
// `DynamicLibrary.open('kernel32.dll')` handle (below), which has no Linux
// equivalent — so any method that touches a binding cannot even be ENTERED
// on the ubuntu-latest runner that produces the coverage report, and the
// syscall sites are genuinely uncoverable off-Windows.
//
// Honest caveat: this span is excluded at FILE granularity, but it is not
// 100% syscall. A few fragments are pure input->output and COULD be unit-
// tested on Linux if extracted into free helpers (the way the three pure
// helpers below already were): the _Coord / _StartupInfoExW struct packing
// and write()'s empty/length guard. They stay entangled here only because
// they share a method with a kernel32 binding; T-431 tracks pulling them
// (and the POSIX marshalling in native_pty.dart) into testable helpers and
// shrinking this ignore span to the raw syscalls.
//
// The pure, platform-agnostic spawn helpers (resolveExecutable / quoteArg /
// composeEnvironmentBlock) sit AFTER the ignore-end below and ARE covered on
// every platform by test/pty/windows_pty_args_test.dart. The FFI path's
// BEHAVIOUR — not its line coverage — is what the Windows runner validates:
// windows.yml spawns real ConPTY children (start/write/resize/kill/errors)
// but collects no coverage, so there is intentionally no line-coverage
// metric for this span anywhere. End-to-end leak/handle correctness is
// proven by the Windows VM soak (tools/windows-verify/).
// -- structs ----------------------------------------------------------------
/// Win32 `COORD` — passed BY VALUE to Create/ResizePseudoConsole.
final class _Coord extends ffi.Struct {
@ffi.Int16()
external int x;
@ffi.Int16()
external int y;
}
/// Win32 `STARTUPINFOEXW`. Field names follow the Win32 struct so the
/// layout is checkable against `<processthreadsapi.h>`; Dart FFI derives
/// offsets from declaration order + C alignment rules, which match MSVC
/// here (cb is followed by 4 bytes of padding before the first pointer).
final class _StartupInfoExW extends ffi.Struct {
@ffi.Uint32()
external int cb;
external ffi.Pointer<ffi.Void> lpReserved;
external ffi.Pointer<ffi.Void> lpDesktop;
external ffi.Pointer<ffi.Void> lpTitle;
@ffi.Uint32()
external int dwX;
@ffi.Uint32()
external int dwY;
@ffi.Uint32()
external int dwXSize;
@ffi.Uint32()
external int dwYSize;
@ffi.Uint32()
external int dwXCountChars;
@ffi.Uint32()
external int dwYCountChars;
@ffi.Uint32()
external int dwFillAttribute;
@ffi.Uint32()
external int dwFlags;
@ffi.Uint16()
external int wShowWindow;
@ffi.Uint16()
external int cbReserved2;
external ffi.Pointer<ffi.Void> lpReserved2;
external ffi.Pointer<ffi.Void> hStdInput;
external ffi.Pointer<ffi.Void> hStdOutput;
external ffi.Pointer<ffi.Void> hStdError;
external ffi.Pointer<ffi.Void> lpAttributeList;
}
/// Win32 `PROCESS_INFORMATION`.
final class _ProcessInformation extends ffi.Struct {
external ffi.Pointer<ffi.Void> hProcess;
external ffi.Pointer<ffi.Void> hThread;
@ffi.Uint32()
external int dwProcessId;
@ffi.Uint32()
external int dwThreadId;
}
// -- FFI bindings -----------------------------------------------------------
final ffi.DynamicLibrary _k32 = ffi.DynamicLibrary.open('kernel32.dll');
typedef _Handle = ffi.Pointer<ffi.Void>;
final _createPipe = _k32
.lookupFunction<
ffi.Int32 Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer<ffi.Void>, ffi.Uint32),
int Function(ffi.Pointer<_Handle>, ffi.Pointer<_Handle>, ffi.Pointer<ffi.Void>, int)
>('CreatePipe');
final _createPseudoConsole = _k32
.lookupFunction<
ffi.Int32 Function(_Coord, _Handle, _Handle, ffi.Uint32, ffi.Pointer<_Handle>),
int Function(_Coord, _Handle, _Handle, int, ffi.Pointer<_Handle>)
>('CreatePseudoConsole');
final _resizePseudoConsole = _k32.lookupFunction<ffi.Int32 Function(_Handle, _Coord), int Function(_Handle, _Coord)>('ResizePseudoConsole');
final _closePseudoConsole = _k32.lookupFunction<ffi.Void Function(_Handle), void Function(_Handle)>('ClosePseudoConsole');
final _initAttrList = _k32
.lookupFunction<
ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Uint32, ffi.Uint32, ffi.Pointer<ffi.IntPtr>),
int Function(ffi.Pointer<ffi.Void>, int, int, ffi.Pointer<ffi.IntPtr>)
>('InitializeProcThreadAttributeList');
final _updateAttr = _k32
.lookupFunction<
ffi.Int32 Function(ffi.Pointer<ffi.Void>, ffi.Uint32, ffi.IntPtr, ffi.Pointer<ffi.Void>, ffi.IntPtr, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>),
int Function(ffi.Pointer<ffi.Void>, int, int, ffi.Pointer<ffi.Void>, int, ffi.Pointer<ffi.Void>, ffi.Pointer<ffi.Void>)
>('UpdateProcThreadAttribute');
final _deleteAttrList = _k32.lookupFunction<ffi.Void Function(ffi.Pointer<ffi.Void>), void Function(ffi.Pointer<ffi.Void>)>('DeleteProcThreadAttributeList');
final _createProcessW = _k32
.lookupFunction<
ffi.Int32 Function(
ffi.Pointer<Utf16>,
ffi.Pointer<Utf16>,
ffi.Pointer<ffi.Void>,
ffi.Pointer<ffi.Void>,
ffi.Int32,
ffi.Uint32,
ffi.Pointer<ffi.Void>,
ffi.Pointer<Utf16>,
ffi.Pointer<_StartupInfoExW>,
ffi.Pointer<_ProcessInformation>,
),
int Function(
ffi.Pointer<Utf16>,
ffi.Pointer<Utf16>,
ffi.Pointer<ffi.Void>,
ffi.Pointer<ffi.Void>,
int,
int,
ffi.Pointer<ffi.Void>,
ffi.Pointer<Utf16>,
ffi.Pointer<_StartupInfoExW>,
ffi.Pointer<_ProcessInformation>,
)
>('CreateProcessW');
final _writeFile = _k32
.lookupFunction<
ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint8>, ffi.Uint32, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>),
int Function(_Handle, ffi.Pointer<ffi.Uint8>, int, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>)
>('WriteFile');
final _closeHandle = _k32.lookupFunction<ffi.Int32 Function(_Handle), int Function(_Handle)>('CloseHandle');
final _getLastError = _k32.lookupFunction<ffi.Uint32 Function(), int Function()>('GetLastError');
final _terminateProcess = _k32.lookupFunction<ffi.Int32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('TerminateProcess');
final _getExitCodeProcess = _k32.lookupFunction<ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint32>), int Function(_Handle, ffi.Pointer<ffi.Uint32>)>(
'GetExitCodeProcess',
);
// Constants — duplicated from <processthreadsapi.h> / <winbase.h>.
const int _kExtendedStartupinfoPresent = 0x00080000;
const int _kCreateUnicodeEnvironment = 0x00000400;
const int _kProcThreadAttributePseudoconsole = 0x00020016;
const int _kInfinite = 0xffffffff;
const int _kErrorBrokenPipe = 109;
const int _kStartfUseStdHandles = 0x00000100;
// -- WindowsPty -------------------------------------------------------------
/// A pseudo-terminal backed by ConPTY via Dart FFI.
class WindowsPty implements PtySession {
WindowsPty._(this._hpc, this._hProcess, this._hThread, this._inWrite, this._outRead, this._conptyInRead, this._conptyOutWrite, this.pid);
/// HPCON — owned until [close] / child exit.
ffi.Pointer<ffi.Void> _hpc;
final ffi.Pointer<ffi.Void> _hProcess;
final ffi.Pointer<ffi.Void> _hThread;
/// Our end of the child-stdin pipe (we write, ConPTY reads).
final ffi.Pointer<ffi.Void> _inWrite;
/// Our end of the child-stdout pipe (ConPTY writes, we read).
final ffi.Pointer<ffi.Void> _outRead;
/// The conpty-side pipe ends. Open for the HPCON's lifetime — the
/// in-process conpty host uses them directly; released together
/// with it in [_closeConsole]. Closing our `_conptyOutWrite` copy
/// is also what finally breaks the pipe for the reader's EOF.
final ffi.Pointer<ffi.Void> _conptyInRead;
final ffi.Pointer<ffi.Void> _conptyOutWrite;
@override
final int pid;
final _out = StreamController<Uint8List>.broadcast();
bool _dead = false;
bool _handlesReleased = false;
Future<void>? _readerReady;
Isolate? _readerIsolate;
ReceivePort? _readerPort;
Completer<void>? _readerExited;
ReceivePort? _waiterPort;
/// Breadcrumb file path + verbosity threaded into the reader/waiter isolates
/// (T-434). Plain values so they survive `Isolate.spawn`.
String? _crumbPath;
bool _verbose = false;
@override
Stream<Uint8List> get output => _out.stream;
@override
bool get isClosed => _dead;
/// Spawn a new ConPTY running [executable] with [arguments].
///
/// [environment] must be the complete environment — it becomes the
/// child's whole environment block. Merge `Platform.environment`
/// before calling.
static WindowsPty start({
required String executable,
List<String> arguments = const [],
required int columns,
required int rows,
String? workingDirectory,
Map<String, String> environment = const {},
PtyLog log = PtyLog.none,
}) {
log.crumb('conpty: start exe=$executable');
executable = resolveExecutable(executable, environment);
// ---- Pipes + pseudo console ---------------------------------------
final ha = calloc<_Handle>();
final hb = calloc<_Handle>();
if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) {
final err = _getLastError();
calloc.free(ha);
calloc.free(hb);
throw PtyException('CreatePipe', 'stdin pipe creation failed', errno: err);
}
final inRead = ha.value;
final inWrite = hb.value;
if (_createPipe(ha, hb, ffi.nullptr, 0) == 0) {
final err = _getLastError();
_closeHandle(inRead);
_closeHandle(inWrite);
calloc.free(ha);
calloc.free(hb);
throw PtyException('CreatePipe', 'stdout pipe creation failed', errno: err);
}
final outRead = ha.value;
final outWrite = hb.value;
calloc.free(ha);
calloc.free(hb);
final size = calloc<_Coord>()
..ref.x = clampPtyDimension(columns)
..ref.y = clampPtyDimension(rows);
final hpcOut = calloc<_Handle>();
log.crumb('conpty: CreatePseudoConsole enter');
final hr = _createPseudoConsole(size.ref, inRead, outWrite, 0, hpcOut);
log.crumb('conpty: CreatePseudoConsole -> hr=$hr');
calloc.free(size);
if (hr != 0) {
_closeHandle(inRead);
_closeHandle(inWrite);
_closeHandle(outRead);
_closeHandle(outWrite);
calloc.free(hpcOut);
throw PtyException('CreatePseudoConsole', 'HRESULT 0x${(hr & 0xffffffff).toRadixString(16)}');
}
final hpc = hpcOut.value;
calloc.free(hpcOut);
// inRead / outWrite deliberately stay open — the in-process conpty
// uses them for its whole lifetime (see the library docstring).
// _closeConsole() releases them together with the HPCON.
// ---- Attribute list (attaches the HPCON to the child) -------------
final sizeOut = calloc<ffi.IntPtr>();
_initAttrList(ffi.nullptr, 1, 0, sizeOut); // sizing call; "fails" with ERROR_INSUFFICIENT_BUFFER by design
final attrBytes = sizeOut.value;
final attrList = calloc<ffi.Uint8>(attrBytes).cast<ffi.Void>();
void freeAttrs() {
calloc.free(attrList);
calloc.free(sizeOut);
}
void bail(String op, String message) {
final err = _getLastError();
freeAttrs();
_closePseudoConsole(hpc);
_closeHandle(inRead);
_closeHandle(outWrite);
_closeHandle(inWrite);
_closeHandle(outRead);
throw PtyException(op, message, errno: err);
}
if (_initAttrList(attrList, 1, 0, sizeOut) == 0) {
bail('InitializeProcThreadAttributeList', 'attribute list init failed');
}
// The HPCON itself is lpValue — the attribute machinery stores the
// pointer, it does NOT copy through it. Passing a pointer-to-slot
// here "succeeds" but hands the child a garbage console and ConPTY
// silently produces no output. (Matches the EchoCon sample.)
if (_updateAttr(attrList, 0, _kProcThreadAttributePseudoconsole, hpc, ffi.sizeOf<_Handle>(), ffi.nullptr, ffi.nullptr) == 0) {
_deleteAttrList(attrList);
bail('UpdateProcThreadAttribute', 'attaching HPCON failed');
}
// ---- Marshal command line + environment + cwd ----------------------
// App name stays null so CreateProcessW does its own first-token
// parse (which also gives .bat/.cmd their cmd.exe host); the
// executable is pre-resolved to an absolute path above so no PATH
// ambiguity is left at this point.
final cmdLine = [executable, ...arguments].map(quoteArg).join(' ').toNativeUtf16(allocator: malloc);
final envBlock = composeEnvironmentBlock(environment).toNativeUtf16(allocator: malloc);
final cwdN = workingDirectory == null ? ffi.nullptr : workingDirectory.toNativeUtf16(allocator: malloc);
// STARTF_USESTDHANDLES with NULL std handles (calloc zeroes them):
// the console subsystem then assigns conpty-backed handles at
// client connect instead of leaking the parent's (docstring §3).
final si = calloc<_StartupInfoExW>()
..ref.cb = ffi.sizeOf<_StartupInfoExW>()
..ref.dwFlags = _kStartfUseStdHandles
..ref.lpAttributeList = attrList;
final pi = calloc<_ProcessInformation>();
log.crumb('conpty: CreateProcessW enter');
final ok = _createProcessW(
ffi.nullptr,
cmdLine,
ffi.nullptr,
ffi.nullptr,
0,
_kExtendedStartupinfoPresent | _kCreateUnicodeEnvironment,
envBlock.cast(),
cwdN.cast(),
si,
pi,
);
final spawnErr = ok == 0 ? _getLastError() : 0;
log.crumb('conpty: CreateProcessW -> ok=$ok err=$spawnErr');
_deleteAttrList(attrList);
freeAttrs();
malloc.free(cmdLine);
malloc.free(envBlock);
if (cwdN != ffi.nullptr) malloc.free(cwdN.cast<ffi.Uint8>());
calloc.free(si);
if (ok == 0) {
calloc.free(pi);
_closePseudoConsole(hpc);
_closeHandle(inRead);
_closeHandle(outWrite);
_closeHandle(inWrite);
_closeHandle(outRead);
throw PtyException('CreateProcessW', 'spawn of $executable failed', errno: spawnErr);
}
final hProcess = pi.ref.hProcess;
final hThread = pi.ref.hThread;
final childPid = pi.ref.dwProcessId;
calloc.free(pi);
final pty = WindowsPty._(hpc, hProcess, hThread, inWrite, outRead, inRead, outWrite, childPid)
.._crumbPath = log.crumbPath
.._verbose = log.verbose;
log.crumb('conpty: spawned pid=$childPid');
pty._spawnReader();
pty._spawnWaiter();
return pty;
}
// -- I/O --------------------------------------------------------------
void _spawnReader() {
_readerReady = _spawnReaderAsync();
}
Future<void> _spawnReaderAsync() async {
final rp = ReceivePort();
_readerPort = rp;
_readerExited = Completer<void>();
rp.listen((msg) {
if (msg == null) {
if (!_out.isClosed) _out.close();
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
_reap();
} else {
if (!_out.isClosed) _out.add(msg as Uint8List);
}
});
try {
_readerIsolate = await Isolate.spawn(_readLoop, (rp.sendPort, _outRead.address, _crumbPath, _verbose));
} catch (e) {
_dead = true;
if (!_out.isClosed) _out.addError(PtyException('reader-spawn', '$e'));
rp.close();
_readerPort = null;
if (!_readerExited!.isCompleted) _readerExited!.complete();
}
}
/// Isolate entry — blocking ReadFile until the ConPTY side closes.
static void _readLoop((SendPort, int, String?, bool) msg) {
final (port, handleAddr, crumbPath, verbose) = msg;
// This isolate is the prime suspect for the freeze: ReadFile blocks
// forever if the ConPTY host never closes the pipe, and Isolate.kill
// can't interrupt the FFI (dart-lang/sdk#46680). It opens its OWN append
// handle so its last "ReadFile enter" crumb survives even a frozen main
// isolate — the breadcrumb that NAMES the wedge after a power-cycle (T-434).
final crumbs = IsolateCrumbFile(crumbPath, 'conpty.reader');
crumbs.crumb('reader started handle=$handleAddr');
final handle = ffi.Pointer<ffi.Void>.fromAddress(handleAddr);
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
final readFile = k32
.lookupFunction<
ffi.Int32 Function(_Handle, ffi.Pointer<ffi.Uint8>, ffi.Uint32, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>),
int Function(_Handle, ffi.Pointer<ffi.Uint8>, int, ffi.Pointer<ffi.Uint32>, ffi.Pointer<ffi.Void>)
>('ReadFile');
final buf = malloc<ffi.Uint8>(65536);
final nRead = calloc<ffi.Uint32>();
var reason = 'eof';
try {
while (true) {
// Blocks until data, broken pipe (ConPTY closed), or invalid
// handle (close() already released it).
if (verbose) crumbs.crumb('ReadFile enter');
final ok = readFile(handle, buf, 65536, nRead, ffi.nullptr);
if (verbose) crumbs.crumb('ReadFile -> ok=$ok n=${nRead.value}');
if (ok == 0) {
reason = 'broken-pipe/invalid';
break;
}
final n = nRead.value;
if (n == 0) {
reason = 'n=0';
break;
}
port.send(Uint8List.fromList(buf.asTypedList(n)));
}
} finally {
calloc.free(nRead);
malloc.free(buf);
}
crumbs.crumb('reader exiting ($reason)');
crumbs.close();
port.send(null);
}
/// Watches for child exit so the pseudo console can be torn down —
/// without ClosePseudoConsole the output pipe never breaks and the
/// reader would block forever on an exited child.
void _spawnWaiter() {
final wp = ReceivePort();
_waiterPort = wp;
wp.listen((_) {
wp.close();
_waiterPort = null;
_closeConsole();
});
Isolate.spawn(_waitLoop, (wp.sendPort, _hProcess.address, _crumbPath)).catchError((Object e) {
// Fall back to close()-driven teardown; the child just won't be
// auto-reaped on self-exit.
wp.close();
_waiterPort = null;
return Isolate.current; // satisfies the Future<Isolate> type; unused
});
}
static void _waitLoop((SendPort, int, String?) msg) {
final (port, handleAddr, crumbPath) = msg;
final crumbs = IsolateCrumbFile(crumbPath, 'conpty.waiter');
crumbs.crumb('waiter started; WaitForSingleObject(INFINITE) enter');
final k32 = ffi.DynamicLibrary.open('kernel32.dll');
final wait = k32.lookupFunction<ffi.Uint32 Function(_Handle, ffi.Uint32), int Function(_Handle, int)>('WaitForSingleObject');
final r = wait(ffi.Pointer<ffi.Void>.fromAddress(handleAddr), _kInfinite);
crumbs.crumb('WaitForSingleObject -> $r (child exited)');
crumbs.close();
port.send(null);
}
@override
int write(List<int> bytes) {
if (_dead || bytes.isEmpty) return 0;
final buf = malloc<ffi.Uint8>(bytes.length);
final nWritten = calloc<ffi.Uint32>();
try {
for (var i = 0; i < bytes.length; i++) {
buf[i] = bytes[i];
}
var written = 0;
while (written < bytes.length) {
final ok = _writeFile(_inWrite, buf + written, bytes.length - written, nWritten, ffi.nullptr);
if (ok == 0) {
final err = _getLastError();
if (err == _kErrorBrokenPipe) _dead = true;
throw PtyException('WriteFile', 'write to ConPTY failed', errno: err);
}
if (nWritten.value == 0) break;
written += nWritten.value;
}
return written;
} finally {
malloc.free(buf);
calloc.free(nWritten);
}
}
@override
void resize({required int cols, required int rows}) {
if (_dead || _hpc == ffi.nullptr) return;
final size = calloc<_Coord>()
..ref.x = clampPtyDimension(cols)
..ref.y = clampPtyDimension(rows);
_resizePseudoConsole(_hpc, size.ref);
calloc.free(size);
}
/// Windows has no signals — any [signal] terminates the child.
@override
bool kill([int? signal]) {
if (_dead || _handlesReleased) return false;
return _terminateProcess(_hProcess, 1) != 0;
}
/// Close the HPCON and the conpty-side pipe ends, once. With every
/// write end of the output pipe gone the reader drains what's left
/// and EOFs.
void _closeConsole() {
final hpc = _hpc;
if (hpc == ffi.nullptr) return;
_hpc = ffi.nullptr;
_closePseudoConsole(hpc);
_closeHandle(_conptyInRead);
_closeHandle(_conptyOutWrite);
}
void _reap() {
if (_dead) return;
_dead = true;
_closeConsole();
_releaseHandles();
}
void _releaseHandles() {
if (_handlesReleased) return;
_handlesReleased = true;
final code = calloc<ffi.Uint32>();
_getExitCodeProcess(_hProcess, code);
calloc.free(code);
_closeHandle(_inWrite);
_closeHandle(_outRead);
_closeHandle(_hThread);
_closeHandle(_hProcess);
}
/// Kill the child and release resources.
///
/// Order matters, mirroring the POSIX close(): terminate the child,
/// break the output pipe (ClosePseudoConsole), wait for the reader
/// to EOF so nothing touches the handles after we close them.
@override
Future<void> close() async {
if (_dead) return;
_dead = true;
await _readerReady;
_terminateProcess(_hProcess, 1);
_closeConsole();
if (_readerExited != null) {
await _readerExited!.future.timeout(const Duration(milliseconds: 500), onTimeout: () {});
}
_readerIsolate?.kill(priority: Isolate.immediate);
_readerIsolate = null;
_readerPort?.close();
_readerPort = null;
_waiterPort?.close();
_waiterPort = null;
_releaseHandles();
if (!_out.isClosed) await _out.close();
}
// coverage:ignore-end
// -- spawn helpers ------------------------------------------------------
/// Resolve a bare command name against the environment's PATH +
/// PATHEXT (mirrors what the POSIX side does with `:`-split PATH —
/// visible/debuggable resolution instead of CreateProcess magic).
///
/// [exists] overrides the on-disk probe so the resolution logic is
/// unit-testable off-Windows; production passes the default. Public for
/// that reason — not part of the backend's external contract.
static String resolveExecutable(String executable, Map<String, String> environment, {bool Function(String path)? exists}) {
final fileExists = exists ?? ((String path) => File(path).existsSync());
final pathext = (environment['PATHEXT'] ?? Platform.environment['PATHEXT'] ?? '.COM;.EXE;.BAT;.CMD').split(';').where((e) => e.isNotEmpty).toList();
final hasKnownExt = pathext.any((e) => executable.toLowerCase().endsWith(e.toLowerCase()));
Iterable<String> candidates(String base) sync* {
if (hasKnownExt) {
yield base;
} else {
yield base;
for (final ext in pathext) {
yield '$base$ext';
}
}
}
if (executable.contains('\\') || executable.contains('/')) {
for (final c in candidates(executable)) {
if (fileExists(c)) return c;
}
return executable;
}
final path = environment['PATH'] ?? Platform.environment['PATH'] ?? '';
for (final dir in path.split(';')) {
if (dir.isEmpty) continue;
for (final c in candidates('$dir\\$executable')) {
if (fileExists(c)) return c;
}
}
return executable;
}
/// Quote one argument per MSVCRT command-line parsing rules. Public so the
/// quoting rules can be unit-tested off-Windows; not an external contract.
static String quoteArg(String arg) {
if (arg.isNotEmpty && !arg.contains(RegExp(r'[ \t"\n\v]'))) return arg;
final b = StringBuffer('"');
var backslashes = 0;
for (final ch in arg.runes) {
final c = String.fromCharCode(ch);
if (c == r'\') {
backslashes++;
continue;
}
if (c == '"') {
b.write(r'\' * (backslashes * 2 + 1));
b.write('"');
backslashes = 0;
continue;
}
if (backslashes > 0) {
b.write(r'\' * backslashes);
backslashes = 0;
}
b.write(c);
}
b.write(r'\' * (backslashes * 2));
b.write('"');
return b.toString();
}
/// Compose the body of a CREATE_UNICODE_ENVIRONMENT block: `K=V\0...\0`
/// with one trailing NUL, entries sorted case-insensitively by key per
/// CreateProcess docs. The caller nativizes via `toNativeUtf16`, whose own
/// terminator completes the required double-NUL ending (which also keeps an
/// empty environment block valid). Public for unit testing.
static String composeEnvironmentBlock(Map<String, String> environment) {
final entries = environment.entries.toList()..sort((a, b) => a.key.toUpperCase().compareTo(b.key.toUpperCase()));
// NUL via fromCharCode — an inline NUL escape in a string literal
// is invisible in review and trips up text tooling.
final nul = String.fromCharCode(0);
final joined = entries.map((e) => '${e.key}=${e.value}$nul').join();
return '$joined$nul';
}
}
+77
View File
@@ -4,6 +4,8 @@
/// of app.dart (T-394).
library;
import 'dart:async';
import 'package:clide/builtin/menubar/menubar.dart';
import 'package:clide/builtin/welcome/src/welcome_view.dart';
import 'package:clide/kernel/kernel.dart';
@@ -31,17 +33,33 @@ class RootShellState extends State<RootShell> {
// (T-341, T-409).
final ModifierTapTracker _modTap = ModifierTapTracker();
// Global multi-chord matcher for window/tab commands (ctrl+w h, gt …) (T-404).
// The passive KeyboardListener can't run sequences or consume the second
// chord (a focused editor/pane swallows it), so this lives at the
// HardwareKeyboard level where returning true consumes the event before focus
// dispatch. It only engages for chords that START a multi-chord binding in the
// active keymap, so single-chord presets (default/vscode/jetbrains) are
// untouched.
late final SequenceMatcher _globalSeq;
Timer? _seqTimeout;
@override
void initState() {
super.initState();
_keyFocus = FocusNode()..requestFocus();
widget.services.textZoom.addListener(_onZoom);
_globalSeq = SequenceMatcher(
keymap: () => widget.services.keymap.keymap ?? Keymap(const []),
context: () => widget.services.keymap.scope,
captureCounts: false,
);
HardwareKeyboard.instance.addHandler(_onRawKey);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_onRawKey);
_seqTimeout?.cancel();
widget.services.textZoom.removeListener(_onZoom);
_menuBar.dispose();
_keyFocus.dispose();
@@ -171,6 +189,9 @@ class RootShellState extends State<RootShell> {
/// (the `;` of `Shift+;`) still dirties the press (T-341, T-409). Fires on
/// the second clean *release*; never consumes anything.
bool _onRawKey(KeyEvent event) {
// Global window/tab sequences (ctrl+w h, gt …) get first claim — handled
// here so a focused editor/pane can't swallow the second chord (T-404).
if (_handleGlobalSequence(event)) return true;
if (event is KeyDownEvent) {
var mod = KeyChord.modifierForLogicalKey(event.logicalKey);
// A modifier pressed while a non-modifier is already held (rolled
@@ -190,6 +211,62 @@ class RootShellState extends State<RootShell> {
bool _nonModifierHeld() => HardwareKeyboard.instance.logicalKeysPressed.any((k) => KeyChord.modifierForLogicalKey(k) == null);
/// Feed one key into the global multi-chord matcher (T-404). Returns true to
/// CONSUME the event (suppressing focus dispatch) while a sequence is being
/// built or completes; false leaves the normal single-chord [_onKey] path
/// untouched. Only KeyDown events drive it — a held key must not re-fire a
/// window command.
bool _handleGlobalSequence(KeyEvent event) {
if (event is! KeyDownEvent) return false;
final chord = KeyChord.fromKeyEvent(event, HardwareKeyboard.instance);
if (chord == null) return false;
final km = widget.services.keymap.keymap;
if (km == null) return false;
final scope = widget.services.keymap.scope;
// Not mid-sequence: only START on a MODIFIED chord that's a sequence prefix
// (ctrl+w …). Bare-key sequences (gg, dd) are editor/pane-local — the
// focused widget owns them, so a global grab would steal the first chord
// before the editor ever saw it. Once pending, the bare second chord (the
// `h` of `ctrl+w h`) is consumed normally. Single-chord presets are
// untouched (no prefix → no engage).
if (!_globalSeq.hasPending) {
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
if (!modified || !km.match([chord], scope).isPrefix) return false;
}
final r = _globalSeq.feed(chord);
switch (r.outcome) {
case SeqOutcome.pending:
_armSeqTimeout();
return true;
case SeqOutcome.fired:
_cancelSeqTimeout();
_dispatchIntent(r.intent!);
return true;
case SeqOutcome.unmatched:
// The sequence broke — drop the buffer and let this lone key through to
// normal handling (the abandoned prefix, e.g. a bare ctrl+w, simply
// does nothing rather than firing late).
_cancelSeqTimeout();
return false;
}
}
/// After a pending prefix, fire its buffered exact match (bare ctrl+w →
/// editor.close) if no completing chord arrives in time — the d-vs-dd timeout
/// (D-82), applied globally.
void _armSeqTimeout() {
_seqTimeout?.cancel();
_seqTimeout = Timer(const Duration(milliseconds: 400), () {
final r = _globalSeq.flush();
if (r.outcome == SeqOutcome.fired) _dispatchIntent(r.intent!);
});
}
void _cancelSeqTimeout() {
_seqTimeout?.cancel();
_seqTimeout = null;
}
void _dispatchIntent(Intent intent) {
// Try the focused context first so feature widgets (palette, editor, …)
// get a chance to handle their own intents; fall back to the app root's
+33 -7
View File
@@ -31,6 +31,7 @@ import 'kernel/kernel.dart';
import 'src/pty/ffi/libc.dart' as libc;
import 'src/daemon/pane_commands.dart';
import 'src/ipc/envelope.dart';
import 'src/ipc/paths.dart' show logDirectory;
import 'src/panes/event_sink.dart';
import 'src/panes/registry.dart';
import 'src/daemon/dispatcher.dart';
@@ -59,6 +60,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
@override
void initState() {
super.initState();
_attachCrashLogging();
WidgetsBinding.instance.addPostFrameCallback((_) => _runTests());
Timer(_timeout, () {
_say('timeout reached — exiting');
@@ -66,6 +68,28 @@ class _ClideTestAppState extends State<ClideTestApp> {
});
}
/// Opt-in crash evidence for testmode (T-436): when `CLIDE_LOG_DIR` is set
/// (CI / a manual Windows repro), tee this harness's logger to a
/// FileLogSink and spawn the watchdog, so a wedged testmode run leaves the
/// same log + watchdog files the real app would. `_say` breadcrumbs each
/// test through the logger, so they land in the file too. Off by default —
/// normal `make run-testmode` keeps the stderr-only path, no isolate.
void _attachCrashLogging() {
final dir = Platform.environment['CLIDE_LOG_DIR'];
if (dir == null || dir.isEmpty) return;
final logDir = logDirectory();
try {
_logger.addSink(FileLogSink(dir: Directory(logDir)).call);
} catch (_) {}
unawaited(_spawnWatchdog(logDir));
}
Future<void> _spawnWatchdog(String logDir) async {
try {
await Isolate.spawn(watchdogEntry, ('$logDir/clide-watchdog.log', 500, 2000));
} catch (_) {}
}
Future<void> _runTests() async {
const workspace = String.fromEnvironment('CLIDE_PROJECT');
const category = String.fromEnvironment('CLIDE_TESTMODE');
@@ -113,27 +137,29 @@ class _ClideTestAppState extends State<ClideTestApp> {
_say('--- toolchain ---');
_log('toolchain.git', tc.git);
_log('toolchain.pql', tc.pql);
_log('toolchain.tmux', tc.tmux);
_log('toolchain.shell', tc.shell);
_log('toolchain.missing', tc.missing.isEmpty ? 'none' : tc.missing.join(', '));
_say('');
await _testExists('git', tc.git);
await _testExists('pql', tc.pql);
await _testExists('tmux', tc.tmux);
await _testExists('shell', tc.shell);
_say('');
await _testExec('git --version', tc.git, ['--version'], workDir);
await _testExec('pql --version', tc.pql, ['--version'], workDir);
await _testExec('tmux -V', tc.tmux, ['-V'], workDir);
await _testExec('shell --version', tc.shell, ['--version'], workDir);
// PowerShell has no --version flag; ask for the version variable
// through the same -c path the passthrough tests use.
await _testExec('shell --version', tc.shell, Platform.isWindows ? ['-c', r'$PSVersionTable.PSVersion.ToString()'] : ['--version'], workDir);
_say('');
// Shell passthrough — use the resolved shell, not a hardcoded path
await _testExec('shell -c git', tc.shell, ['-c', '${tc.git} --version'], workDir);
await _testExec('shell -c pql', tc.shell, ['-c', '${tc.pql} --version'], workDir);
await _testExec('shell -c tmux', tc.shell, ['-c', '${tc.tmux} -V'], workDir);
// (-c works for POSIX shells and as PowerShell's -Command alias).
// PowerShell needs the & call operator to run a quoted path; POSIX
// shells take the bare path.
String shellCall(String exe, String args) => Platform.isWindows ? "& '$exe' $args" : '$exe $args';
await _testExec('shell -c git', tc.shell, ['-c', shellCall(tc.git, '--version')], workDir);
await _testExec('shell -c pql', tc.shell, ['-c', shellCall(tc.pql, '--version')], workDir);
await _testExec('shell -c git (bare)', tc.shell, ['-c', 'git --version'], workDir);
_say('');
+5 -5
View File
@@ -5,15 +5,15 @@
/// re-derive the same four things: a [LayerLink] + [CompositedTransformFollower]
/// (or a hand-rolled `Positioned`), a full-screen tap-away barrier, the
/// `Overlay.insert` / `OverlayEntry` bookkeeping, and post-frame focus capture.
/// This widget owns all of it; callers supply the trigger ([anchor]) and the
/// floating content ([overlayBuilder]). Modal, centred dialogs stay on the
/// This widget owns all of it; callers supply the trigger (`anchor`) and the
/// floating content (`overlayBuilder`). Modal, centred dialogs stay on the
/// kernel `DialogRouter` — this is for anchored, non-modal popovers.
library;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Primary placement of the floating panel relative to the [anchor].
/// Primary placement of the floating panel relative to the `anchor`.
enum ClideAnchorSide { below, above, left, right }
/// Cross-axis alignment of the panel's edge to the anchor's edge.
@@ -41,8 +41,8 @@ class ClideOverlayController extends ChangeNotifier {
void toggle() => _open ? close() : open();
}
/// Wraps [anchor] with a [CompositedTransformTarget] and, while [controller] is
/// open, inserts an [OverlayEntry] built from [overlayBuilder], positioned
/// Wraps `anchor` with a [CompositedTransformTarget] and, while [controller] is
/// open, inserts an [OverlayEntry] built from `overlayBuilder`, positioned
/// relative to the anchor (or centred when [centered]).
class ClideAnchoredOverlay extends StatefulWidget {
const ClideAnchoredOverlay({
+4 -4
View File
@@ -4,9 +4,9 @@
/// list of one: there is no separate single-card path, which keeps the model
/// uniform and reliable.
///
/// - **Collapsed** (default): a one-line ticker — the [label], the echoed
/// [collapsedSummary] (the run's latest content line), a fixed-width [counter]
/// ("3 steps"), and the aggregate [status] (spinner / check / cross). The
/// - **Collapsed** (default): a one-line ticker — the `label`, the echoed
/// `collapsedSummary` (the run's latest content line), a fixed-width `counter`
/// ("3 steps"), and the aggregate `status` (spinner / check / cross). The
/// whole row is the toggle.
/// - **Expanded**: a framed inner canvas wrapping the item cards; clicking the
/// frame BACKGROUND (padding, the gaps between items, the gutter — anywhere an
@@ -16,7 +16,7 @@
///
/// Chrome is consistent across every collapser: the chevron is hard against the
/// LEFT edge, the status icon hard against the RIGHT edge, the counter sits in a
/// fixed-width slot just inboard of it, and [color] drives the border + the
/// fixed-width slot just inboard of it, and `color` drives the border + the
/// chevron/label tint so each instance keeps its visual identity through one
/// widget. The inner item cards are content (they keep their OWN per-item status
/// + stripe); the aggregate status/count/title shown here are computed by the
+1 -1
View File
@@ -1,5 +1,5 @@
/// Full-screen zoom + pan overlay (T-252 / D-78). A reusable primitive: it
/// takes any [child] and shows it over the [DialogRouter]'s dimmed backdrop
/// takes any `child` and shows it over the [DialogRouter]'s dimmed backdrop
/// (the host supplies the backdrop + outside-click dismiss). The image card is
/// its first consumer; canvas / graph / diff previews can adopt it later.
///
+2 -2
View File
@@ -1,6 +1,6 @@
/// Horizontal marquee (T-150). Shows [child] statically when it fits the
/// Horizontal marquee (T-150). Shows `child` statically when it fits the
/// available width; when it's wider, scrolls it leftward in a seamless
/// loop (a second copy follows after [gap]). Clips to its box. Used by
/// loop (a second copy follows after `gap`). Clips to its box. Used by
/// the status-bar slot so a long pane status doesn't get truncated.
///
/// Own-the-stack: a `Ticker`-driven `SingleChildScrollView`, no package.
+3 -3
View File
@@ -1,12 +1,12 @@
/// Behaviour-only wrapper every pane uses (T-150). No chrome — that's
/// [ClidePaneChrome]'s job. ClidePane handles cross-pane uniformity:
/// surfacing the pane's [statusWidget] to the bottom status bar while the
/// surfacing the pane's `statusWidget` to the bottom status bar while the
/// pane is focused, via [FocusTracker.setStatusWidget].
///
/// The widget lives with the pane; ClidePane only conveys it to the
/// shared slot while this pane is the shown one (its contribution is
/// focused and, for multi-pane contributions, it's the [active] sub-tab),
/// and re-conveys whenever [statusWidget] changes. A backgrounded pane
/// focused and, for multi-pane contributions, it's the `active` sub-tab),
/// and re-conveys whenever `statusWidget` changes. A backgrounded pane
/// keeps its content locally and re-conveys on regaining focus.
library;
+2 -2
View File
@@ -2,11 +2,11 @@
///
/// The host owns the text parsing + completion (where the `@`/`/` token is, how
/// to filter, how to rewrite the text on select); `ClideTypeahead` owns the
/// anchored overlay + the suggestion list. It is driven by [suggestions]
/// anchored overlay + the suggestion list. It is driven by `suggestions`
/// non-empty shows the popover above the field, empty hides it. Unlike a menu,
/// it does NOT capture focus or install a tap-away barrier: the text field keeps
/// focus (you're still typing), and the host closes it on text change / blur /
/// Esc. Pass [navController] to drive the highlight from the field's own key
/// Esc. Pass `navController` to drive the highlight from the field's own key
/// handler (the slash typeahead does this while the EditableText keeps focus);
/// omit it for a mouse-only list (the @-mention).
library;
+1 -1
View File
@@ -16,7 +16,7 @@ typedef MultitabEntryCallback<T> = void Function(MultitabEntry<T> entry);
///
/// The widget is generic and domain-free: it never knows what's
/// inside a tab. Hosts pick `T` and decide what add / close mean
/// (e.g. spawning or killing a tmux session for the Claude pane).
/// (e.g. spawning or killing a Claude session for the Claude pane).
///
/// See `docs/design/multitab-pane.md` for the design rationale.
class MultitabPane<T> extends StatelessWidget {
+156 -41
View File
@@ -7,7 +7,9 @@
* root, same definition the Flutter app uses on boot).
* 2. Hashes that path with FNV-1a 64-bit and resolves the per-
* workspace socket path per D-70 (Linux: $XDG_RUNTIME_DIR/clide/
* <hash>.sock; macOS: $HOME/Library/Caches/clide/<hash>.sock).
* <hash>.sock; macOS: $HOME/Library/Caches/clide/<hash>.sock;
* Windows: %LOCALAPPDATA%\clide\<hash>.sock AF_UNIX works on
* Windows 10 1803+ via afunix.h).
* 3. Connects, sends `{"v":1,"type":"request","id":"<pid>",
* "cmd":"_argv","args":{"argv":[...]}}` (the server runs
* parseArgv on it per T-125), reads the JSON-line response,
@@ -15,21 +17,40 @@
* exits with the response's exit code.
*
* Design notes:
* - No third-party deps. Standard POSIX + a minimal JSON writer
* (string-escape only we never PARSE JSON, just emit argv into
* it; the response is read whole then printed as-is to stdout).
* - No third-party deps. Standard POSIX / Win32 + a minimal JSON
* writer (string-escape only we never PARSE JSON, just emit argv
* into it; the response is read whole then printed as-is).
* - The argvIpcRequest translator lives in Dart (T-125). We just
* ship argv across the wire under a sentinel cmd `_argv`; the
* server unpacks it.
* - Workspace-root discovery: we look for `.git` (dir OR file
* submodules use a file). If we don't find one walking upward,
* exit with EX_USAGE.
* - Windows hashes the CANONICAL workspace key: backslash
* separators + ASCII-lower-cased UTF-8 bytes, matching
* `canonicalWorkspaceKey` in lib/src/ipc/paths.dart. NTFS is
* case-insensitive, so the same workspace can be spelled many
* ways; both sides fold to one spelling before hashing.
*
* Build: `make clide-cli` (see Makefile). Pure C99, builds with
* any gcc / clang / cc.
* Build: `make clide-cli` (see Makefile). Pure C99: gcc / clang / cc
* on POSIX, MSVC cl (+ ws2_32.lib) on Windows.
*/
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <winsock2.h>
#include <afunix.h>
#include <windows.h>
#include <process.h>
#else
#define _POSIX_C_SOURCE 200809L
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/un.h>
#include <unistd.h>
#endif
#include <ctype.h>
#include <errno.h>
#include <inttypes.h>
@@ -37,11 +58,6 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/un.h>
#include <unistd.h>
#ifdef __APPLE__
#include <TargetConditionals.h>
@@ -52,6 +68,32 @@
#define EX_OSERR 71
#define EX_UNAVAILABLE 69
/* -- tiny platform shim ------------------------------------------------- */
#ifdef _WIN32
typedef SOCKET sock_t;
#define NET_INVALID INVALID_SOCKET
static int net_read(sock_t s, char *buf, int n) { return recv(s, buf, n, 0); }
static int net_write(sock_t s, const char *buf, int n) { return send(s, buf, n, 0); }
static void net_close(sock_t s) { closesocket(s); }
static int net_errno(void) { return WSAGetLastError(); }
static const char *net_strerror(int e) {
static char msg[256];
snprintf(msg, sizeof(msg), "winsock error %d", e);
return msg;
}
#define clide_getpid _getpid
#else
typedef int sock_t;
#define NET_INVALID (-1)
static int net_read(sock_t s, char *buf, int n) { return (int)read(s, buf, (size_t)n); }
static int net_write(sock_t s, const char *buf, int n) { return (int)write(s, buf, (size_t)n); }
static void net_close(sock_t s) { close(s); }
static int net_errno(void) { return errno; }
static const char *net_strerror(int e) { return strerror(e); }
#define clide_getpid getpid
#endif
static const uint64_t FNV_OFFSET = 0xcbf29ce484222325ULL;
static const uint64_t FNV_PRIME = 0x100000001b3ULL;
@@ -65,6 +107,64 @@ static void fnv1a64_hex(const char *s, char out[17]) {
snprintf(out, 17, "%016" PRIx64, h);
}
#ifdef _WIN32
/* Walk CWD upward looking for `.git` using the wide API (the path can
* contain anything; ANSI getcwd would mangle non-ACP characters), then
* emit the CANONICAL UTF-8 key: backslashes + ASCII-folded lower case.
* Mirrors `canonicalWorkspaceKey` in lib/src/ipc/paths.dart. */
static int find_workspace_root(const char *start, char *out, size_t out_size) {
(void)start; /* CWD-only on Windows; start override is unused. */
wchar_t cwd[4096];
DWORD n = GetCurrentDirectoryW(4096, cwd);
if (n == 0 || n >= 4096) return -1;
while (1) {
size_t len = wcslen(cwd);
wchar_t probe[4200];
_snwprintf(probe, 4200, (len > 0 && cwd[len - 1] == L'\\') ? L"%s.git" : L"%s\\.git", cwd);
probe[4199] = L'\0';
if (GetFileAttributesW(probe) != INVALID_FILE_ATTRIBUTES) {
int r = WideCharToMultiByte(CP_UTF8, 0, cwd, -1, out, (int)out_size, NULL, NULL);
if (r <= 0) return -1;
/* Canonical fold: '/' -> '\', ASCII upper -> lower. UTF-8
* continuation bytes have the high bit set, so the ASCII
* fold never touches multi-byte sequences. */
for (char *p = out; *p; p++) {
if (*p == '/') *p = '\\';
else if (*p >= 'A' && *p <= 'Z') *p = (char)(*p + 32);
}
return 0;
}
/* Climb one. `C:\foo` -> `C:\`; stop once the drive/UNC root
* itself has been probed. */
wchar_t *slash = wcsrchr(cwd, L'\\');
if (!slash) return -1;
if (len <= 3 && cwd[1] == L':') return -1; /* at "X:\" already */
if (slash == cwd + 2 && cwd[1] == L':') {
cwd[3] = L'\0'; /* keep the root's backslash: "X:\" */
} else if (slash == cwd) {
return -1;
} else {
*slash = L'\0';
}
}
}
/* `%LOCALAPPDATA%\clide\<hash>.sock` */
static int socket_path_for(const char *workspace_root, char *out, size_t out_size) {
char hash[17];
fnv1a64_hex(workspace_root, hash);
const char *local = getenv("LOCALAPPDATA");
if (!local || !*local) {
const char *prof = getenv("USERPROFILE");
if (!prof || !*prof) return -1;
return snprintf(out, out_size, "%s\\AppData\\Local\\clide\\%s.sock", prof, hash);
}
return snprintf(out, out_size, "%s\\clide\\%s.sock", local, hash);
}
#else /* !_WIN32 */
/* Walk `start` upward looking for an entry named `.git`. Writes the
* containing directory into `out` (PATH_MAX). Returns 0 on success,
* -1 if no .git was found before /. */
@@ -112,25 +212,39 @@ static int socket_path_for(const char *workspace_root, char *out, size_t out_siz
#endif
}
/* Open a UNIX-domain stream socket connected to `path`. Returns fd
* on success, -1 on failure (errno set). */
static int connect_unix(const char *path) {
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) return -1;
#endif /* _WIN32 */
/* Open a UNIX-domain stream socket connected to `path`. Returns the
* socket on success, NET_INVALID on failure (net_errno() set). */
static sock_t connect_unix(const char *path) {
#ifdef _WIN32
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return NET_INVALID;
SOCKADDR_UN addr;
#else
struct sockaddr_un addr;
#endif
sock_t fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd == NET_INVALID) return NET_INVALID;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
if (strlen(path) >= sizeof(addr.sun_path)) {
close(fd);
net_close(fd);
#ifndef _WIN32
errno = ENAMETOOLONG;
return -1;
#endif
return NET_INVALID;
}
strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
int saved = errno;
close(fd);
int saved = net_errno();
net_close(fd);
#ifndef _WIN32
errno = saved;
return -1;
#else
WSASetLastError(saved);
#endif
return NET_INVALID;
}
return fd;
}
@@ -165,11 +279,11 @@ static void json_escape(const char *s, char *out, size_t out_size) {
/* Build the request envelope and write it to `out`. Returns 0 on
* success, -1 if any input was too large. */
static int build_request(int argc, char **argv, pid_t pid, char *out, size_t out_size) {
static int build_request(int argc, char **argv, long long pid, char *out, size_t out_size) {
/* Compute argv array size: each arg gets its own escaped JSON. */
int n = snprintf(out, out_size,
"{\"type\":\"request\",\"v\":1,\"id\":\"c%lld\",\"cmd\":\"_argv\",\"args\":{\"argv\":[",
(long long)pid);
pid);
if (n < 0 || (size_t)n >= out_size) return -1;
for (int i = 0; i < argc; i++) {
char esc[4096];
@@ -181,15 +295,17 @@ static int build_request(int argc, char **argv, pid_t pid, char *out, size_t out
return (n < 0 || (size_t)n >= out_size) ? -1 : 0;
}
/* Read one line (terminated by \n) from fd into out. Returns 0 on
* success, -1 on EOF / error. The trailing \n is stripped. */
static int read_line(int fd, char *out, size_t out_size) {
/* Read one line (terminated by \n) from the socket into out. Returns
* 0 on success, -1 on EOF / error. The trailing \n is stripped. */
static int read_line(sock_t fd, char *out, size_t out_size) {
size_t i = 0;
while (i + 1 < out_size) {
char c;
ssize_t r = read(fd, &c, 1);
int r = net_read(fd, &c, 1);
if (r <= 0) {
#ifndef _WIN32
if (r < 0 && errno == EINTR) continue;
#endif
return -1;
}
if (c == '\n') {
@@ -263,32 +379,31 @@ int main(int argc, char **argv) {
return EX_SOFTWARE;
}
int fd = connect_unix(sock_path);
if (fd < 0) {
fprintf(stderr, "clide: cannot connect to %s: %s\n", sock_path, strerror(errno));
sock_t fd = connect_unix(sock_path);
if (fd == NET_INVALID) {
fprintf(stderr, "clide: cannot connect to %s: %s\n", sock_path, net_strerror(net_errno()));
return EX_UNAVAILABLE;
}
/* Build + send request. Worst-case envelope sizing: argv totals
* plus JSON overhead. 64 KB envelope handles 4 KB args * 16. */
char req[65536];
if (build_request(argc - 1, argv + 1, getpid(), req, sizeof(req)) != 0) {
if (build_request(argc - 1, argv + 1, (long long)clide_getpid(), req, sizeof(req)) != 0) {
fprintf(stderr, "clide: request payload too large\n");
close(fd);
net_close(fd);
return EX_USAGE;
}
if (write(fd, req, strlen(req)) != (ssize_t)strlen(req)) {
fprintf(stderr, "clide: write failed: %s\n", strerror(errno));
close(fd);
if (net_write(fd, req, (int)strlen(req)) != (int)strlen(req)) {
fprintf(stderr, "clide: write failed: %s\n", net_strerror(net_errno()));
net_close(fd);
return EX_OSERR;
}
/* Read the response — one JSON line. */
char resp[65536];
if (read_line(fd, resp, sizeof(resp)) != 0) {
fprintf(stderr, "clide: response read failed: %s\n",
errno ? strerror(errno) : "short read");
close(fd);
fprintf(stderr, "clide: response read failed: %s\n", net_strerror(net_errno()));
net_close(fd);
return EX_OSERR;
}
@@ -325,14 +440,14 @@ int main(int argc, char **argv) {
fputc('\n', stdout);
fflush(stdout);
}
close(fd);
net_close(fd);
return 0;
}
}
close(fd);
net_close(fd);
return 0;
}
close(fd);
net_close(fd);
const char *code_v = json_value(resp, "code", &code_len);
const char *msg_v = json_value(resp, "message", &msg_len);
int exit_code = code_v ? (int)strtol(code_v, NULL, 10) : EX_SOFTWARE;
+8 -8
View File
@@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c
url: "https://pub.dev"
source: hosted
version: "93.0.0"
version: "99.0.0"
alchemist:
dependency: "direct dev"
description:
@@ -21,10 +21,10 @@ packages:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7"
url: "https://pub.dev"
source: hosted
version: "10.0.1"
version: "12.1.0"
args:
dependency: transitive
description:
@@ -93,10 +93,10 @@ packages:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.15.1"
crypto:
dependency: transitive
description:
@@ -547,10 +547,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499"
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.1.0"
version: "15.2.0"
watcher:
dependency: transitive
description:
+1 -1
View File
@@ -13,7 +13,7 @@ description: >-
subsystem handlers (pane, files, editor, git, pql), and the
extension framework.
publish_to: none
version: 2.4.1
version: 2.5.0
repository: https://github.com/postmeridiem/clide
# Short user-facing tagline (the welcome subtitle, web meta
# description, etc.). Baked into lib/src/build_info.g.dart by
+52
View File
@@ -236,6 +236,58 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('ctrl+w o fires a window command via the global matcher, not editor.close (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
expect(f.services.arrangement.isInFocusMode, isFalse);
// ctrl+w (chord) then a BARE o → panel.focusMode. The second chord is
// consumed at the hardware level, so a focused pane can't swallow it.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyW);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
expect(f.services.arrangement.isInFocusMode, isTrue);
});
testWidgets('bare ctrl+w closes the editor after the ambiguity timeout (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
f.services.arrangement.openEditor();
expect(f.services.arrangement.editorOpen, isTrue);
// ctrl+w with no completing chord: pends, then the timeout flushes the
// exact bare-ctrl+w binding (editor.close from the contributions layer).
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyW);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump(const Duration(milliseconds: 450));
expect(f.services.arrangement.editorOpen, isFalse);
});
testWidgets('a bare-key sequence prefix (g) is not grabbed by the global matcher (T-404)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
await pumpApp(tester);
f.services.arrangement.openEditor();
// `g` is a prefix (gg) but bare → editor/pane-local. The global matcher must
// NOT consume it or fire a window command; the editor stays open.
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.pump();
expect(f.services.arrangement.isInFocusMode, isFalse);
expect(f.services.arrangement.editorOpen, isTrue);
});
testWidgets('window control buttons render and tap as no-ops in tests', (tester) async {
await pumpApp(tester);
// _RightHatContent renders ClideTappable window buttons on non-macOS;
@@ -14,10 +14,11 @@ import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:clide/builtin/claude/src/transcript_publisher.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/kernel.dart' show PaneKeyNav;
import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show Builder, Image, FileImage, MediaQuery, ValueKey;
import 'package:flutter/widgets.dart' show Builder, Focus, Image, FileImage, MediaQuery, Scrollable, ScrollableState, ValueKey;
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
@@ -199,6 +200,61 @@ void main() {
expect(find.text('Waiting for Claude…'), findsOneWidget);
});
testWidgets('vim G / gg / j scroll the conversation under vim.normal (T-406)', (tester) async {
await tester.runAsync(() => f.services.keymap.setPreset('vim'));
f.services.keymap.setScopeFlag('vim.normal', true);
addTearDown(() => f.services.keymap.clearScopeFlag('vim.normal'));
// Enough prose to overflow the 700px viewport so there's room to scroll.
await pumpWith(tester, [for (var i = 0; i < 40; i++) AssistantTextMessage(uuid: 'a$i', timestamp: _t, isSidechain: false, text: 'line number $i')]);
// Focus the pane's nav region (its own Focus is PaneKeyNav's outermost).
final node = tester.widget<Focus>(find.descendant(of: find.byType(PaneKeyNav), matching: find.byType(Focus)).first).focusNode!;
node.requestFocus();
await tester.pump();
final pos = tester.state<ScrollableState>(find.byType(Scrollable).first).position;
expect(pos.maxScrollExtent, greaterThan(0), reason: 'content must overflow to scroll');
// G → jump to the bottom.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await tester.pump();
expect(pos.pixels, pos.maxScrollExtent);
// gg → jump to the top.
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.sendKeyEvent(LogicalKeyboardKey.keyG);
await tester.pump();
expect(pos.pixels, 0);
// j → down one line (48px); k → back up.
await tester.sendKeyEvent(LogicalKeyboardKey.keyJ);
await tester.pump();
expect(pos.pixels, 48);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK);
await tester.pump();
expect(pos.pixels, 0);
// ctrl+d / ctrl+u → half a viewport down then back up.
await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await tester.sendKeyEvent(LogicalKeyboardKey.keyD);
await tester.pump();
expect(pos.pixels, greaterThan(0));
await tester.sendKeyEvent(LogicalKeyboardKey.keyU);
await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await tester.pump();
expect(pos.pixels, 0);
// h / l / o have no reader-pane semantics — they don't move the scroll.
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
await tester.sendKeyEvent(LogicalKeyboardKey.keyH);
await tester.sendKeyEvent(LogicalKeyboardKey.keyO);
await tester.pump();
expect(pos.pixels, 0);
});
testWidgets('a Workflow tool-use with a live run renders the workflow card (T-416)', (tester) async {
var run = const WorkflowRun(toolUseId: 'x1', name: 'parallel-words');
run = run.foldEvent({
@@ -122,18 +122,18 @@ void main() {
test('an image-show message with no live session is dropped silently (T-249)', () async {
f.services.messages.publish('test', imageShowChannel, {'path': '/tmp/x.png'});
f.services.messages.publish('test', imageShowChannel, {'path': ''});
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// Nothing to assert beyond "no throw" — there is no conversation to
// receive the card and the CLI already acked at publish time.
});
test('a project switch closes sessions that belong to the old root (T-269)', () async {
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
f.services.events.emit(const ProjectOpened(path: '/repo-one'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
f.services.events.emit(const ProjectOpened(path: '/repo-two'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// No live sessions in this fixture — the sweep runs over an empty set.
expect(activeSessionOrchestrator!.sessions, isEmpty);
});
@@ -230,7 +230,7 @@ void main() {
}
expect(orch.sessions, isEmpty);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(created.single.killed, isTrue);
});
@@ -247,7 +247,7 @@ void main() {
}
expect(orch.sessions, isEmpty);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(created.every((p) => p.killed), isTrue);
});
@@ -65,7 +65,7 @@ void main() {
final m = await orch.spawn(SpawnSpec(id: 'fork-x', role: 'teammate', sessionId: 'placeholder-uuid', cwd: '/repo', forkSourceSessionId: 'source-uuid'));
expect(m.sessionId, 'placeholder-uuid'); // starts as the placeholder
created.last.emit(jsonEncode({'type': 'system', 'subtype': 'init', 'session_id': 'real-fork-id', 'model': 'claude-opus-4-8', 'permissionMode': 'default'}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(m.sessionId, 'real-fork-id'); // updated to the branch's real id
});
@@ -120,7 +120,7 @@ void main() {
await orch.close('primary');
expect(orch.byId('primary'), isNull);
expect(orch.sessions, isEmpty);
await Future<void>.delayed(Duration.zero); // session.dispose is async
await pumpEventQueue(); // session.dispose is async
expect(created.single.killed, isTrue);
});
@@ -143,7 +143,7 @@ void main() {
await orch.spawn(spec('a'));
await orch.spawn(spec('b'));
orch.dispose();
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(created.every((p) => p.killed), isTrue);
});
@@ -164,7 +164,7 @@ void main() {
await orch.spawn(teamSpec('primary', 'lead', 'lead'));
await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate'));
orch.broker.sendMessage('primary', 'tyre', 'pick up T-9');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final tyreProc = created[1];
expect(tyreProc.writes.any((w) => w.contains('[team] lead: pick up T-9')), isTrue);
});
@@ -210,7 +210,7 @@ void main() {
},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(s.availableModels, hasLength(2));
expect(s.availableModels[0].value, 'default');
expect(s.availableModels[0].description, 'recommended');
@@ -226,13 +226,13 @@ void main() {
expect(sent['type'], 'control_request');
expect((sent['request'] as Map)['subtype'], 'set_model');
expect((sent['request'] as Map)['model'], 'sonnet');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.model, 'sonnet');
});
test('setModel(default) does not guess the resolved model', () async {
session.setModel('default');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses, isEmpty, reason: 'only the CLI knows what default resolves to');
});
@@ -240,10 +240,10 @@ void main() {
final errors = <String>[];
session.modelErrors.listen(errors.add);
proc.emit(initEvent()); // model: claude-opus-4-7
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.setModel('bogus-model');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.model, 'bogus-model'); // optimistic
final rid = (jsonDecode(proc.writes.single) as Map)['request_id'];
@@ -253,7 +253,7 @@ void main() {
'response': {'subtype': 'error', 'request_id': rid, 'error': 'Unknown model: bogus-model'},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.model, 'claude-opus-4-7', reason: 'rolled back');
expect(errors, ['Unknown model: bogus-model']);
});
@@ -267,7 +267,7 @@ void main() {
'response': {'subtype': 'success', 'request_id': rid},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.model, 'opus');
});
});
@@ -275,7 +275,7 @@ void main() {
test('parses assistant text + tool_use events into items', () async {
proc.emit(assistantText('hello there'));
proc.emit(assistantToolUse());
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(items, hasLength(2));
expect(items[0], isA<AssistantTextMessage>());
@@ -290,7 +290,7 @@ void main() {
test('derives status: model + tokens from assistant, permission-mode from init', () async {
proc.emit(initEvent());
proc.emit(assistantText('hi'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.model, 'claude-opus-4-7');
expect(statuses.last.permissionMode, 'default');
@@ -300,7 +300,7 @@ void main() {
test('only emits status on change', () async {
proc.emit(initEvent());
proc.emit(initEvent()); // identical → no second emit
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses, hasLength(1));
});
@@ -308,11 +308,11 @@ void main() {
// the plain broadcast stream dropped it — the status bar stayed blank.
test('subscribing AFTER the init event still yields the status (T-274/T-386)', () async {
proc.emit(initEvent());
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final late = <SessionStatus>[];
session.statusStream.listen(late.add);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(late, hasLength(1), reason: 'replay-latest delivers the current status to late binders');
expect(late.single.model, 'claude-opus-4-7');
@@ -323,7 +323,7 @@ void main() {
final ids = <String>[];
session.sessionIdResolved.listen(ids.add);
proc.emit(jsonEncode({'type': 'system', 'subtype': 'init', 'session_id': 'sess-abc', 'model': 'claude-opus-4-7', 'permissionMode': 'default'}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(session.claudeSessionId, 'sess-abc');
expect(ids, ['sess-abc']);
});
@@ -331,7 +331,7 @@ void main() {
group('live cost/context from result events (T-168)', () {
test('result event with total_cost_usd populates cost field', () async {
proc.emit(resultEvent(cost: 0.042));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.cost, closeTo(0.042, 1e-9));
});
@@ -344,7 +344,7 @@ void main() {
},
),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.contextWindow, 1000000);
});
@@ -352,7 +352,7 @@ void main() {
proc.emit(initEvent());
proc.emit(assistantText('hi'));
proc.emit(resultEvent(cost: 0.05));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.model, 'claude-opus-4-7');
expect(statuses.last.permissionMode, 'default');
expect(statuses.last.cost, closeTo(0.05, 1e-9));
@@ -361,7 +361,7 @@ void main() {
test('result event without cost or modelUsage emits nothing', () async {
final before = statuses.length;
proc.emit(jsonEncode({'type': 'result', 'result': '', 'usage': <String, dynamic>{}}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.length, before); // no change → no emit
});
});
@@ -369,14 +369,14 @@ void main() {
group('rate_limit_event status (T-168)', () {
test('rate_limit_event with status populates rateLimitInfo', () async {
proc.emit(rateLimitEvent(status: 'rate_limited'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.rateLimitInfo, contains('rate limited'));
});
test('rate_limit_event with an ISO resetsAt includes the time', () async {
// 2026-05-30T14:32:00Z → shows 14:32 (UTC, local may differ but contains digits)
proc.emit(rateLimitEvent(status: 'rate_limited', resetsAt: '2026-05-30T14:32:00Z'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.rateLimitInfo, contains('rate limited'));
expect(statuses.last.rateLimitInfo, contains('resets'));
});
@@ -390,7 +390,7 @@ void main() {
'rate_limit_info': {'status': 'rate_limited', 'resetsAt': 1780000000},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.rateLimitInfo, contains('rate limited'));
expect(statuses.last.rateLimitInfo, contains('resets'));
});
@@ -401,7 +401,7 @@ void main() {
proc.emit(streamMessageStart('msg-1'));
proc.emit(streamTextDelta('one '));
proc.emit(streamTextDelta('two three'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final parts = items.whereType<AssistantTextMessage>().toList();
// Each delta emits an upserting placeholder; all share the stable uuid and
// the latest carries the accumulated text.
@@ -414,7 +414,7 @@ void main() {
proc.emit(streamMessageStart('msg-2'));
proc.emit(streamTextDelta('hel'));
proc.emit(assistantTextWithId('msg-2', 'hello there'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final parts = items.whereType<AssistantTextMessage>().toList();
// The final, complete text reuses the placeholder uuid so the controller
// replaces rather than appends — no duplicate.
@@ -427,7 +427,7 @@ void main() {
proc.emit(streamTextDelta('working'));
proc.emit(assistantTextWithId('msg-3', 'working on it')); // finalises partial-msg-3
proc.emit(assistantToolUse()); // separate block, own uuid
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final tool = items.whereType<AssistantToolUse>().single;
expect(tool.uuid, isNot('partial-msg-3'));
expect(items.last, isA<AssistantToolUse>());
@@ -438,11 +438,11 @@ void main() {
proc.emit(streamTextDelta('first'));
proc.emit(streamMessageStop());
proc.emit(jsonEncode({'type': 'result', 'result': '', 'usage': <String, dynamic>{}}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// A new turn reusing the same id still streams (no leftover finalised flag).
proc.emit(streamMessageStart('msg-4'));
proc.emit(streamTextDelta('second'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final parts = items.whereType<AssistantTextMessage>().toList();
expect(parts.last.text, 'second');
});
@@ -452,14 +452,14 @@ void main() {
proc.emit('');
proc.emit('not json');
proc.emit(' ');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(items, isEmpty);
expect(statuses, isEmpty);
});
test('send writes a stream-json user message and echoes it locally', () async {
session.send('do the thing');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(proc.writes, hasLength(1));
final sent = jsonDecode(proc.writes.single) as Map<String, Object?>;
@@ -484,7 +484,7 @@ void main() {
},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final u = items.whereType<UserMessage>().single;
expect(u.injected, isTrue);
});
@@ -501,7 +501,7 @@ void main() {
},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(items.whereType<UserMessage>().single.injected, isFalse);
});
@@ -509,7 +509,7 @@ void main() {
final emitted = <ToolPrompt?>[];
session.pendingPromptStream.listen(emitted.add);
proc.emit(canUseTool('req-1'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final p = session.pendingPrompt;
expect(p, isNotNull);
@@ -526,7 +526,7 @@ void main() {
test('resolvePrompt(allow) writes success+updatedInput and clears the pending prompt', () async {
proc.emit(canUseTool('req-2'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final p = session.pendingPrompt!;
session.resolvePrompt(p.promptId, AllowTool(p.input));
@@ -557,13 +557,13 @@ void main() {
},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(session.pendingPrompt!.permissionSuggestions, hasLength(1));
});
test('resolvePrompt(allow with updatedPermissions) echoes them in the response', () async {
proc.emit(canUseTool('rp'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt(
'rp',
AllowTool(
@@ -580,7 +580,7 @@ void main() {
test('resolvePrompt(allow with a follow-up note) sends the note as a user message', () async {
proc.emit(canUseTool('rn'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt('rn', AllowTool(const {'x': 1}, followUpNote: 'use docs/ instead'));
// first write = control_response (allow), second = the follow-up message
@@ -592,14 +592,14 @@ void main() {
test('resolvePrompt records the tool outcome — allow', () async {
proc.emit(canUseTool('o1'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt('o1', AllowTool(const {}));
expect(session.toolUseOutcomes['toolu_1'], isTrue);
});
test('resolvePrompt records the tool outcome — deny', () async {
proc.emit(canUseTool('o2'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt('o2', const DenyTool('no'));
expect(session.toolUseOutcomes['toolu_1'], isFalse);
});
@@ -608,44 +608,44 @@ void main() {
test('approving ExitPlanMode leaves plan mode (T-337)', () async {
proc.emit(planInit());
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.permissionMode, 'plan');
proc.emit(canUseTool('exit-1', tool: 'ExitPlanMode', input: {'plan': 'do the thing'}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final p = session.pendingPrompt!;
expect(p.toolName, 'ExitPlanMode');
session.resolvePrompt(p.promptId, AllowTool(p.input));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.permissionMode, 'default', reason: 'approving ExitPlanMode must exit plan mode');
});
test('denying ExitPlanMode stays in plan mode (T-337)', () async {
proc.emit(planInit());
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
proc.emit(canUseTool('exit-2', tool: 'ExitPlanMode', input: {'plan': 'x'}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt(session.pendingPrompt!.promptId, const DenyTool('keep planning'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.permissionMode, 'plan', reason: 'a denied plan-exit keeps plan mode');
});
test('approving a non-ExitPlanMode tool does not change plan mode (T-337)', () async {
proc.emit(planInit());
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
proc.emit(canUseTool('w1')); // a Write
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt(session.pendingPrompt!.promptId, AllowTool(const {}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.permissionMode, 'plan', reason: 'only ExitPlanMode exits plan mode');
});
test('noteEffort merges the effort level into the status (T-412)', () async {
proc.emit(initEvent());
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.noteEffort('xhigh');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.effort, 'xhigh');
expect(statuses.last.model, 'claude-opus-4-7'); // merge, not replace
});
@@ -653,7 +653,7 @@ void main() {
test('addLocalNotice emits a synthetic clide item and sends nothing (T-411)', () async {
final before = proc.writes.length;
session.addLocalNotice('/status is a Claude Code TUI command');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final notice = items.whereType<AssistantTextMessage>().single;
expect(notice.synthetic, isTrue);
expect(notice.text, contains('/status'));
@@ -662,7 +662,7 @@ void main() {
test('resolvePrompt(deny) writes a deny decision with a message', () async {
proc.emit(canUseTool('req-3'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt('req-3', const DenyTool('nope'));
final decision = ((jsonDecode(proc.writes.single) as Map)['response'] as Map)['response'] as Map;
@@ -682,14 +682,14 @@ void main() {
},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt(
'aq',
AllowTool(const {
'answers': {'Pet': 'Dogs'},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final echo = items.whereType<UserMessage>().toList();
expect(echo, hasLength(1));
@@ -699,7 +699,7 @@ void main() {
test('prompts queue: resolving the head surfaces the next', () async {
proc.emit(canUseTool('q1'));
proc.emit(canUseTool('q2'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(session.pendingPrompt!.promptId, 'q1');
session.resolvePrompt('q1', AllowTool(const {}));
@@ -710,7 +710,7 @@ void main() {
test('resolvePrompt is a no-op for an unknown / already-resolved id', () async {
proc.emit(canUseTool('req-4'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
session.resolvePrompt('req-4', AllowTool(const {})); // resolves
session.resolvePrompt('req-4', AllowTool(const {})); // already gone
@@ -726,7 +726,7 @@ void main() {
'request': {'subtype': 'mystery_subtype'},
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(items, isEmpty);
final resp = (jsonDecode(proc.writes.single) as Map)['response'] as Map;
@@ -767,11 +767,11 @@ void main() {
// The control_request itself emits no status event; without an optimistic
// update the badge stayed stale. Each call must surface the new mode.
session.setPermissionMode('plan');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.permissionMode, 'plan');
session.setPermissionMode('acceptEdits');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(statuses.last.permissionMode, 'acceptEdits');
});
@@ -782,7 +782,7 @@ void main() {
expect(session.busy, isTrue);
proc.emit(jsonEncode({'type': 'result', 'subtype': 'success'}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(session.busy, isFalse);
// Leading false is the replayed seed — busyStream tells a new
// subscriber the CURRENT state before the live updates (T-386).
@@ -796,14 +796,14 @@ void main() {
test('promptedToolUseIds contains the tool_use_id after a can_use_tool arrives', () async {
proc.emit(canUseTool('p1'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// promptedToolUseIds exposes the set of prompted tool use ids.
expect(session.promptedToolUseIds, contains('toolu_1'));
});
test('rate_limit_event with a non-ISO resetsAt shows the raw string', () async {
proc.emit(rateLimitEvent(status: 'rate_limited', resetsAt: 'soon'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// Non-ISO resetsAt → DateTime.tryParse returns null → raw string is used.
expect(statuses.last.rateLimitInfo, 'rate limited — resets soon');
});
@@ -841,14 +841,14 @@ void main() {
'id': 0,
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final r = mcpResponseOf(mproc.writes.last);
expect((r['result'] as Map)['serverInfo'], {'name': 'clide-team', 'version': '9.9.9'});
});
test('answers tools/list with the server tools', () async {
mproc.emit(mcpMessage('m2', {'method': 'tools/list', 'jsonrpc': '2.0', 'id': 1}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final r = mcpResponseOf(mproc.writes.last);
final tools = (r['result'] as Map)['tools'] as List;
expect(tools.single['name'], 'ping');
@@ -863,7 +863,7 @@ void main() {
'id': 2,
}),
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(server.calls, ['ping']);
final r = mcpResponseOf(mproc.writes.last);
final content = (r['result'] as Map)['content'] as List;
@@ -872,21 +872,21 @@ void main() {
test('an mcp_message for an unknown server is answered with an error', () async {
mproc.emit(mcpMessage('m4', {'method': 'tools/list', 'jsonrpc': '2.0', 'id': 3}, server: 'nope'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final r = mcpResponseOf(mproc.writes.last);
expect(r['error'], isNotNull);
});
test('answers notifications/initialized with an empty result', () async {
mproc.emit(mcpMessage('m5', {'method': 'notifications/initialized', 'jsonrpc': '2.0', 'id': 4}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final r = mcpResponseOf(mproc.writes.last);
expect(r['result'], isA<Map>());
});
test('answers unknown MCP method with a JSON-RPC error -32601', () async {
mproc.emit(mcpMessage('m6', {'method': 'resources/list', 'jsonrpc': '2.0', 'id': 5}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final r = mcpResponseOf(mproc.writes.last);
expect((r['error'] as Map)['code'], -32601);
expect((r['error'] as Map)['message'], contains('resources/list'));
@@ -900,12 +900,12 @@ void main() {
final ends = <SessionEnd>[];
session.endedStream.listen(ends.add);
session.send('do something');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(session.busy, isTrue, reason: 'a send marks the turn in flight');
proc.stderr.addAll(['boom: stack', 'fatal: died']);
proc.exit.complete(70);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(session.busy, isFalse, reason: 'a dead process is not thinking');
expect(ends, hasLength(1));
@@ -918,11 +918,11 @@ void main() {
final pendings = <ToolPrompt?>[];
session.pendingPromptStream.listen(pendings.add);
proc.emit(canUseTool('p1'));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(session.pendingPrompt, isNotNull);
proc.exit.complete(1);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(session.pendingPrompt, isNull);
expect(pendings.last, isNull, reason: 'the composer swaps back from the prompt UI');
});
@@ -932,7 +932,7 @@ void main() {
final s = StreamJsonSession(p)..start();
await s.dispose();
p.exit.complete(9); // the kill's exit must not surface as a crash
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(s.end, isNull);
});
});
@@ -976,7 +976,7 @@ void main() {
}),
);
p.emit(jsonEncode({'type': 'system', 'subtype': 'task_notification', 'tool_use_id': 'toolu_wf', 'status': 'completed', 'summary': 'done'}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final run = session.workflows['toolu_wf'];
expect(run, isNotNull);
@@ -994,7 +994,7 @@ void main() {
final items = <ConversationItem>[];
session.items.listen(items.add);
p.emit(jsonEncode({'type': 'system', 'subtype': 'task_progress', 'tool_use_id': 'toolu_wf', 'workflow_progress': const []}));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(items, isEmpty);
expect(session.workflows.containsKey('toolu_wf'), isTrue);
});
+3 -3
View File
@@ -168,7 +168,7 @@ void main() {
final events = <void>[];
final sub = broker.changes.listen((_) => events.add(null));
broker.removeMember('teammate:tyre');
await Future<void>.delayed(Duration.zero); // let the broadcast event deliver
await pumpEventQueue(); // let the broadcast event deliver
await sub.cancel();
expect(events, hasLength(1));
});
@@ -177,7 +177,7 @@ void main() {
var done = false;
broker.changes.listen(null, onDone: () => done = true);
broker.dispose();
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(done, isTrue);
});
});
@@ -223,7 +223,7 @@ void main() {
final events = <void>[];
final sub = broker.changes.listen((_) => events.add(null));
broker.reassignTask(id, 'primary');
await Future<void>.delayed(Duration.zero); // let the broadcast event deliver
await pumpEventQueue(); // let the broadcast event deliver
await sub.cancel();
expect(events, hasLength(1));
});
+12 -12
View File
@@ -40,7 +40,7 @@ void main() {
final events = <void>[];
final sub = model.changes.listen((_) => events.add(null));
broker.sendMessage('primary', 'tyre', 'hello tyre');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
await sub.cancel();
expect(model.messages, hasLength(1));
expect(model.messages.single.from, 'lead');
@@ -51,7 +51,7 @@ void main() {
test('broadcast messages are appended for each recipient', () async {
broker.broadcast('primary', 'standup');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// One message for 'tyre', one for 'user' (both are non-sender members).
expect(model.messages.length, greaterThanOrEqualTo(1));
expect(model.messages.every((m) => m.text == 'standup'), isTrue);
@@ -59,7 +59,7 @@ void main() {
test('direct send_message to user lands in the timeline', () async {
broker.sendMessage('primary', 'user', 'attention user');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(model.messages.single.text, 'attention user');
expect(model.messages.single.to, 'user');
// User has no stdin delivery.
@@ -71,7 +71,7 @@ void main() {
final sub = model.changes.listen((_) => events.add(null));
broker.sendMessage('primary', 'tyre', 'one');
broker.sendMessage('primary', 'tyre', 'two');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
await sub.cancel();
expect(events, hasLength(2));
});
@@ -79,7 +79,7 @@ void main() {
test('messages list is append-only (oldest first)', () async {
broker.sendMessage('primary', 'tyre', 'first');
broker.sendMessage('primary', 'tyre', 'second');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(model.messages[0].text, 'first');
expect(model.messages[1].text, 'second');
});
@@ -92,20 +92,20 @@ void main() {
group('postAsUser routing', () {
test('postAsUser with no toName broadcasts to all agents', () async {
model.postAsUser('hello team');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// Delivered to tyre (lead is the sender-equivalent; user has no delivery).
expect(delivered.any((d) => d.$1 == 'teammate:tyre'), isTrue);
});
test('postAsUser with toName=team broadcasts', () async {
model.postAsUser('standup', toName: 'team');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(delivered.any((d) => d.$1 == 'teammate:tyre'), isTrue);
});
test('postAsUser with a member name delivers to that member only', () async {
model.postAsUser('hey tyre', toName: 'tyre');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(delivered.length, 1);
expect(delivered.single.$1, 'teammate:tyre');
});
@@ -148,7 +148,7 @@ void main() {
},
);
interruptModel.postAsUser('cancel that', toName: 'tyre', interrupt: true);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(resolvedName, 'tyre');
interruptModel.dispose();
});
@@ -163,7 +163,7 @@ void main() {
},
);
interruptModel.postAsUser('abort all', interrupt: true);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// Broadcast → resolver not called (no single target to interrupt).
expect(resolvedName, isNull);
interruptModel.dispose();
@@ -179,7 +179,7 @@ void main() {
},
);
interruptModel.postAsUser('no interrupt', toName: 'tyre', interrupt: false);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(resolvedName, isNull);
interruptModel.dispose();
});
@@ -193,7 +193,7 @@ void main() {
var done = false;
model.changes.listen(null, onDone: () => done = true);
model.dispose();
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(done, isTrue);
});
}
+3 -3
View File
@@ -59,7 +59,7 @@ void main() {
await orch.spawn(SpawnSpec(id: 'primary', role: 'primary', sessionId: 'p-uuid', cwd: '/repo'));
final accepted = await applyTicketPickUp(payload(), orchestrator: orch, ipc: ipc, messages: messages);
await Future<void>.delayed(Duration.zero); // let the bus deliver 'changed'
await pumpEventQueue(); // let the bus deliver 'changed'
expect(accepted, isTrue);
expect(statusCalls, hasLength(1));
@@ -71,7 +71,7 @@ void main() {
test('no live session: nothing injected, ticket untouched (T-339)', () async {
// Orchestrator has no sessions → quiet no-op.
final accepted = await applyTicketPickUp(payload(), orchestrator: orch, ipc: ipc, messages: messages);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(accepted, isFalse);
expect(statusCalls, isEmpty);
@@ -87,7 +87,7 @@ void main() {
ipc: ipc,
messages: messages,
);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(accepted, isTrue); // prompt still delivered
expect(statusCalls, isEmpty); // but no transition
@@ -97,9 +97,9 @@ void main() {
test('decisions.detail tab count stays at 1 after multiple selections', () async {
_select(f, 'D-1');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
_select(f, 'D-2');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
final tabs = f.services.panels.tabsFor(Slots.contextPanel);
expect(tabs.where((t) => t.id == 'decisions.detail').length, 1, reason: 'no per-click re-contribution — exactly one decisions.detail tab');
@@ -107,25 +107,25 @@ void main() {
test('selection activates decisions.detail tab', () async {
_select(f, 'D-1');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.panels.activeTabIn(Slots.contextPanel), 'decisions.detail');
});
test('second selection switches to decisions.detail (already active, stays)', () async {
_select(f, 'D-1');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
_select(f, 'D-2');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.panels.activeTabIn(Slots.contextPanel), 'decisions.detail');
});
test('clicking the same decision twice leaves decisions.detail active', () async {
_select(f, 'D-5');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
_select(f, 'D-5');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.panels.activeTabIn(Slots.contextPanel), 'decisions.detail');
expect(f.services.panels.tabsFor(Slots.contextPanel).where((t) => t.id == 'decisions.detail').length, 1);
@@ -139,7 +139,7 @@ void main() {
f.services.arrangement.setCollapsed(Slots.contextPanel, true);
_select(f, 'D-3');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.arrangement.isVisible(Slots.contextPanel), isTrue, reason: 'panel must be made visible on selection');
expect(f.services.arrangement.isCollapsed(Slots.contextPanel), isFalse, reason: 'panel must be un-collapsed on selection');
@@ -149,7 +149,7 @@ void main() {
for (var i = 1; i <= 10; i++) {
_select(f, 'D-$i');
}
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.panels.activeTabIn(Slots.contextPanel), 'decisions.detail');
expect(f.services.panels.tabsFor(Slots.contextPanel).where((t) => t.id == 'decisions.detail').length, 1);
@@ -158,11 +158,11 @@ void main() {
test('null id in selection message is ignored', () async {
// Seed a valid tab selection first.
_select(f, 'D-1');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// Then send a bad message.
f.services.messages.publish('builtin.decisions', 'selection', {'id': null});
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// Tab still active, still only one.
expect(f.services.panels.activeTabIn(Slots.contextPanel), 'decisions.detail');
@@ -176,7 +176,7 @@ void main() {
// tab at all — but the panel activation path must not fire either.
f.services.panels.registerSlot(const SlotDefinition(id: Slots.contextPanel, position: SlotPosition.right));
f.services.messages.publish('builtin.decisions', 'selection', {'id': 'D-99'});
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(
f.services.panels.activeTabIn(Slots.contextPanel),
@@ -1,6 +1,7 @@
import 'package:clide/builtin/default_layout/default_layout.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
@@ -151,5 +152,45 @@ void main() {
// Sidebar auto-expanded.
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
});
test('workspace.tab.next/previous cycle the workspace tabs with wraparound (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final panels = f.services.panels;
for (final id in ['wt.a', 'wt.b', 'wt.c']) {
panels.contribute(TabContribution(id: id, slot: Slots.workspace, title: id, build: (_) => const SizedBox.shrink()));
}
panels.setTabOrder(Slots.workspace, ['wt.a', 'wt.b', 'wt.c']);
panels.activateTab(Slots.workspace, 'wt.a');
await f.services.commands.execute('workspace.tab.next');
expect(panels.activeTabIn(Slots.workspace), 'wt.b');
await f.services.commands.execute('workspace.tab.next');
expect(panels.activeTabIn(Slots.workspace), 'wt.c');
await f.services.commands.execute('workspace.tab.next'); // wrap forward
expect(panels.activeTabIn(Slots.workspace), 'wt.a');
await f.services.commands.execute('workspace.tab.previous'); // wrap backward
expect(panels.activeTabIn(Slots.workspace), 'wt.c');
});
test('ctrl+pagedown/up resolve to the workspace tab-cycle commands across presets (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final km = f.services.keymap.keymap;
expect((km?.resolve(KeyChord.parse('ctrl+pagedown'), const {}) as InvokeCommandIntent?)?.commandId, 'workspace.tab.next');
expect((km?.resolve(KeyChord.parse('ctrl+pageup'), const {}) as InvokeCommandIntent?)?.commandId, 'workspace.tab.previous');
});
test('workspace tab cycle is a no-op with fewer than two tabs (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final panels = f.services.panels;
panels.contribute(TabContribution(id: 'only', slot: Slots.workspace, title: 'only', build: (_) => const SizedBox.shrink()));
panels.activateTab(Slots.workspace, 'only');
final r = await f.services.commands.execute('workspace.tab.next');
expect(r.ok, isTrue);
expect(r.data['cycled'], isFalse);
expect(panels.activeTabIn(Slots.workspace), 'only');
});
});
}
+4 -4
View File
@@ -54,7 +54,7 @@ void main() {
expect(c.focusPath, 'lib/b.dart');
expect(notified, greaterThan(0));
// focus() reloads so the latest edits to that file are present.
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(diffCalls, greaterThan(before));
});
@@ -62,13 +62,13 @@ void main() {
c.focus('lib/gone.dart');
expect(c.focusPath, 'lib/gone.dart');
// The reload triggered by focus() returns a list without that file.
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(c.focusPath, isNull);
});
test('a focus that stays in the diff survives reload', () async {
c.focus('lib/a.dart');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(c.focusPath, 'lib/a.dart');
});
@@ -76,7 +76,7 @@ void main() {
await c.load();
final before = diffCalls;
bus.emit(DaemonEvent(subsystem: 'git', kind: 'git.changed', data: const {}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(diffCalls, greaterThan(before));
});
@@ -34,36 +34,36 @@ void main() {
test('editor.opened opens the editor split', () async {
expect(f.services.arrangement.editorOpen, isFalse);
emitEditor('editor.opened', id: 'b_1');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.arrangement.editorOpen, isTrue);
});
test('editor.active-changed with a buffer keeps the split open', () async {
emitEditor('editor.active-changed', id: 'b_2');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.arrangement.editorOpen, isTrue);
});
test('editor.active-changed with a null id collapses the split', () async {
emitEditor('editor.opened', id: 'b_1');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.arrangement.editorOpen, isTrue);
emitEditor('editor.active-changed', id: null);
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.arrangement.editorOpen, isFalse);
});
test('a non-editor event does not open the split', () async {
f.services.events.emit(DaemonEvent(subsystem: 'git', kind: 'changed', data: const {}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.arrangement.editorOpen, isFalse);
});
test('after deactivate, editor events no longer open the split', () async {
await f.services.extensions.deactivate('builtin.editor');
emitEditor('editor.opened', id: 'b_9');
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(f.services.arrangement.editorOpen, isFalse);
});
}
+7
View File
@@ -56,6 +56,13 @@ void main() {
await tester.pump();
}
testWidgets('focusing the editor publishes editor.focused (T-406)', (tester) async {
stubOneBuffer('hello');
expect(f.services.keymap.scope['editor.focused'], isNot(true));
await pumpEditor(tester); // taps into the editor → focus
expect(f.services.keymap.scope['editor.focused'], isTrue, reason: 'pane nav guards on !editor.focused');
});
testWidgets('normal-mode x deletes the char under the caret', (tester) async {
String? sentText;
f.ipc.stub('editor.set-content', (a) async {
+97 -1
View File
@@ -22,9 +22,13 @@ void main() {
km = Keymap([KeymapLayer.fromYaml(src)]);
});
const normal = {'vim.normal': true};
// Editor-focused normal mode: j/k/h/l/gg/G/o are buffer motions here because
// the `editor.focused` flag suppresses the pane-nav bindings (T-406).
const normal = {'vim.normal': true, 'editor.focused': true};
const insert = {'vim.insert': true};
const visual = {'vim.visual': true};
// A non-editor pane focused under vim normal mode: the same keys are nav.*.
const paneNormal = {'vim.normal': true};
Intent? resolve(String chord, Map<String, bool> scope) => km.resolve(KeyChord.parse(chord), scope);
@@ -83,4 +87,96 @@ void main() {
expect(m.feed(KeyChord.parse('g')).outcome, SeqOutcome.pending);
expect(_cmd(m.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
});
group('pane navigation (T-406)', () {
test('motion keys resolve to nav.* when a non-editor pane is focused', () {
expect(resolve('j', paneNormal), isA<NavDownIntent>());
expect(resolve('k', paneNormal), isA<NavUpIntent>());
expect(resolve('h', paneNormal), isA<NavCollapseOrLeftIntent>());
expect(resolve('l', paneNormal), isA<NavExpandOrRightIntent>());
expect(resolve('ctrl+d', paneNormal), isA<NavPageDownIntent>());
expect(resolve('ctrl+u', paneNormal), isA<NavPageUpIntent>());
expect(resolve('shift+g', paneNormal), isA<NavBottomIntent>());
expect(resolve('o', paneNormal), isA<NavActivateIntent>());
expect(resolve('enter', paneNormal), isA<NavActivateIntent>());
});
test('the editor.focused guard hands the same keys to the editor', () {
// With the editor focused, nav.* is suppressed and the buffer motions win.
expect(_cmd(resolve('j', normal)), 'editor.vim.down');
expect(_cmd(resolve('h', normal)), 'editor.vim.left');
expect(_cmd(resolve('l', normal)), 'editor.vim.right');
expect(_cmd(resolve('shift+g', normal)), 'editor.vim.docEnd');
expect(_cmd(resolve('o', normal)), 'editor.vim.openBelow');
});
test('gg resolves to nav.top in a pane, docStart in the editor', () {
final pane = SequenceMatcher(keymap: () => km, context: () => paneNormal);
pane.feed(KeyChord.parse('g'));
expect(pane.feed(KeyChord.parse('g')).intent, isA<NavTopIntent>());
final editor = SequenceMatcher(keymap: () => km, context: () => normal);
editor.feed(KeyChord.parse('g'));
expect(_cmd(editor.feed(KeyChord.parse('g')).intent), 'editor.vim.docStart');
});
test('pane nav is normal-mode only — visual mode keeps the editor motion', () {
// nav.* is guarded `vim.normal && !editor.focused`; visual mode has no
// vim.normal flag, so j stays the editor motion even without editor.focused.
expect(_cmd(resolve('j', visual)), 'editor.vim.down');
});
});
group('ctrl+w window family (T-404)', () {
SequenceMatcher matcher([Keymap? k]) => SequenceMatcher(keymap: () => k ?? km, context: () => normal, captureCounts: false);
Intent? seq(SequenceMatcher m, List<String> chords) {
SeqResult? r;
for (final c in chords) {
r = m.feed(KeyChord.parse(c));
}
return r?.intent;
}
test('ctrl+w h/l/j/o resolve to the panel commands', () {
expect(_cmd(seq(matcher(), ['ctrl+w', 'h'])), 'panel.focus.left');
expect(_cmd(seq(matcher(), ['ctrl+w', 'l'])), 'panel.focus.right');
expect(_cmd(seq(matcher(), ['ctrl+w', 'j'])), 'dock.toggle');
expect(_cmd(seq(matcher(), ['ctrl+w', 'o'])), 'panel.focusMode');
});
test('ctrl+w w and ctrl+w ctrl+w cycle panels; shift+w cycles back', () {
expect(seq(matcher(), ['ctrl+w', 'w']), isA<FocusNextPanelIntent>());
expect(seq(matcher(), ['ctrl+w', 'ctrl+w']), isA<FocusNextPanelIntent>());
expect(seq(matcher(), ['ctrl+w', 'shift+w']), isA<FocusPreviousPanelIntent>());
});
test('ctrl+w q and ctrl+w c close the editor', () {
expect(_cmd(seq(matcher(), ['ctrl+w', 'q'])), 'editor.close');
expect(_cmd(seq(matcher(), ['ctrl+w', 'c'])), 'editor.close');
});
test('bare ctrl+w is a live prefix; the timeout flush fires editor.close', () {
// editor.close's bare ctrl+w binding comes from the default-layout
// contributions layer, which sits under the preset in the real app.
final layered = Keymap([
KeymapLayer.fromYaml(File('assets/keymaps/vim.yaml').readAsStringSync()),
KeymapLayer(
name: 'contrib',
bindings: [KeymapBinding.chord(KeyChord.parse('ctrl+w'), intent: const InvokeCommandIntent('editor.close'))],
),
]);
final m = matcher(layered);
expect(m.feed(KeyChord.parse('ctrl+w')).outcome, SeqOutcome.pending);
expect(_cmd(m.flush().intent), 'editor.close'); // bare ctrl+w → close, after the wait
});
test('ctrl+w sequences need vim.normal/visual — inert under no vim scope', () {
final m = SequenceMatcher(keymap: () => km, context: () => const {}, captureCounts: false);
// With no vim scope, ctrl+w isn't a sequence prefix here, so the first
// chord doesn't pend on the family.
expect(m.feed(KeyChord.parse('ctrl+w')).outcome, isNot(SeqOutcome.fired));
expect(seq(matcher(km), ['ctrl+w', 'h']), isNotNull); // but it does under vim.normal
});
});
}
@@ -287,7 +287,7 @@ void main() {
// Emit files.changed for a file at root level — parent is ''.
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'README.md'}, ts: DateTime.now().toUtc()));
// Give the async refresh a tick.
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(lsCallCount, greaterThan(countAfterLoad));
});
@@ -305,7 +305,7 @@ void main() {
// 'lib' is not in _entries yet, so its parent 'lib/src' won't be there.
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'lib/src/foo.dart'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(lsCallCount, countAfterLoad);
});
@@ -322,7 +322,7 @@ void main() {
final countAfterLoad = lsCallCount;
f.services.events.emit(DaemonEvent(subsystem: 'editor', kind: 'files.changed', data: {'path': 'README.md'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(lsCallCount, countAfterLoad);
});
@@ -339,7 +339,7 @@ void main() {
final countAfterLoad = lsCallCount;
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.opened', data: {'path': 'README.md'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
expect(lsCallCount, countAfterLoad);
});
@@ -352,7 +352,7 @@ void main() {
final countAfterLoad = 1;
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'pubspec.yaml'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// Root '' is in _entries, so reload fires.
expect(countAfterLoad, 1); // just confirming test ran
@@ -364,7 +364,7 @@ void main() {
await c.load();
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': null}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// No crash — just checking the null-path guard.
});
});
@@ -382,6 +382,99 @@ void main() {
});
});
group('FileTreeController — keyboard selection (T-406)', () {
// Tree: '' (root) → [lib/ (→ app.dart), main.dart]
Future<FileTreeController> tree({bool expandLib = false}) async {
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
f.ipc.stub('files.watch', (_) async => _ok(const {}));
f.ipc.stub('files.ls', (args) async {
final path = args['path'] as String? ?? '';
if (path == '') {
return _ok({
'entries': [_fileEntry(name: 'lib', path: 'lib', isDirectory: true), _fileEntry(name: 'main.dart', path: 'main.dart')],
});
}
if (path == 'lib') {
return _ok({
'entries': [_fileEntry(name: 'app.dart', path: 'lib/app.dart')],
});
}
return _ok({'entries': <Object?>[]});
});
final c = makeCtrl();
await c.load();
if (expandLib) await c.toggle('lib');
return c;
}
test('visibleNodes flattens the root + expanded children in render order', () async {
final c = await tree(expandLib: true);
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'lib/app.dart', 'main.dart']);
expect(c.visibleNodes().map((n) => n.depth), [0, 1, 2, 1]);
});
test('a collapsed directory hides its children from the visible list', () async {
final c = await tree();
expect(c.visibleNodes().map((n) => n.path), ['', 'lib', 'main.dart']);
});
test('moveSelection walks the visible list and clamps at the ends', () async {
final c = await tree(expandLib: true);
expect(c.selectedPath, isNull);
c.moveSelection(1);
expect(c.selectedPath, ''); // first move lands on the root
c.moveSelection(1);
expect(c.selectedPath, 'lib');
c.moveSelection(2);
expect(c.selectedPath, 'main.dart'); // lib/app.dart skipped over by +2
c.moveSelection(5); // clamp at the bottom
expect(c.selectedPath, 'main.dart');
c.moveSelection(-100); // clamp at the top
expect(c.selectedPath, '');
});
test('selectEdge jumps to the first / last visible row (gg / G)', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: false);
expect(c.selectedPath, 'main.dart');
c.selectEdge(top: true);
expect(c.selectedPath, '');
});
test('expandOrInto expands a collapsed dir, then steps into its first child', () async {
final c = await tree();
c.moveSelection(1); // root
c.moveSelection(1); // lib (collapsed)
expect(c.isExpanded('lib'), isFalse);
await c.expandOrInto(); // expands
expect(c.isExpanded('lib'), isTrue);
expect(c.selectedPath, 'lib'); // selection stays on the dir
await c.expandOrInto(); // steps into first child
expect(c.selectedPath, 'lib/app.dart');
});
test('collapseOrOut collapses an expanded dir, then steps out to the parent', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: true);
c.moveSelection(2); // lib/app.dart
expect(c.selectedPath, 'lib/app.dart');
await c.collapseOrOut(); // a file → step to parent
expect(c.selectedPath, 'lib');
await c.collapseOrOut(); // an expanded dir → collapse in place
expect(c.isExpanded('lib'), isFalse);
expect(c.selectedPath, 'lib');
});
test('activateTarget reports the selected row as dir-or-file for the view', () async {
final c = await tree(expandLib: true);
c.selectEdge(top: true);
c.moveSelection(1); // lib
expect(c.activateTarget(), (isDirectory: true, path: 'lib'));
c.moveSelection(2); // main.dart
expect(c.activateTarget(), (isDirectory: false, path: 'main.dart'));
});
});
group('FileTreeController — dispose()', () {
test('dispose cancels event subscription without error', () async {
f.ipc.stub('files.root', (_) async => _ok({'path': '/ws'}));
@@ -395,7 +488,7 @@ void main() {
c.dispose();
ctrl = null; // prevent tearDown from double-disposing
f.services.events.emit(DaemonEvent(subsystem: 'files', kind: 'files.changed', data: {'path': 'README.md'}, ts: DateTime.now().toUtc()));
await Future<void>.delayed(Duration.zero);
await pumpEventQueue();
// Test passes if no exception.
});
});

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