24 Commits
Author SHA1 Message Date
jpmschweitzerandClaude e2b3f7cff1 update CLAUDE.md and reserve decision/ticket/claude-control slots
test / unit + widget + golden + a11y (push) Successful in 4m34s
test / integration_test (xvfb) (push) Failing after 1m4s
test / bundle smoke (xvfb 5s) (push) Failing after 58s
test / daemon subprocess + web WASM smoke (push) Successful in 2m27s
CLAUDE.md gains a "Decision discipline" guardrail pointing at
decisions/<domain>.md, rewrites every inline ADR link to the new
anchor-link style (ADR 0001 → D-001, etc.), collapses the bottom
"Open questions" section to a one-line pointer, and updates the
parent-project note to reference decisions/architecture.md instead
of the deleted docs/ADRs/.

Three extension slots are reserved with id-only stubs, registered
alongside the existing 21 built-ins in main.dart:
  - builtin.decisions — future sidebar for D/Q/R records
  - builtin.tickets — future sidebar + kanban workspace
  - builtin.claude-control — future .claude/ first-class surface
    (Settings / Skills / Agents / Hooks / MCP). Distinct from the
    existing builtin.claude PTY-pane stub.

`flutter analyze` clean; builtin + a11y test suites still pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 17:32:06 +02:00
jpmschweitzerandClaude 4dd9a74be7 add decisions-validate pre-push gate
One new Makefile target (tools/scripts/plan decisions validate)
wired as a prerequisite of push-check so malformed records fail
locally before they reach a reviewer. Parser dry-run is cheap
(~100ms) and stays within the <90s pre-push budget.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 17:28:44 +02:00
jpmschweitzerandClaude a96a54ec7f add Python stopgap planning tooling under tools/scripts/plan
Ports settled-reach's decisions_sync.py + ticket + decision scripts,
Scrum-stripped. Writes to .pql/pql.db (gitignored). Verb shape
mirrors the eventual `pql` subcommands so migration is a call-site
find-replace once pql ships feature parity (D-040, R-011).

Ticket IDs are T-NNN (TEXT PKs) — reshape from settled-reach's
integer auto-increment so the stopgap's writes are compatible with
pql's future reads without a data migration. No `sprints` table; the
pyramid is kanban / waterfall (D-035).

Verified end-to-end: sync parses 71 records (39 confirmed, 23 open
questions, 9 rejected) with 62 cross-refs and zero broken links;
`ticket new`, `ticket status`, `ticket board`, and `decisions show
--with-refs` all round-trip correctly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 17:28:10 +02:00
jpmschweitzerandClaude cede971646 migrate ADRs into D/R records; delete docs/ADRs/
Lift docs/ADRs/0001-0006 into decisions/:
  0001 → D-001 (CLI-first, not MCP)
  0002 → R-002 (Go sidecar; superseded by D-005)
  0003 → D-003 (pql as supporter tool; wrap, don't duplicate)
  0004 → D-004 (ignore file strategy)
  0005 → D-005 (Dart core; sidecar dissolved; ptyc as pql-peer)
  0006 → D-006 (CLI and event surface contract)

ADR 0006's trailing open-questions footer migrates to
questions-architecture.md as Q-001 / Q-002 / Q-003 (already landed
in the scaffold commit). Titles preserved verbatim; cross-references
updated to the new anchor-link style.

docs/ADRs/ deleted — originals remain in git history.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 17:13:37 +02:00
jpmschweitzerandClaude 4d515ce51e scaffold decisions/ with backlog from planning sessions
Adopt settled-reach's Q&D record convention. Confirmed decisions
live under decisions/<domain>.md as D-NNN; open questions under
questions-<domain>.md as Q-NNN; rejected alternatives in rejected.md
as R-NNN. Markdown is source of truth; .pql/pql.db (added later) is a
query index.

Backlog captured from the Tier-0 Flutter planning sessions: bare
WidgetsApp, theme pipeline, kernel admission rule, feature-first
layout, a11y + i18n as Tier-0 contracts, test pyramid, kanban over
Scrum, pql-owns-planning, Python stopgap sunset clause.

ADR migration (D-001, D-003-D-006 confirmed, R-002 rejected) is
staged for the next commit so the diff stays readable.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 17:10:42 +02:00
jpmschweitzerandClaude 77e395b3f2 document test layers and Claude's UI workflow
Three docs under docs/testing/:

  * README.md — layer-by-layer reference (what each covers, the
    runner, how to invoke, local vs CI flow). Points at the load-
    bearing startup gate and the client-side-only constraint so a
    macOS clone runs the same suite as Linux.
  * a11y-manual.md — 15-minute manual checklist run at every tier
    cut. Orca on Linux, VoiceOver on macOS. Catches prose drift
    automation can't judge.
  * claude-ui-workflow.md — how Claude Code drives the WASM build
    through Playwright, including the `flt-semantics-placeholder`
    quirk (semantics are opt-in in Flutter web; the driver
    auto-clicks the placeholder to enable them).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:50:05 +02:00
jpmschweitzerandClaude 7507e6c7fc add Makefile test-layer targets + rename coverage.sh
Makefile grows targets for each test layer (test, test-a11y,
test-integration, test-e2e, test-all), the coverage + smoke-bundle
helpers, and the UI harness (ui-dev, ui-stop, ui-smoke). push-check
now runs `test + test-a11y` — fast pre-push gate under 90s.

ci/coverage.sh is renamed to ci/test_coverage.sh so it matches the
`test_*.sh` naming of the other layer scripts and sidesteps the
repo's `coverage.*` gitignore pattern (intended for coverage output
files like coverage.lcov, not scripts).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:49:45 +02:00
jpmschweitzerandClaude 02cabcad35 add per-layer CI scripts and Gitea workflow (not activated)
ci/*.sh — shell-only, client-side-only so `git clone && make test`
works on any Linux or macOS dev box without network or shared state.
One script per testing layer so both Makefile targets and the CI
workflow can call them without duplicating logic. Rewrite of the
existing ci/test.sh to shell out to dart + flutter layers in one
pass (analyze, format, dart test, flutter test) plus five new
scripts for the other layers.

smoke_bundle.sh is the "tests passed but app doesn't start" gate
the user flagged: builds the Linux release bundle, runs it under
xvfb for 5s, fails on any non-SIGTERM exit — catches dynamic-linker
errors, missing-asset regressions, plugin-init crashes that widget
tests can't see.

.gitea/workflows/test.yml is a four-job pipeline (unit, integration,
startup-bundle, e2e) that shells out to the ci/*.sh scripts. NOT
activated — Gitea Actions has to be enabled in the instance settings
first. GitHub-Actions-compatible, so copying to .github/workflows/
is the whole migration if the repo moves.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:48:41 +02:00
jpmschweitzerandClaude c8d019e630 add web WASM Playwright harness for UI driving
tools/ui/ — scripts + Playwright config that let Claude Code (and
humans) drive the Flutter WASM build in a real browser. The point is
to avoid screenshot round-trips: every interactive widget in clide
ships a Semantics wrapper anyway (a11y requirement), so the automation
layer just queries the `flt-semantics[aria-label]` DOM.

  * build.sh — `flutter build web --wasm` from app/
  * serve.sh — `python3 -m http.server 4280` in the background.
    Before binding, kills any stale listener on the port (orphans
    from earlier failed runs no longer accumulate).
  * stop.sh — port-based kill; escalates to SIGKILL after 300ms.
    The pidfile is now advisory — port ownership is the source of
    truth.
  * driver.ts — `ClideDriver` class with `byLabel`, `click`, `type`,
    `readText`, `screenshot`, `dumpSemanticsTree`, and
    `waitUntilReady` that auto-clicks the `flt-semantics-placeholder`
    so the semantic tree is populated before queries.
  * tests/smoke.spec.ts — first driver test; asserts welcome +
    disconnected labels surface in the Semantics DOM.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:48:07 +02:00
jpmschweitzerandClaude 370e800864 add integration tests for app boot, theme picker, extension lifecycle
Three tests under app/integration_test/, run against the real built
app via the integration_test package. The load-bearing one is
app_starts_test.dart — boots ClideApp, waits for the root shell to
settle, asserts the three-column layout + welcome tab + statusbar
indicator all render. This is the "tests pass and then app fails to
start" gate that widget tests can't see because they never pump the
real bindings.

theme_picker_test.dart invokes theme.pick, asserts the modal opens,
selects a theme, asserts the modal dismisses.

extension_lifecycle_test.dart disables ipc-status via settings,
asserts its statusbar item unmounts, re-enables, asserts it
reappears.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:47:36 +02:00
jpmschweitzerandClaude 96b26e29f0 add app test suite — unit, widget, golden, a11y (168 tests)
Four layers under app/test/:

  * kernel/, extension/ — unit tests for every kernel service and
    the extension contract. i18n fallback chain gets the full
    matrix (exact/lang/default/placeholder, namespace isolation,
    unknown namespace, interpolation). Theme resolver and
    extension-manager topo sort covered.
  * widgets/, builtin/ — widget tests for every primitive and each
    Tier 0 built-in. Assertions reach into the Semantics node so
    a missing label fails structurally, not visually.
  * goldens/ — Alchemist + Ahem font for cross-platform pixel
    stability; primitives only (button, tab bar, icon set). No
    goldens for compositions — they'd churn through every tier.
  * a11y/ — contract-level gate: semantic coverage walks the
    built-in catalogue, contrast walks every token pair in every
    bundled theme against WCAG-AA thresholds (catches real
    regressions: the first run caught `tab.inactive_text` at 2.81
    in summer-night), i18n coverage asserts every referenced key
    exists in its catalog (exists-in-map, not "key == value" — too
    ambiguous for keys that happen to equal their translation).

Helpers under helpers/: `KernelFixture.create()` boots a
KernelServices with in-memory stores + a FakeDaemonClient (no
socket, drivable connected-state), `widget_harness` wraps a
widget in the minimum tree that resolves theme + i18n.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:47:14 +02:00
jpmschweitzerandClaude b13483e2d5 scaffold Flutter app — kernel, extensions, widgets, Tier 0 built-ins
First real content under app/. Lays the whole Tier 0 foundation in one
commit because the pieces depend on each other circularly (kernel →
extension → widgets → built-ins all reference types from the layer
below); splitting would leave intermediate commits that don't compile.

Key shapes:

  * bare WidgetsApp root — no Material, no Cupertino, no Scaffold.
    ClideTheme InheritedWidget is the only source of tokens.
  * ClideKernel InheritedWidget aggregates 18 services (settings,
    project, extensions, theme, panels, events, ipc, commands +
    palette + keybindings, clipboard, files, notify, dialog, tray,
    secrets, os, net, focus, log, i18n). ExtensionContext exposes
    them through a stable interface.
  * ClideExtension + sealed ContributionPoint hierarchy (Tab,
    StatusItem, Toolbar, Command, TrayItem, LayoutPreset). One
    manifest ships N contributions into kernel slots.
  * Three-tier theme pipeline: palette (named colors) → semantic
    roles → ~60 surface tokens. Defaults at each layer so legacy
    palette-only themes produce a complete SurfaceTokens.
  * A11y baked in from day one — every interactive primitive wraps
    in Semantics(label:, hint:, button:); ensureSemantics() at boot;
    theme/contrast.dart helper exposes token pairs for the WCAG
    gate in the a11y test suite.
  * i18n ported from fframe's L10n pattern (text-driven, namespaced
    JSON catalogs, caller-supplied placeholders) with a proper
    locale fallback chain (exact → language → default → placeholder)
    fframe lacks.
  * Four Tier-0 built-ins live (default-layout, welcome, ipc-status,
    theme-picker) plus 17 id-reserving stubs so later tiers fill in
    without rename churn.

.gitignore extended to cover app/ sub-package artefacts and the
Playwright harness scratch dirs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:39:41 +02:00
jpmschweitzerandClaude 235cbcc046 add Dart core package — IPC envelopes, daemon, ping round-trip
First real content for the `clide` Dart package at the repo root.
One AOT-compiled binary (ADR 0005) with two modes:

  * `clide --daemon`  long-running unix-socket server; listens on
    `$XDG_RUNTIME_DIR/clide-$USER.sock` with stale-socket
    reclaim, accepts JSON-lines request/response traffic, clean
    SIGTERM shutdown unlinks the socket file.
  * `clide <subcommand>`  one-shot; opens the socket, sends a
    request, writes the response JSON to stdout, exits with the
    dispatcher's error code per ADR 0006 (0/1/2/3/4). Unknown
    subcommands forward to the daemon so extensions can register
    their own without CLI changes.

Tier 0 handlers: `ping` (returns pong + version + UTC ts) and
`version`. Both are covered by `test/ipc/` + `test/daemon/`; the
subprocess test builds `bin/clide`, starts it, pings it, SIGTERMs
it, and asserts the socket file disappears.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:36:40 +02:00
jpmschweitzerandClaude bded1a2b65 add Bazzite Flutter dev setup script
One-shot installer that drops the Flutter SDK under ~/opt/flutter,
wires PATH in ~/.bashrc and ~/.zshrc, and layers the desktop build
deps (GTK, ninja, clang, etc.) via rpm-ostree install. Reboot
required before `flutter doctor` will report green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:36:11 +02:00
jpmschweitzerandClaude 329923538e swap .claude permissions from Go tooling to Dart + Flutter
Go sidecar is retired (ADR 0005). The matching Claude-Code allow list
still permitted `go`, `golangci-lint`, `goreleaser`, `govulncheck`,
`npm` — none of which are used anymore. Narrow the allow list to the
commands that actually run in this project.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 15:35:47 +02:00
jpmschweitzerandClaude 9522625304 retire Go sidecar; adopt Dart core (ADR 0005)
ADR 0005 supersedes ADR 0002. The "sidecar vs app" split was
load-bearing on Go-vs-Dart being a meaningful boundary. Going
all-Dart collapses that — the daemon becomes clide --daemon mode
of the same AOT binary the CLI lives in, both sharing lib/ with
the Flutter app. The one native gap (Dart's multi-threaded VM
can't safely fork+exec) is filled by a small C supporter tool —
ptyc, Project Terminal Controller, peer of pql — rather than
introducing a second core language.

ADR 0006 defines the CLI/event surface on top of that Dart core:
subsystem list (pane/tab/editor/panel/tree/git/pql/canvas/graph/
theme/settings/project), command shape, versioned JSON event
schema, pql-parity exit codes, and command-event duality as the
operational form of user/Claude parity.

Deleted: sidecar/cmd, sidecar/go.mod, every sidecar/internal
package. Rewritten: Makefile (dart compile exe, flutter
analyze/format/test, build-linux/build-macos, ptyc-build),
ci/*.sh, .githooks/pre-push (no more GOBIN PATH dance),
.gitignore (Flutter/Dart at repo root, ptyc section),
project.yaml (drop module: and go_version:), CLAUDE.md
(guardrails, dependencies, commands refreshed).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-21 00:28:27 +02:00
jpmschweitzerandClaude Opus 4.7 0de5f06f43 add pre-push quality gate
.githooks/pre-push runs make push-check, which aggregates the gates
that must pass before a push lands: Go lint, test, test-race, build,
test-integration, vuln, plus Flutter app-analyze and app-test. App-
side targets gracefully noop until Flutter is scaffolded, so the
gate is usable today without waiting on the app bootstrap.

Hooks are versioned under .githooks/ rather than the usual local
.git/hooks so the gate travels with the repo. `make hooks` wires
them up by pointing git core.hooksPath at the tracked directory —
one-time setup, documented in CLAUDE.md alongside `make tools`.

The hook prepends $GOBIN/$HOME/go/bin to PATH before invoking make,
so govulncheck / goimports / golangci-lint installed via `make tools`
resolve even when the user hasn't added that directory to their
login PATH.

The git-commit skill already forbids --no-verify, which is what keeps
this gate meaningful: "the hook is slow" is a reason to fix the slow
test, not to bypass the gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:14:12 +02:00
jpmschweitzerandClaude Opus 4.7 c39c2df6f5 add Go sidecar and CLI skeleton
cmd/clide/main.go is the single entry; the same binary will later
switch into sidecar-daemon mode via --daemon. internal/cli holds the
dispatch (stdlib flag for now, Cobra arrives with the first real
subcommand in Tier 2). internal/diag mirrors pql's exit-code contract
so the two tools share one mental model for callers. internal/version
exposes the build-info struct that the Makefile's -ldflags -X targets
stamp; Info() is wired to --version so the binary reports both the
declared project.yaml version and the commit it was built from.

The daemon, pty, proc, git, ipc, and pql sub-packages ship as doc.go
stubs naming the subsystem they will own. They keep the layout legible
before the code lands so tier work has a home on arrival.

Module path targets Gitea (git.schweitz.net/jpmschweitzer/clide/sidecar),
matching where the repo lives. Go module resolution works if the host
serves the meta tags; a vanity import path can be added later if
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 22:11:54 +02:00
jpmschweitzerandClaude Opus 4.7 4ae53ba16a add Makefile, ci scripts, and pinned tooling
Root Makefile drives both the Go sidecar under sidecar/ and the Flutter
app under app/ through one interface. Mirrors the pql/claudian pattern:
VERSION read from project.yaml via awk and stamped into the sidecar
binary via -ldflags -X, so the version the codebase claims to be and
the version the binary reports cannot drift.

Flutter targets (app-analyze, app-test, app-build-*) check for
app/pubspec.yaml and flutter on PATH and gracefully noop when either
is missing. That makes the Makefile usable today — before the app is
scaffolded — without ceremony.

ci/ scripts shell out to the Makefile so local dev and CI run the same
commands. lint.sh includes the supply-chain gate (make security) so
there is no version of "green lint, known-vulnerable dep" that CI
accepts.

make tools installs govulncheck, goimports, and golangci-lint at
exact pinned versions — bump deliberately, never floating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:47:53 +02:00
jpmschweitzerandClaude Opus 4.7 16e43d3096 add CLAUDE.md orientation for the Flutter rebuild
Lean orientation doc for future Claude Code instances. States the
three surfaces (Flutter app, Go sidecar/CLI, pql as supporter),
summarizes the guardrails as one-liners with pointers into ADRs
0001-0004, lists the tier ordering so new work doesn't skip ahead,
and names the parent projects (legacy/ for Python clide, the
discarded claudian experiment that contributed the architectural
patterns, pql as the active supporter). Commands section is a
placeholder until the Makefile lands in the next commit.

Orientation, not a decision log — rationale lives in the ADRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:44:44 +02:00
jpmschweitzerandClaude Opus 4.7 a48ccedfd4 add Flutter-rebuild project identity at the repo root
project.yaml is fresh and declares version 2.0.0-dev — a clean break
from Python clide's v1.2.0 (which remains under legacy/). It carries
module path, toolchain pins (go >=1.25, Flutter stable, Dart >=3.5),
and the Gitea repository URL. Same schema as the Makefile will read
via awk for sidecar version stamping.

README.md and LICENSE land alongside. The README pitches the rebuild
tersely and points at docs/initial-plan.md for the full story; the
Python README is preserved under legacy/README.md.

.gitignore is rewritten for the new toolchain. The previous file was
wall-to-wall Python rules; now the root ignores Flutter/Dart/Go build
output plus the usual OS/editor/secrets/.claude-local entries, and
Python-specific rules scope to legacy/** so they don't fire on any
stray caches at the repo root.

.editorconfig sets tabs for Go, two-space for Dart and everything
else, and the usual EOL + trailing-whitespace rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:43:41 +02:00
jpmschweitzerandClaude Opus 4.7 005d19ae93 add initial plan for the Flutter rebuild
docs/initial-plan.md is the new north star. It states the rebuild's
premise explicitly: Python Clide's IDE skeleton gets rebuilt native
in Flutter, a sharpened subset of Obsidian's ideas (just canvas and
graph — no vault, no bases, no plugin inheritance) folds in, and
the architectural patterns from the short-lived claudian plugin
port over via ADRs 0001–0004.

Tier 0 is "Flutter app + sidecar daemon connected, empty IDE."
Tier 1 is "Claude runs inside clide, session survives app restart."
Later tiers layer panes, git, pql integration, canvas and graph,
and an extension API — each ordered so the guardrails (CLI-first,
user/Claude parity, pql-as-subsystem) stay enforceable at every
step.

Future Claude Code instances should read this before proposing
changes that touch architecture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:41:43 +02:00
jpmschweitzerandClaude Opus 4.7 a782511470 carry forward ADRs, Claude Code config, and changelog discipline
Ports the patterns that crystallized during the short-lived claudian
plugin project (discarded in favour of this Flutter rebuild):

- ADRs 0001-0004 capture decisions that survive the host change —
  CLI-first over MCP, Go for the sidecar, pql as a supporter tool
  that becomes a clide-managed subsystem when present, and the
  ignore-file strategy that wires all file-enumerating surfaces
  through one knob in .pql/config.yaml.
- .claude/settings.json and the git-commit and skill-create skills
  come over with naming updated for clide. The git-commit skill's
  "no Conventional Commits" convention supersedes the Python-era
  clide style under legacy/; the Keep-a-Changelog discipline and
  the project.yaml-version-and-changelog-bumped-together rule
  apply going forward.
- CHANGELOG.md starts fresh at the repo root to track the Flutter
  rebuild. The Python changelog is preserved under legacy/.

.gitignore narrows from `.claude/` to just `.claude/settings.local.json`
so project-level config and skills travel with the repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:38:49 +02:00
jpmschweitzerandClaude Opus 4.7 a355751437 move python clide to legacy/
Clide is being rebuilt as a Flutter desktop app. The Python Textual
implementation moves wholesale into legacy/ rather than being deleted:
its pane model, panel set, git skills, and panel communication design
are real thought that should remain readable next to the new code
while the rebuild finds its shape. Git's rename tracking preserves
history, so `git log -- legacy/` still works.

The Flutter rebuild lives at the repo root alongside a Go sidecar
(the architecture claudian was heading toward, which folds into
clide as a core component rather than a separate plugin project).
Bootstrap of the new shape lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:30:51 +02:00
458 changed files with 17383 additions and 794 deletions
+34
View File
@@ -0,0 +1,34 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)",
"Bash(dart *)",
"Bash(flutter *)",
"Bash(make *)"
],
"deny": [
"Bash(rm -rf /*)",
"Bash(rm -rf ~*)",
"Bash(rm -rf $HOME*)",
"Bash(sudo rm *)",
"Bash(sudo chmod *)",
"Bash(sudo chown *)",
"Bash(mkfs*)",
"Bash(mkfs.*)",
"Bash(dd if=*)",
"Bash(git push --force*)",
"Bash(git push -f*)",
"Bash(git push --force-with-lease*)",
"Bash(git reset --hard*)",
"Bash(git clean -fd*)",
"Bash(git clean -fdx*)",
"Bash(git clean -ffd*)",
"Bash(git branch -D *)",
"Bash(git checkout -- *)",
"Bash(git restore .*)",
"Bash(chmod -R 777 *)",
"Bash(chmod 777 *)"
]
}
}
+113
View File
@@ -0,0 +1,113 @@
---
name: git-commit
description: >
Git commit conventions for this repo — message style, changelog and
version-bump rules, attribution trailer, and the safety rules that
always apply. Use whenever the user says "commit", "commit this",
"create a commit", "make a commit", "amend", or asks Claude to check
work into git in any form.
---
# Git commit rule — this repo
Follow these conventions whenever you create a commit in this repository. These are in addition to the standard Claude Code git-safety protocol (no `--no-verify`, no force-push to main, prefer new commits over `--amend`, etc.).
## Message style
- **First line:** imperative mood, ≤ 70 characters. Examples: `add sidecar PTY scaffold`, `fix IPC reconnect after app reload`, `update CLI exit-code contract`.
- **Body (optional):** wrap at ~72 chars. Explain the *why* — the reason this change exists. The diff already shows the *what*; don't restate it in prose.
- **No emojis.** Anywhere.
- **Don't prefix with types** like `feat:` or `fix:` — this repo isn't Conventional Commits. (The Python-era clide under `legacy/` used Conventional Commits; the Flutter rebuild at the repo root does not.)
- **Don't reference the current task or flow** (`for the v2.0 milestone`, `used by the canvas panel`) — that context belongs in the PR description and rots as the repo evolves.
- **Naming:** the project is `clide`. The Flutter desktop app lives at the repo root; the Go sidecar/CLI binary is `clide`. The supporter project is `pql` (referenced, not part of this repo). The archived Python implementation lives under `legacy/`.
## Logically-separated commits
Default to **one logical change per commit**, even when a lot of work lands in the tree at once. When there's a pile of uncommitted or untracked files:
1. **Read `git status` + `git diff` first** — never stage the whole tree blind.
2. **Group by concern**, not by file location. Typical concerns to separate:
- **Bookkeeping** — `.gitignore`, editor/IDE config, lockfiles, `go.sum` / `pubspec.lock` updates.
- **Documentation** — `README.md`, `CLAUDE.md`, ADRs under `docs/ADRs/`, design notes under `docs/`.
- **General-purpose tooling / skills** — things that aren't project-specific (reusable skills, shared scripts).
- **Project-specific conventions** — this repo's own rules.
- **Feature or subsystem** — one cohesive change per commit; a sidecar change and an app change for the same feature can land together, but two unrelated features should split.
- **Layer changes** — Flutter app, sidecar CLI, sidecar daemon, IPC server, pql wrapper, canvas driver, git panel — separate concerns; prefer separate commits when the changes are independent.
3. **Sequence the commits** so each one is cleanly scoped, but don't obsess about whether each intermediate commit "works" — for scaffolding PRs it's fine if the full picture only snaps together at the end.
4. **Prefer many small focused commits over one large mixed one** — a reviewer can read, revert, or cherry-pick a focused commit; they can't do any of those to a blob.
5. **Use `git add <specific paths>`** — never `git add -A` or `git add .` when splitting, or you'll sweep in the next commit's work by accident.
6. **Verify between commits** with `git status` and `git log -1` to confirm the split landed as intended.
Corollary: if a commit's subject line needs the word "and" to be accurate, it probably should have been two commits.
## Changelog discipline
This repo follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). **Every user-visible commit must touch `CHANGELOG.md`** under the `## [Unreleased]` section, in the appropriate subsection:
- **Added** — new features or capabilities.
- **Changed** — changes to existing functionality.
- **Deprecated** — soon-to-be-removed features.
- **Removed** — now-removed features.
- **Fixed** — bug fixes.
- **Security** — vulnerability fixes.
Entries should be short imperative phrases that describe user-facing impact — not implementation detail. "Added sidecar PTY support for terminal pane" beats "Added `internal/pty/session.go`."
**What skips the changelog:** pure bookkeeping commits that have no user-visible effect (typo fix in internal comment, `.gitignore` tweak, lint config change, reformatting). When in doubt, add an entry — the harm of an extra line is zero.
When a commit spans multiple entries (e.g. a feature that adds one thing and fixes another), add a line under each applicable subsection rather than cramming both into one.
## Cutting a release
Cutting a release is its own commit. In a single commit:
1. Move all entries from `## [Unreleased]` under a new heading `## [X.Y.Z] — YYYY-MM-DD`.
2. Leave an empty `## [Unreleased]` section at the top with its subsection skeleton ready.
3. Bump `project.yaml` `version:` to `X.Y.Z` (drop the `-dev` suffix for the tag; re-add it on the next development commit if desired).
4. Commit subject: `release vX.Y.Z`.
`project.yaml` is the single source of truth for the version — the Makefile reads it for ldflag stamping of the sidecar binary, and the Flutter app reads it for build info. Bumping `project.yaml` and the changelog out of sync is the mistake this rule prevents.
## Attribution trailer
Every commit ends with the Claude co-author line:
```
Co-Authored-By: Claude <noreply@anthropic.com>
```
The model-identifier variant produced by the Claude Code harness (e.g. `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`) is also accepted — don't rewrite it if the harness emits that form.
## HEREDOC discipline
Pass commit messages via HEREDOC so multi-line formatting survives:
```bash
git commit -m "$(cat <<'EOF'
short imperative summary
Optional longer body explaining why this change was needed,
wrapped at about 72 characters.
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
Never pass multi-line messages via `-m "line1\nline2"` or multiple `-m` flags — Git's behavior differs between shells and quoting regimes and the trailer can end up in the wrong place.
## What not to commit
- `.env` and any `*.env.local` — see `.gitignore`.
- `.claude/settings.local.json` — user-specific Claude Code settings, ignored.
- Build artefacts: `sidecar/bin/`, `sidecar/dist/`, `build/` (Flutter output), `app/.dart_tool/` — gitignored.
- SQLite index files (`*.sqlite`, `*.sqlite-wal`, `*.sqlite-shm`, `*.db`) — caches generated against local repos; must never land here. Gitignored defensively.
- Coverage / test output (`*.out`, `coverage.*`, `*.test`) — gitignored.
## Safety reminders (reinforced from the global Claude Code protocol)
- **Never** `--no-verify`. If a pre-commit hook fails, fix the underlying issue and create a new commit.
- **Never** `--amend` a commit unless the user explicitly asks. A failed-hook commit didn't land, so amending would overwrite the *previous* commit and lose work.
- **Never** force-push to `main` or `master`. Warn the user if they ask.
- When staging, prefer naming specific files over `git add -A` / `git add .` — those can sweep in secrets or unintended files.
- `git status` before staging, `git diff --staged` before committing, `git log -1` after committing to confirm.
+203
View File
@@ -0,0 +1,203 @@
---
name: skill-create
description: >
Guidance for creating effective Claude Code skills (.skill packages).
Use when the user wants to create, build, design, or iterate on a skill —
including writing SKILL.md files, bundling scripts/references/assets,
initializing new skills, packaging skills, or improving existing ones.
Triggers on requests like "create a skill", "make a new skill",
"build a skill for X", "package this skill", or "improve my skill".
---
# Skill Creator
## About Skills
Skills are modular, self-contained packages that extend Claude's capabilities
by providing specialized knowledge, workflows, and tools. They transform Claude
from a general-purpose agent into a specialized agent equipped with procedural
knowledge that no model can fully possess.
### What Skills Provide
- **Specialized workflows** — Multi-step procedures for specific domains
- **Tool integrations** — Instructions for working with specific file formats or APIs
- **Domain expertise** — Company-specific knowledge, schemas, business logic
- **Bundled resources** — Scripts, references, and assets for complex and repetitive tasks
## Core Principles
### Concise is Key
The context window is a public good. Skills share it with everything else Claude
needs: system prompt, conversation history, other skills' metadata, and the
actual user request.
Default assumption: Claude is already very smart. Only add context Claude doesn't
already have. Challenge each piece of information: "Does Claude really need this
explanation?" and "Does this paragraph justify its token cost?"
Prefer concise examples over verbose explanations.
### Set Appropriate Degrees of Freedom
Match specificity to the task's fragility and variability:
- **High freedom** (text-based instructions): Multiple approaches valid, decisions
depend on context, heuristics guide the approach.
- **Medium freedom** (pseudocode or scripts with parameters): Preferred pattern
exists, some variation acceptable, configuration affects behavior.
- **Low freedom** (specific scripts, few parameters): Operations are fragile and
error-prone, consistency is critical, specific sequence must be followed.
Think of Claude as exploring a path: a narrow bridge with cliffs needs specific
guardrails (low freedom), while an open field allows many routes (high freedom).
## Anatomy of a Skill
```
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ ├── description: (required)
│ │ └── compatibility: (optional, rarely needed)
│ └── Markdown instructions (required)
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
```
### SKILL.md (required)
- **Frontmatter (YAML)**: `name` and `description` fields (required). Only these
are read by Claude to determine when the skill triggers — be clear and
comprehensive. The `compatibility` field is for environment requirements but
most skills don't need it.
- **Body (Markdown)**: Instructions and guidance. Only loaded AFTER the skill
triggers.
### Bundled Resources (optional)
**Scripts (`scripts/`)** — Executable code for tasks requiring deterministic
reliability or that are repeatedly rewritten.
**References (`references/`)** — Documentation loaded as needed into context.
Keep SKILL.md lean; move detailed reference material, schemas, and examples here.
If files are large (>10k words), include grep search patterns in SKILL.md.
**Assets (`assets/`)** — Files used in output, not loaded into context (templates,
images, icons, boilerplate code, fonts).
### What to NOT Include
Do NOT create extraneous files like README.md, INSTALLATION_GUIDE.md,
QUICK_REFERENCE.md, CHANGELOG.md, etc. The skill should only contain information
needed for an AI agent to do the job.
## Progressive Disclosure
Skills use a three-level loading system:
1. **Metadata** (name + description) — Always in context (~100 words)
2. **SKILL.md body** — When skill triggers (<5k words)
3. **Bundled resources** — As needed (unlimited; scripts can run without reading)
Keep SKILL.md body under 500 lines. Split content into separate files when
approaching this limit. Reference split files from SKILL.md with clear
descriptions of when to read them.
### Disclosure Patterns
**Pattern 1: High-level guide with references** — Keep overview in SKILL.md,
link to detail files loaded only when needed.
**Pattern 2: Domain-specific organization** — Organize content by domain
(e.g., `references/finance.md`, `references/sales.md`) so only relevant content
is loaded.
**Pattern 3: Conditional details** — Show basic content, link to advanced
content loaded only when the user needs those features.
Guidelines:
- Avoid deeply nested references — keep one level deep from SKILL.md
- Structure longer reference files with a table of contents at the top
## Skill Creation Process
Follow these steps in order, skipping only with clear reason:
### Step 1: Understand the Skill with Concrete Examples
Skip only when usage patterns are already clearly understood.
Ask the user for concrete examples of how the skill will be used:
- "What functionality should the skill support?"
- "Can you give some examples of how this skill would be used?"
- "What would a user say that should trigger this skill?"
Avoid overwhelming users — start with the most important questions.
### Step 2: Plan the Reusable Skill Contents
Analyze each example by considering how to execute from scratch and identifying
what scripts, references, and assets would help with repeated execution.
Establish a list of reusable resources: scripts, references, and assets.
### Step 3: Initialize the Skill
Create the skill directory manually:
```
mkdir -p <output-directory>/<skill-name>
```
Then create `SKILL.md` with frontmatter and body. Add `scripts/`, `references/`,
and `assets/` subdirectories only as needed.
Skip if iterating on an existing skill.
### Step 4: Edit the Skill
Remember the skill is for another Claude instance to use. Include beneficial,
non-obvious information.
For design patterns, consult:
- `references/workflows.md` — Sequential workflows and conditional logic
- `references/output-patterns.md` — Template and example patterns
**Implementation order:**
1. Start with reusable resources (`scripts/`, `references/`, `assets/`)
2. Test added scripts by running them
3. Delete unused example files from initialization
4. Update SKILL.md
**Writing guidelines:** Always use imperative/infinitive form.
**Frontmatter:**
- `name`: The skill name — use **domain-action** naming: `{domain}-{action}`.
The domain is the system/area the skill operates on, the action is what it does.
Examples: `pr-review`, `sprint-plan`, `docs-search`, `git-commit`, `debt-scan`.
Multi-action wrappers (like `ticket`) can use the domain name alone.
The directory name must match the `name` field.
- `description`: Primary triggering mechanism. Include what the skill does AND
specific triggers/contexts. All "when to use" info goes here (not in body).
**Body:** Instructions for using the skill and its bundled resources.
### Step 5: Validate the Skill
Check the skill manually:
- Frontmatter has `name` and `description`
- SKILL.md body is under 500 lines
- No extraneous files (README.md, CHANGELOG.md, etc.)
- Scripts are executable and tested
- References are referenced from SKILL.md
### Step 6: Iterate
After real usage, notice struggles or inefficiencies, identify needed updates,
implement changes, and test again.
+22
View File
@@ -0,0 +1,22 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.go]
indent_style = tab
indent_size = 4
[*.dart]
indent_size = 2
[Makefile]
indent_style = tab
[*.md]
trim_trailing_whitespace = false
+71
View File
@@ -0,0 +1,71 @@
# Gitea Actions workflow for clide.
#
# NOT YET ACTIVATED. Gitea Actions must be enabled in the instance
# settings before this runs; until then the file is just a ready-made
# pipeline Claude + the user can review.
#
# When the repo eventually lands on GitHub, copy this file verbatim to
# `.github/workflows/test.yml` — Gitea Actions consumes GitHub-Actions
# syntax, so no rewrite is needed.
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
name: unit + widget + golden + a11y
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: dart pub get
- run: (cd app && flutter pub get)
- run: ci/test.sh
- run: ci/test_a11y.sh
- run: ci/test_coverage.sh
integration:
name: integration_test (xvfb)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: dart pub get
- run: (cd app && flutter pub get)
- uses: coactions/setup-xvfb@v1
with: { run: ci/test_integration.sh }
startup-bundle:
name: bundle smoke (xvfb 5s)
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- run: sudo apt-get update && sudo apt-get install -y xvfb ninja-build libgtk-3-dev
- run: dart pub get
- run: (cd app && flutter pub get)
- run: ci/smoke_bundle.sh
e2e:
name: daemon subprocess + web WASM smoke
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with: { channel: stable, cache: true }
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: dart pub get
- run: (cd app && flutter pub get)
- run: (cd tools/ui && npm install && npx playwright install --with-deps chromium)
- run: ci/test_e2e.sh
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Pre-push gate. Blocks the push if any quality check fails.
#
# Install: `make hooks` (points git core.hooksPath at .githooks/).
# Bypass: never. If this runs slowly, fix the slow test; don't reach
# for --no-verify (git-commit skill forbids it).
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
echo "==> pre-push: make push-check"
make push-check
+88 -175
View File
@@ -1,188 +1,101 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# -- Flutter / Dart (app + core at repo root) ---------------------------
/.dart_tool/
/.packages
/.flutter-plugins
/.flutter-plugins-dependencies
/build/
/.idea/
/.vscode/
/*.iml
/ios/Pods/
/macos/Pods/
/windows/flutter/ephemeral/
/linux/flutter/ephemeral/
/macos/Flutter/ephemeral/
/bin/clide
/bin/clide.exe
# pubspec.lock is committed (supply-chain policy).
# C extensions
*.so
# Flutter app sub-package under app/. Same patterns, different prefix.
app/.dart_tool/
app/.flutter-plugins
app/.flutter-plugins-dependencies
app/build/
app/*.iml
app/ios/Pods/
app/macos/Pods/
app/windows/flutter/ephemeral/
app/linux/flutter/ephemeral/
app/macos/Flutter/ephemeral/
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# UI harness (Playwright) — npm + output artefacts.
tools/ui/node_modules/
tools/ui/out/
tools/ui/test-results/
tools/ui/playwright-report/
tools/ui/.serve.pid
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
!clide.spec
# -- ptyc (C supporter tool) --------------------------------------------
/ptyc/bin/
/ptyc/*.o
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# -- Test, coverage, profile output ------------------------------------
*.test
*.out
*.prof
*.pprof
coverage.*
/tmp/
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# -- pql per-repo state (clide dogfoods against itself; pql's index lands
# here when running queries locally). `pql.db` is also where the
# `tools/scripts/plan` stopgap writes decisions + tickets (D-040).
/.pql/
# Translations
*.mo
*.pot
# -- Python stopgap tooling under tools/scripts/plan ------------------
tools/scripts/__pycache__/
tools/scripts/planning/__pycache__/
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# -- SQLite index files (defensive; should never land at repo root) ----
*.sqlite
*.sqlite-journal
*.sqlite-wal
*.sqlite-shm
*.db
# Flask stuff:
instance/
.webassets-cache
# -- Editor / IDE ------------------------------------------------------
.vscode/
.idea/
*.swp
*.swo
# Scrapy stuff:
.scrapy
# -- OS cruft ----------------------------------------------------------
.DS_Store
._*
Thumbs.db
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
# -- Secrets -----------------------------------------------------------
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Clide specific
.config/
snapshot_report.html
logs/
# Credentials
.env.local
.env.*.local
*-credentials.md
# Claude Code
.claude/
# -- Claude Code — user-specific settings only; project-level settings
# and skills are committed
.claude/settings.local.json
# -- Legacy Python clide (under legacy/, pre-dating the Flutter rebuild).
# Narrowly scoped so repo-root ignores stay focused on the current
# toolchain.
legacy/.venv/
legacy/.ruff_cache/
legacy/**/__pycache__/
legacy/**/*.py[cod]
legacy/**/*$py.class
legacy/**/.pytest_cache/
legacy/**/.mypy_cache/
legacy/**/.ruff_cache/
legacy/**/*.egg-info/
legacy/**/htmlcov/
legacy/**/.coverage
legacy/**/.coverage.*
+120 -42
View File
@@ -1,60 +1,138 @@
# Changelog
All notable changes to this project will be documented in this file.
All notable changes to clide are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
The format follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.0] - 2026-02-24
This changelog tracks the Flutter rebuild at the repo root. The Python
Textual implementation's changelog is preserved under
[`legacy/CHANGELOG.md`](legacy/CHANGELOG.md).
Versions are tracked in [`project.yaml`](project.yaml) under `version:`,
which is the single source of truth. Cutting a release means (a) moving
the entries below from `## [Unreleased]` under a new dated version
heading, and (b) bumping `project.yaml` `version:` in the same commit.
## [Unreleased]
### Added
- **clide-web**: Python web server replacing ttyd + zellij for browser access
- FastAPI + uvicorn with WebSocket ↔ PTY bridge via tmux
- Project switching via `/projects/<name>` URL routing
- Vendored xterm.js (no CDN, works on disconnected LAN)
- Auto-respawn on Clide exit (tmux `pane-died` hook)
- Setup wizard for first-run configuration (`clide-web-setup`)
- Service management make targets (start-server, stop-server, etc.)
- **SQLite database layer**: SQLModel tables (Project, Session, UserPreference, ConnectionLog) shared between clide and clide-web
- **Dynamic tabbed workspace**: Multi-file editor and multi-terminal support with closable tabs
- Migrated the `docs/ADRs/` content into `decisions/` as D/R records:
ADR 0001 → `D-001`, ADR 0002 → `R-002` (superseded by `D-005`), ADR
0003 → `D-003`, ADR 0004 → `D-004`, ADR 0005 → `D-005`, ADR 0006 →
`D-006`. Titles preserved; ADR 0006's trailing open questions moved
to `questions-architecture.md` as `Q-001` / `Q-002` / `Q-003`. The
originals are preserved in git history.
- `decisions/` at the repo root — Q&D record system ported from
settled-reach and adapted for clide's domains. Confirmed decisions
(`D-NNN`) live under domain files (`architecture.md`, `extensions.md`,
`accessibility.md`, `testing.md`, `tooling.md`, `process.md`); open
questions (`Q-NNN`) live under parallel `questions-<domain>.md`;
rejected alternatives (`R-NNN`) live in `rejected.md`. Record shape,
claiming rules, and the eventual pql-side tooling plan are documented
in `decisions/README.md`. Migration of the existing `docs/ADRs/` into
these files lands in a follow-up commit.
- `DECISIONS.md` one-line pointer at the repo root (matches
settled-reach's convention).
- `tools/scripts/plan` — Python stopgap entrypoint for `decisions`
and `ticket` subcommands, writing to `.pql/pql.db` (gitignored).
Supports `decisions sync | validate | claim | list | show | coverage`
and `ticket new | list | show | status | assign | team | block |
unblock | label | search | board`, plus `sqlite-query`. Verb shape
and output format mirror the eventual `pql` subcommands so migration
when pql ships feature parity is a call-site find-replace
(`tools/scripts/plan ` → `pql `). Ported from settled-reach with
the Scrum layer stripped; ticket IDs are `T-NNN` (TEXT PKs) and
there's no `sprints` table. Time-limited per
[`D-040`](decisions/process.md#d-040-python-stopgap-under-toolsscriptsplan)
/ [`R-011`](decisions/rejected.md#r-011-permanent-stopgap).
- `make decisions-validate` — cheap parser dry-run wired into
`push-check`. Catches malformed records before push.
- Reserved extension slots — `builtin.decisions`, `builtin.tickets`,
`builtin.claude-control`. Id-reserving stubs under
`app/lib/builtin/` with no contributions yet. Implementations land
once [`Q-021`](decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)
resolves (decisions + tickets) or when the claude-control tier
arrives (`.claude/` first-class surface — distinct from the
existing `builtin.claude` PTY-pane stub).
- `CLAUDE.md` — new "Decision discipline" guardrail pointing at
`decisions/`.
### Changed
- Context panel (Jira, TODOs, Problems) backgrounds now match sidebar (`$surface`)
- Makefile uses `printf` instead of `echo` for consistent ANSI color rendering
- Full PTY terminal emulator with pyte replaces simple terminal widget
- Nerd Font support and clipboard paste in terminal
- `CLAUDE.md` — inline ADR links rewritten to point at the migrated
`decisions/` records; bottom "Open questions" section collapsed to
a pointer at `decisions/questions-*.md`; parent-project note
updated to reference `decisions/architecture.md` instead of the
deleted `docs/ADRs/`.
### Removed
- **ttyd** (C binary fork with Nerd Font) — replaced by clide-web
- **zellij** (Rust binary for session persistence) — replaced by tmux
- `clide-launcher` shell script — logic absorbed into clide-web
- `update-ttyd.sh` build script
## [1.0.0] - 2026-02-01
- `docs/ADRs/` directory — content lifted into `decisions/` as D/R
records (see Added above). Originals preserved in git history.
### Added
- **Skill Installer Service**: Install bundled Claude Code skills (commit, branch, push, pull, stash) to user or project scope
- **TileListView Component**: Reusable card-style list widget with consistent styling
- **TODO.md Integration**: Parse and display project TODO.md files in the TODOs panel
- **File Watcher**: Real-time file system monitoring with reactive updates
- **Git Graph View**: Visual commit graph in sidebar
- **Branch Status Widget**: Enhanced branch display with remote tracking info
- **Theme System**: 22 built-in themes including Summer Night (default)
- **Alt-key Shortcuts**: VSCode-familiar keybindings that don't interfere with input fields
- Go sidecar skeleton under `sidecar/` — `cmd/clide/main.go`, `go.mod`, and the `internal/*` packages (`cli`, `daemon`, `diag`, `git`, `ipc`, `pql`, `proc`, `pty`, `version`). Deleted wholesale per [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md): the "sidecar language: Go" premise no longer holds once the core is Dart. All functionality listed for those packages will be reimplemented under `lib/` as part of Tier 0.
- Go-specific Makefile targets (`lint`, `vuln`, `test-race`, `fmt`, `tidy`, `snapshot`, `tools`, `install` via Go), the `govulncheck`/`goimports`/`golangci-lint` version pins, and the pre-push hook's `GOBIN` PATH injection. Replaced with Dart/Flutter equivalents (`analyze`, `format`, `test`, `test-integration`, `build` via `dart compile exe`).
- `module:` and `go_version:` from `project.yaml` — single-language core means no Go module path to track.
### Changed
- Restructured project with clear separation of controllers, services, and widgets
- Improved panel architecture with state preservation on hide/show
- Enhanced git integration with better status display
### Fixed
- Test fixtures updated for new widget architecture
## [0.1.0] - 2026-01-15
- [ADR 0002](docs/ADRs/0002-sidecar-language-go.md) marked **superseded** by [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md). The "sidecar language: Go" guardrail is retired. CLAUDE.md's guardrails, dependency notes, and command reference are updated to reflect the Dart-core direction.
- `.gitignore` retargeted: Flutter/Dart output at the repo root (`.dart_tool/`, `build/`, platform ephemeral dirs, `bin/clide`), plus a `ptyc/` section for the C helper's build artefacts. Go-specific rules removed.
- `ci/lint.sh`, `ci/test.sh`, `ci/security.sh`, and `.githooks/pre-push` rewritten for the Dart toolchain — no Go shell-outs, no `GOBIN` PATH dance.
### Added
- Initial project structure and TUI framework
- Basic panel layout (sidebar, workspace, context)
- Claude Code integration as primary workspace
- Pydantic models for data validation
- pytest test infrastructure
- Testing docs under `docs/testing/`: `README.md` (what each layer covers, how to run, local vs CI flow), `a11y-manual.md` (15-minute Orca + VoiceOver checklist run at every tier cut), `claude-ui-workflow.md` (how Claude Code drives the app through the Playwright harness, including the `flt-semantics-placeholder` quirk).
- Makefile targets for every test layer and the UI harness: `test`, `test-a11y`, `test-integration`, `test-e2e`, `test-all`, `coverage`, `smoke-bundle`, `ui-dev`, `ui-stop`, `ui-smoke`. `push-check` now runs `test + test-a11y` (fast pre-push gate, <90s).
- Per-layer CI shell scripts under `ci/`: `test.sh` (analyze + format + unit + widget + golden, ~5s), `test_a11y.sh` (a11y contract), `test_integration.sh` (integration_test one file at a time — desktop can't batch them reliably), `test_e2e.sh` (daemon subprocess + browser WASM Playwright smoke), `smoke_bundle.sh` (xvfb-run the Linux release bundle for 5s; catches dynamic-linker / asset-bundle / plugin-init regressions that widget tests can't see), `coverage.sh` (flutter test --coverage + lcov summary).
- `.gitea/workflows/test.yml` — four-job pipeline (`unit`, `integration`, `startup-bundle`, `e2e`) that shells out to the `ci/*.sh` scripts. **Not activated yet** — Gitea Actions has to be enabled in the instance settings first. GitHub-Actions-syntax-compatible, so copying to `.github/workflows/` is a one-file move when the repo migrates.
- Web WASM harness under `tools/ui/` — Playwright driver so Claude Code (and humans) can drive the Flutter build in a real browser via the Semantics tree. `build.sh` / `serve.sh` / `stop.sh` manage a local `http.server` on `:4280` with port-based reclaim and kill (so orphaned listeners from earlier runs get swept). `driver.ts` exposes `ClideDriver` with `byLabel` / `click` / `type` / `readText` / `screenshot` / `dumpSemanticsTree` / `waitUntilReady` (auto-clicks the `flt-semantics-placeholder` to enable the semantics tree). First Playwright test `smoke.spec.ts` asserts welcome + disconnected labels render in the browser.
- Integration tests under `app/integration_test/`, run with the `integration_test` package against the real built app (not an in-memory widget pump). The load-bearing startup gate lives here: `app_starts_test.dart` boots `ClideApp`, waits for the root shell to settle, and asserts the three-column layout + welcome tab + statusbar connection indicator all render. Also covers theme-picker modal open/select/dismiss (`theme_picker_test.dart`) and extension enable/disable lifecycle with contributions mounting/unmounting (`extension_lifecycle_test.dart`).
- App-level test suite under `app/test/` — 168 tests across four layers:
- **Unit** (`kernel/`, `extension/`) — events bus, settings (scope + YAML round-trip), log, i18n fallback chain matrix, theme resolver + loader + controller, panel registry + arrangement, command registry + keybinding parser + palette filter, extension-manager dep-order / cycle detection / enable-disable, manifest loader, extension scanner.
- **Widget** (`widgets/`, `builtin/`) — every primitive's Semantics presence + token consumption + hover/press states; each Tier 0 built-in's contributions, view, and locale-switch re-render.
- **Golden** (`goldens/`) — widget primitives only, Alchemist + Ahem font; PNG fixtures checked in under `_files/ci/` and `_files/linux/`.
- **A11y** (`a11y/`) — `semantic_coverage_test.dart` (contract-level check that every built-in carries title + version + label-ready contributions), `contrast_test.dart` (WCAG-AA ratio gate on every bundled theme's canonical token pairs), `i18n_coverage_test.dart` (asserts every Tier-0-referenced key is present in its `en_US` catalog), `keyboard_traversal_test.dart` (focusability smoke).
- Test helpers under `app/test/helpers/` — `KernelFixture` (boots a KernelServices with in-memory defaults + a fake daemon for widget-level tests), `FakeDaemonClient` (subclasses the real client, no socket, drivable connected-state), `golden_harness` (Alchemist config with Ahem font for cross-platform pixel stability), `widget_harness` (wraps a widget in Directionality + ClideKernel + ClideTheme + MediaQuery).
- Flutter desktop app scaffold under `app/` with a bare `WidgetsApp` root (no Material, no Cupertino) and the Tier 0 three-column layout.
- **Kernel** (`app/lib/kernel/`) — 18 services consumed by every extension: `settings` (scope-resolved get/set across `app.*`/`project.*`/`ext.*`), `project`, `extensions`, `theme`, `panels` (slot registry + arrangement), `events`, `ipc`, `commands` (+ palette + keybinding resolver), `clipboard`, `files`, `notify`, `dialog` (single-at-a-time modal router), `tray`, `secrets`, `os`, `net`, `focus`, and `log`. Unified in a `ClideKernel` `InheritedWidget`.
- **i18n** (`app/lib/kernel/src/i18n/`) — text-driven lookup ported from [fframe](https://github.com/postmeridiem/fframe)'s `L10n`: namespaced JSON catalogs, `string()` / `interpolated()` calls with caller-supplied placeholders, and a proper locale fallback chain (exact → language → default-country → default-language → placeholder). Improves on fframe's design by adding the chain, which fframe lacks.
- **A11y from Tier 0** — `Semantics(label:, hint:, button:)` on every interactive primitive; `SemanticsBinding.ensureSemantics()` at boot; a `theme/contrast.dart` helper exposes token pairs that the a11y suite walks for WCAG-AA compliance.
- **Extension contract** (`app/lib/extension/`) — abstract `ClideExtension`, sealed `ContributionPoint` hierarchy (`TabContribution`, `StatusItemContribution`, `ToolbarButtonContribution`, `CommandContribution`, `TrayItemContribution`, `LayoutPresetContribution`). Each extension ships one manifest contributing N atoms into kernel slots. Priority-based ordering within a slot, dependency-aware activation, YAML manifest loader + scanner for `~/.clide/extensions/`.
- **Three-tier theme pipeline** — palette (named colors) → semantic roles → ~60 VS-Code-style surface tokens, each layer with defaults so palette-only themes ship. Ported `summer-night` as the first bundled theme; muted value calibrated for WCAG-AA contrast.
- **Widget primitives** (`app/lib/widgets/`) — `ClideSurface`, `ClideText`, `ClideButton`, `ClideTabBar`, `ClideDivider`, `ClideScrollbar`, `ClideTooltip`, `ClideIcon` + eight `CustomPainter`-rendered icons (folder, gear, x, chevron-left/right, dot, check, plug). All token-consuming, all Semantics-wrapped.
- **Tier 0 built-in extensions** — `builtin.default-layout` (classic three-column preset + reset command), `builtin.welcome` (workspace placeholder), `builtin.ipc-status` (live-region statusbar indicator), `builtin.theme-picker` (command + modal, bound to `ctrl+k`). Plus 17 id-reserving stubs (`builtin.claude`, `builtin.terminal`, `builtin.files`, `builtin.editor`, `builtin.git`, ...) so later tiers can fill in without rename churn.
- **Lua runtime boundary** — `app/lib/lua/` ships as typed stubs (`host`, `adapter`, `capability_api`, `render_intent`) so third-party Lua extensions can plug in at Tier 6 without retrofitting.
- `.gitignore` extended to cover `app/` sub-package artefacts (`app/.dart_tool`, `app/build`, per-platform ephemeral dirs, `app/*.iml`) and the Playwright harness under `tools/ui/` (`node_modules`, `out`, test-results).
- Dart core package at the repo root: `bin/clide.dart` (one binary, `--daemon` and one-shot subcommand modes), `lib/clide.dart` barrel exporting the shared IPC types, `lib/src/ipc/` (`envelope.dart`, `server.dart`, `paths.dart`, `schema_v1.dart`), and `lib/src/daemon/dispatcher.dart`. `clide --daemon` listens on a unix socket; `clide ping` / `clide version` round-trip through it with the ADR 0006 exit-code contract (`0/1/2/3/4`). Includes `test/ipc/` and `test/daemon/` suites covering envelope parsing, the in-process server, and a subprocess smoke that verifies signal-driven shutdown + socket unlink.
- `scripts/bazzite-flutter-setup.sh` — one-shot installer for the Flutter SDK + desktop build deps on Bazzite / Fedora Silverblue. Drops the SDK under `~/opt/flutter`, wires PATH in the user's shell rc files, and layers the Linux desktop build deps via `rpm-ostree install`.
- [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md) — Dart core; sidecar directory dissolved; `ptyc` as pql-peer. Establishes one Dart AOT binary for both CLI and daemon, `lib/` as the shared core, and promotes the C PTY helper to a standalone supporter tool on the same footing as pql.
- [ADR 0006](docs/ADRs/0006-cli-and-event-surface.md) — CLI and event surface contract. Defines the subsystem list (`pane`, `tab`, `editor`, `panel`, `tree`, `git`, `pql`, `canvas`, `graph`, `theme`, `settings`, `project`), the command shape, the versioned JSON event schema, the pql-style exit-code contract, and the command↔event duality rule that operationalises user/Claude parity.
- Architectural decision records carried forward from the short-lived
`claudian` plugin project (discarded in favour of this Flutter
rebuild):
[ADR 0001](docs/ADRs/0001-cli-first-not-mcp.md) — CLI-first, not MCP.
[ADR 0002](docs/ADRs/0002-sidecar-language-go.md) — Sidecar language: Go.
[ADR 0003](docs/ADRs/0003-pql-as-supporter-tool.md) — pql as supporter tool; wrap, don't duplicate; pql is a Clide subsystem when present.
[ADR 0004](docs/ADRs/0004-ignore-file-strategy.md) — Ignore file strategy (`ignore_files:` in `.pql/config.yaml`, layered).
- Pre-push quality gate: `.githooks/pre-push` runs `make push-check` (lint + test + test-race + test-integration + vuln + app-analyze + app-test) so bad pushes are caught locally before they hit Gitea. The app-side targets gracefully noop until Flutter is scaffolded. Install with `make hooks` (sets `git config core.hooksPath .githooks`); the hook prepends `$GOBIN`/`$HOME/go/bin` to PATH so govulncheck resolves without the user touching their shell profile.
- Go sidecar/CLI skeleton under `sidecar/` (module `git.schweitz.net/jpmschweitzer/clide/sidecar`): `cmd/clide/main.go`, `internal/cli` with a stdlib-flag dispatch, `internal/diag` mirroring pql's exit-code + stderr-JSON contract, `internal/version` with ldflag-stamped build info, and placeholder packages for `daemon`, `pty`, `proc`, `git`, `ipc`, `pql` awaiting their tier. `clide --version` emits JSON build-info today.
- Root `Makefile` drives both the Go sidecar and the Flutter app under one toolchain. Version is read from `project.yaml` via awk and stamped into the sidecar via `-ldflags -X`. Flutter targets gracefully noop before the app is scaffolded so the Makefile is usable from day one. Pinned Go tooling (govulncheck, goimports, golangci-lint) installs via `make tools`.
- `ci/` entry scripts: `test.sh`, `lint.sh` (includes the supply-chain gate — no green lint without a green CVE scan), `security.sh`, `release.sh` (stub).
- Project identity files for the Flutter rebuild at the repo root: `project.yaml` (single source of truth for version + module path, version 2.0.0-dev), a fresh `README.md`, MIT `LICENSE`, and `.editorconfig`. The Python clide's manifest and README are preserved under `legacy/`.
- [`docs/initial-plan.md`](docs/initial-plan.md) — the north-star design document for the Flutter rebuild. Captures what we kept from Python Clide (pane model, git skills, Claude-always-visible), what we took from Obsidian (canvas and graph — no vault, no bases, no plugin inheritance), what Claudian's short experiment contributed (Go sidecar, CLI-first, pql-as-subsystem, ignore-file strategy), and the tier roadmap (Tier 0 app+sidecar handshake → Tier 5 canvas+graph).
- [`CLAUDE.md`](CLAUDE.md) orientation doc for future Claude Code instances: project identity, guardrails as one-liners, tier ordering, parent-project pointers, commands, dependencies & supply chain, open questions. Points at the design doc and ADRs rather than restating their content.
- Claude Code configuration under `.claude/`: project-level
allow/deny permissions and two skills — `skill-create` (generic
skill authoring guidance) and `git-commit` (this repo's commit
conventions: no Conventional Commits, Keep a Changelog discipline,
`project.yaml`-and-changelog-bumped-together rule, attribution
trailer, safety reminders).
+63 -323
View File
@@ -2,344 +2,84 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
## What clide is
Clide is a TUI IDE wrapper for Claude Code CLI, designed to be Claude-centric with VSCode-familiar keybindings. See `docs/tui-ide-spec.md` for full specification.
A Flutter desktop IDE for Claude Code. One language (Dart) across the stack, plus small native supporter tools where Dart can't reach.
### Design Principles
- **Claude-centric**: Claude Code is the primary workspace, always visible
- **Contextual panels**: Editor/Diff/Terminal appear only when needed
- **Alt-key shortcuts**: Alt-based keybindings don't interfere with input fields
- **Responsive**: Works on 13" laptop to widescreen monitors
- **State preservation**: Hiding panels preserves all state (never destroy widgets)
- **`app/`** — Flutter desktop application (Linux / macOS primary; Windows stretch).
- **`lib/` + `bin/clide.dart`** — Dart core, shared by the app and by one AOT-compiled binary that has two modes: `clide <subcommand>` (one-shot for Claude) and `clide --daemon` (long-running for the app). Owns IPC, PTYs (via `ptyc`), subprocesses, file watchers, git shell-outs, pql invocations.
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. Clide wraps it for every query surface; never re-implements it.
- **`ptyc`** — small C supporter tool, peer of pql. Spawns a PTY + child and hands the master fd back over `SCM_RIGHTS`. Clide shells out to it for every pane (shell, tmux, claude, LSP, debug adapter).
## Tech Stack
App ↔ daemon ↔ CLI speak a single JSON-lines unix socket protocol. The daemon outlives app restarts so Claude sessions survive reopens. Native rendering — markdown, canvas, graph — is Dart/Flutter (`CustomPaint` + widgets), not third-party packages.
| Component | Library | Version |
|-----------|---------|---------|
| Runtime | Python | 3.12+ |
| TUI Framework | Textual | latest |
| CLI | Typer | latest |
| Data Validation | Pydantic | v2 (strict mode) |
| Settings | pydantic-settings | latest |
| Database | SQLModel + SQLite | latest |
| Web Server | FastAPI + uvicorn | latest |
| Testing | pytest + pytest-asyncio + pytest-textual-snapshot | latest |
| Extensions | pluggy | latest |
Design doc: [`docs/initial-plan.md`](docs/initial-plan.md). Decisions: [`decisions/`](decisions/) (`D-NNN` confirmed, `Q-NNN` open, `R-NNN` rejected — see [`decisions/README.md`](decisions/README.md)). Python Textual predecessor under [`legacy/`](legacy/).
## Development Commands
## Guardrails
### Clide (TUI)
These are load-bearing. Violating any means the design is wrong, not the rule.
```bash
make setup # Create venv, install deps
make run # Run application
make test # Run all tests
make test-single # Run single test (TEST=path::test_name)
make typecheck # Run mypy
make lint # Run ruff check
make format # Run ruff format
make build # Build for current platform
```
- **Flutter desktop is the host. No Electron, ever.** Web target may work as a happy accident — don't compromise desktop fidelity for it. If we ship a web build at all, prefer Flutter's **WebAssembly (CanvasKit/Skwasm) compile** over the JS/HTML renderer: it matches the desktop rendering pipeline, keeps our custom `CustomPaint` components pixel-identical, and avoids the DOM-renderer quirks around input handling and terminal-style content. `xterm.dart` is the terminal renderer (Tier 1); markdown, canvas, graph are custom `CustomPaint`/widget components (Tiers 2+, 5).
- **No heavy lifting in the UI layer.** The app renders and handles input; process/PTY/IO lifecycles live in the daemon. The daemon is Dart too — the split is *process boundary*, not language boundary.
- **CLI-first, not MCP.** Claude talks via Bash (`clide ...`), matching pql's contract. See [`D-001`](decisions/architecture.md#d-001-cli-first-not-mcp).
- **Dart is the core; native supporter tools fill specific gaps.** One Dart AOT binary for CLI + daemon. `ptyc` (C) for PTY spawning. `pql` (Go) for queries. No second "core language." See [`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) (supersedes [`R-002`](decisions/rejected.md#r-002-go-sidecar)).
- **Own the rendering stack.** PTY (via `ptyc`), markdown renderer, graph, canvas — all clide-owned, not pulled from opinionated packages. Third-party rendering is where we'd hit ceilings first; we'd rather pay the cost up front.
- **User/Claude parity.** Every CLI subcommand has a UI affordance in the app, and every UI action has a CLI. Events are symmetric: every UI state change is a subscribable event. See [`D-006`](decisions/architecture.md#d-006-cli-and-event-surface-contract).
- **pql: wrap, don't duplicate, and treat it as a clide subsystem when present.** Pql logic only lives in `lib/src/pql/` (pure shell-outs). Clide owns pql's `ignore_files:` config key; it never touches pql's `.pql/` index/cache data. See [`D-003`](decisions/architecture.md#d-003-pql-as-supporter-tool-clide-wraps-never-duplicates).
- **Repo-is-the-workspace.** The git repo root is the workspace — no parallel "vault" concept. Clide dogfoods against its own repo.
- **Ignore discipline.** Single knob: `ignore_files:` in `.pql/config.yaml`, ordered layering. Default `[.gitignore]`; clide writes `[.gitignore, .clideignore]` when `.clideignore` exists. See [`D-004`](decisions/architecture.md#d-004-ignore-file-strategy).
- **Decision discipline.** All architectural choices live in `decisions/<domain>.md` as `D-NNN` records. Open questions live in `decisions/questions-<domain>.md` as `Q-NNN`. Rejected alternatives live in `decisions/rejected.md` as `R-NNN`. Before an architectural change, read the relevant domain file; before disagreeing with a guardrail, propose an amendment to the underlying `D-NNN` rather than a one-off. Claim new IDs via `tools/scripts/plan decisions claim D <domain> "title"` (until pql ships planning subcommands — see [`Q-021`](decisions/questions-architecture.md#q-021-pql-absorbs-planning-vs-keeps-separate)). See [`decisions/README.md`](decisions/README.md).
### clide-web (Web Server)
## Tier ordering (don't skip ahead)
```bash
cd clide-web/
make setup # Create venv, install deps, run setup wizard
make run # Run the web server (foreground)
make dev # Run with auto-reload
make start-server # Start systemd service
make stop-server # Stop systemd service
make restart-server # Restart systemd service
make status-server # Show service status
make logs-server # Tail service logs
```
1. **Tier 0** — Flutter app + Dart daemon handshake, empty IDE shell.
2. **Tier 1** — Claude in an `xterm.dart` pane backed by `ptyc`-spawned PTYs; session persists across app restarts.
3. **Tier 2** — Pane model + active-file awareness + `clide open/active/insert/replace-selection/tail`.
4. **Tier 3** — Git panel (staged/unstaged, hunk stage, conflict UI) + diff tab + `clide git …`.
5. **Tier 4** — pql integration: Query panel, file tree, backlinks, problems — all drawing from pql.
6. **Tier 5** — Canvas (`CustomPaint` + `InteractiveViewer`) and graph view.
7. **Tier 6** — Dart extension API, settings, theming, distributable builds.
## Panel Architecture
See `docs/initial-plan.md` for the full tier definitions and acceptance criteria.
## Parent projects
- **`legacy/`** — Python Textual clide v1.2.0. Feature-frozen. Reference for the pane model, panel set, git skills (`/commit`, `/stash`, `/pull`, `/push` — rewire to `clide git …`), TODO.md parsing format.
- **`projects/claudian`** (April 2026, discarded) — 2-day experiment with an Obsidian-plugin approach. Its architectural patterns (CLI-first, pql-as-subsystem, ignore-file strategy, supply-chain gate, changelog discipline, commit conventions) are the records you see under `decisions/` and the skills under `.claude/skills/`. It first proposed a Go sidecar; [`D-005`](decisions/architecture.md#d-005-dart-core-sidecar-dissolved-ptyc-as-pql-peer) reversed that in favour of a Dart core.
- **[`projects/pql`](https://github.com/postmeridiem/pql)** — active supporter tool. Clide depends on it; never duplicates it.
## Dependencies & supply chain
- **Dart (core + app):** prefer-zero-deps. Flutter-SDK widgets first; third-party packages need justification. What stays is exact-pinned in `pubspec.yaml` (no caret ranges). Advisories reviewed before every bump; `pubspec.lock` committed.
- **`ptyc` and any future native supporter tool:** no dep graph by design (libc-only for `ptyc`). "Audit" is reading the source before each bump.
- `pubspec.lock` is always committed.
- `make security` runs the Dart advisory review; `ci/security.sh` is the CI entry.
## Commands
(Placeholder until Tier 0 commits land these targets in real form.)
```
┌─────────────────┬─────────────────────────┬──────────────────┐
│ panel-sidebar │ panel-workspace (60%) │ panel-context │
│ │ [Editor][Diff][Terminal]│ │
│ [Files][Git] │ (hidden when inactive) │ [Problems][TODOs]│
│ [Tree] ├─────────────────────────┤ [Jira] │
│ │ │ │
│ (content area) │ panel-claude │ (content area) │
│ │ (40% when workspace │ │
│ │ visible, else 100%) │ │
├─────────────────┤ ├──────────────────┤
│ ⎇ main ▾ │ │ [⚠ 3][✓12][Jira]│
└─────────────────┴─────────────────────────┴──────────────────┘
make build # dart compile exe bin/clide.dart -o bin/clide
make test # flutter test
make test-integration# daemon + CLI + fixture-repo suite
make analyze # flutter analyze
make format # dart format --set-exit-if-changed
make build-linux # flutter build linux
make build-macos # flutter build macos
make ptyc-build # build the ptyc PTY-spawn helper
make security # Dart advisory review + ptyc source review
make push-check # pre-push gate: analyze + format + test
make hooks # install the repo's git hooks (one-time setup)
make clean # remove build artefacts
```
## Project Structure
One-time setup on a fresh clone: `make hooks && flutter pub get` once Flutter is installed.
```
clide/
├── clide/ # Package source
│ ├── __init__.py
│ ├── __main__.py
│ ├── app.py # Main App, layout, keybindings
│ ├── cli.py # Typer entry point
│ ├── controllers/ # Domain logic (no UI)
│ ├── widgets/ # UI components
│ │ ├── panels/ # Main layout containers
│ │ └── components/ # Reusable UI pieces
│ ├── models/ # Pydantic data models
│ ├── services/ # Background task logic
│ ├── themes/ # Theme system
│ ├── extensions/ # Plugin system
│ └── helpers/ # Utility functions
│
├── tests/
│ ├── conftest.py
│ ├── harnesses/
│ │ ├── app_harness.py
│ │ └── controller_harness.py
│ ├── unit/
│ ├── integration/
│ └── snapshots/
│
├── .config/ # User config (gitignored)
│ ├── settings.toml # User settings
│ └── themes/ # Custom user themes
│ └── my-theme.toml
├── docs/
│ ├── tui-ide-spec.md # Full UI/UX specification
│ ├── web-deployment.md # Web deployment architecture
│ └── ARCHITECTURE.md # Framework best practices
├── pyproject.toml
└── Makefile
│
clide-web/ # Web server package (wraps clide)
├── clide_web/
│ ├── server.py # FastAPI app, routes, WebSocket handler
│ ├── sessions.py # tmux session manager
│ ├── pty_bridge.py # PTY ↔ WebSocket bridge
│ ├── config.py # Pydantic settings with DB overlay
│ ├── setup_wizard.py # Interactive first-run config
│ └── static/
│ ├── index.html # HTML page (toolbar + xterm.js)
│ └── vendor/ # Vendored xterm.js (offline)
├── pyproject.toml
└── Makefile
```
## Changelog discipline
## Key Patterns
[Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Every user-visible commit adds an entry under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md). Cutting a release means moving Unreleased entries under a new dated version heading **and** bumping `project.yaml` `version:` in the same commit — see [`.claude/skills/git-commit/SKILL.md`](.claude/skills/git-commit/SKILL.md) for the full rule.
### Panel Visibility (Hide, Don't Destroy)
## Open questions
```python
def toggle_workspace(self, visible: bool) -> None:
workspace = self.query_one("#panel-workspace")
workspace.display = visible # Preserves all child state
claude = self.query_one("#panel-claude")
claude.styles.height = "40%" if visible else "100%"
```
### Background Tasks
Use `@work` decorator for non-blocking operations:
```python
@work(thread=True)
def refresh_git_status(self) -> None:
result = subprocess.run(["git", "status", "--porcelain"], ...)
self.call_from_thread(self.update_git_view, result.stdout)
```
### Reactive State
```python
class ClideApp(App):
current_file: reactive[str | None] = reactive(None)
workspace_visible: reactive[bool] = reactive(False)
problem_count: reactive[int] = reactive(0)
todo_count: reactive[int] = reactive(0)
compact_mode: reactive[bool] = reactive(False)
```
### Pydantic Models (Strict + Frozen)
```python
from pydantic import BaseModel, ConfigDict
class GitChange(BaseModel):
model_config = ConfigDict(strict=True, frozen=True)
path: str
status: Literal["added", "modified", "deleted", "untracked", "renamed"]
staged: bool
```
### Controller → Widget Communication
Controllers emit Textual messages; widgets subscribe:
```python
# In controller
class GitStatusUpdated(Message):
def __init__(self, status: GitStatus) -> None:
self.status = status
super().__init__()
self.post_message(GitStatusUpdated(status))
# In widget
def on_git_status_updated(self, event: GitStatusUpdated) -> None:
self.refresh_view(event.status)
```
## Theme System
### Default Theme: Summer Night
Based on [jackw01/summer-night-vscode-theme](https://github.com/jackw01/summer-night-vscode-theme):
```python
SUMMER_NIGHT = ThemeColors(
primary="#00a3d2", # cyan
secondary="#00a9b9", # teal
accent="#fa5f8b", # pink
background="#21262f", # mono_8
surface="#393e48", # mono_7
panel="#292e38",
foreground="#e2e8f5", # mono_1
success="#00ab9a", # green
warning="#d08447", # orange
error="#f06c6f", # red
)
```
### Built-in Themes (22 total)
| Category | Themes |
|----------|--------|
| Core | summer-night (default), summer-day |
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
| GitKraken | one-dark-teal, gamma |
| Seasonal - Winter | winter-is-coming, monokai-winter |
| Seasonal - Fall | fall, dark-autumn |
| Seasonal - Halloween | all-hallows-eve, halloween |
| Seasonal - Christmas | christmas, santa-baby |
| Hacker | pro-hacker, hacker-style |
| Bonus | houston |
### Theme Definition
```python
class ThemeColors(BaseModel):
model_config = ConfigDict(strict=True, frozen=True)
primary: str # Main accent
secondary: str # Secondary accent
accent: str # Highlight accent
background: str # Main background
surface: str # Elevated surfaces
panel: str # Panel backgrounds
foreground: str # Primary text
success: str # Success/green
warning: str # Warning/yellow
error: str # Error/red
class ThemeDefinition(BaseModel):
name: str # Identifier (e.g., "summer-night")
display_name: str # Human-readable name
dark: bool # Dark or light theme
colors: ThemeColors
```
### Custom Themes
Users can add themes in `.config/themes/`:
```toml
# .config/themes/my-theme.toml
name = "my-theme"
display_name = "My Custom Theme"
dark = true
[colors]
primary = "#007acc"
secondary = "#3c3c3c"
accent = "#0e639c"
background = "#1e1e1e"
surface = "#252526"
panel = "#2d2d30"
foreground = "#d4d4d4"
success = "#4ec9b0"
warning = "#dcdcaa"
error = "#f44747"
```
### Theme Switching
- Keybinding: `Ctrl+K Ctrl+T`
- Settings: `theme = "summer-night"` in ClideSettings
- Runtime: `app.theme = "dracula"`
## Keybindings (Alt-based)
| Action | Binding |
|--------|---------|
| Quit | `Alt+Q` |
| Command palette | `Alt+P` |
| Quick open | `Alt+O` |
| Toggle left sidebar | `Alt+B` |
| Toggle right sidebar | `Alt+Shift+B` |
| Toggle terminal | `` Alt+` `` |
| Focus Claude | `Alt+1` |
| Focus Editor | `Alt+2` |
| Focus Terminal | `Alt+3` |
| Toggle compact mode | `Alt+C` |
| Git panel | `Alt+G` |
| Problems panel | `Alt+M` |
| Select theme | `Alt+T` |
| Save file | `Alt+S` |
## Configuration
Settings are loaded from multiple sources (in priority order):
1. Environment variables (`CLIDE_*`)
2. `.config/settings.toml`
3. Defaults in ClideSettings
```python
class ClideSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="CLIDE_",
env_file=".env",
extra="ignore",
)
theme: str = "summer-night"
jira_enabled: bool = False
jira_cli_path: str = "jira"
panels: PanelConfig = PanelConfig()
keybindings: KeybindingsConfig = KeybindingsConfig()
```
## Documentation Links
### Core Stack
- [Textual Documentation](https://textual.textualize.io/)
- [Textual Testing Guide](https://textual.textualize.io/guide/testing/)
- [Textual Themes Guide](https://textual.textualize.io/guide/design/)
- [Typer Documentation](https://typer.tiangolo.com/)
- [Pydantic v2 Documentation](https://docs.pydantic.dev/latest/)
- [Pydantic Settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
### Testing
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/en/latest/)
- [pytest-textual-snapshot](https://github.com/Textualize/pytest-textual-snapshot)
### Extensions
- [Pluggy Documentation](https://pluggy.readthedocs.io/)
### Build
- [PyInstaller Documentation](https://pyinstaller.org/)
- [Gitea Actions](https://docs.gitea.com/usage/actions/overview)
### Theme References
- [Summer Night VSCode Theme](https://github.com/jackw01/summer-night-vscode-theme)
Open questions live under [`decisions/questions-*.md`](decisions/questions.md).
+1
View File
@@ -0,0 +1 @@
See [`decisions/README.md`](decisions/README.md).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jeroen Schweitzer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+159 -98
View File
@@ -1,121 +1,182 @@
.PHONY: setup run test test-single typecheck lint format build build-macos build-linux build-windows build-all clean help
# clide — local dev targets. CI scripts in ci/ shell out to these.
#
# One toolchain: Flutter + Dart. Native supporter tools (`ptyc`, future
# peers) live in their own directories with their own build targets and
# compose in under `make build`.
PYTHON := python3.12
VENV := .venv
BIN := $(VENV)/bin
TEST ?= tests/
VERSION ?= 1.0.0
INSTALL_DIR ?= $(HOME)/.local/bin
# Colors for output
BLUE := \033[0;34m
GREEN := \033[0;32m
RESET := \033[0m
# Version stamping. Source of truth: project.yaml `version:` field. Local
# builds augment with git short SHA + dirty marker (semver build metadata).
VERSION_BASE ?= $(shell awk -F': *' '/^version:/ {gsub(/[" ]/,"",$$2); print $$2; exit}' project.yaml)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DIRTY := $(shell git diff --quiet HEAD 2>/dev/null || echo .dirty)
VERSION ?= $(VERSION_BASE)+$(COMMIT)$(DIRTY)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
help:
@printf "$(BLUE)Clide Development Commands$(RESET)\n"
@printf "\n"
@printf "$(GREEN)setup$(RESET) Create venv and install dependencies\n"
@printf "$(GREEN)run$(RESET) Run the application\n"
@printf "$(GREEN)test$(RESET) Run all tests\n"
@printf "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)\n"
@printf "$(GREEN)typecheck$(RESET) Run mypy type checking\n"
@printf "$(GREEN)lint$(RESET) Run ruff linter\n"
@printf "$(GREEN)format$(RESET) Run ruff formatter\n"
@printf "$(GREEN)build$(RESET) Build executable for current platform\n"
@printf "$(GREEN)clean$(RESET) Remove build artifacts and caches\n"
@printf "\n"
@printf "$(BLUE)Distribution Builds$(RESET)\n"
@printf "\n"
@printf "$(GREEN)build-macos$(RESET) Build macOS DMG (VERSION=x.x.x)\n"
@printf "$(GREEN)build-linux$(RESET) Build Linux AppImage (VERSION=x.x.x)\n"
@printf "$(GREEN)build-windows$(RESET) Build Windows installer (VERSION=x.x.x)\n"
.PHONY: help
help: ## Show this help.
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
setup:
@echo "Creating virtual environment..."
$(PYTHON) -m venv $(VENV)
@echo "Installing dependencies..."
$(BIN)/pip install --upgrade pip
$(BIN)/pip install -e ".[dev,build]"
@echo "Installing pre-commit hooks..."
$(BIN)/pre-commit install || true
@printf "$(GREEN)Setup complete! Activate with: source $(VENV)/bin/activate$(RESET)\n"
# -- app + core (Flutter / Dart) ----------------------------------------
run:
$(BIN)/python -m clide
# App and core targets gracefully noop if the Dart package isn't
# scaffolded yet (pre-Tier-0 state) or if flutter isn't installed
# locally.
APP_PRESENT := $(shell test -f pubspec.yaml && echo yes || echo no)
HAS_FLUTTER := $(shell command -v flutter >/dev/null && echo yes || echo no)
test:
$(BIN)/pytest $(TEST)
.PHONY: build
build: ## Build the Dart AOT `clide` binary (CLI + --daemon modes in one binary).
ifeq ($(APP_PRESENT),yes)
dart compile exe bin/clide.dart -o bin/clide \
--define=clideVersion=$(VERSION) \
--define=clideCommit=$(COMMIT) \
--define=clideDate=$(DATE)
else
@echo "(pubspec.yaml not scaffolded yet; skipping)"
endif
test-single:
$(BIN)/pytest $(TEST) -v
.PHONY: install
install: build ## Install bin/clide into $(INSTALL_DIR).
ifeq ($(APP_PRESENT),yes)
install -m 0755 bin/clide $(INSTALL_DIR)/clide
else
@echo "(pubspec.yaml not scaffolded yet; skipping)"
endif
test-cov:
$(BIN)/pytest --cov=clide/clide --cov-report=html --cov-report=term
.PHONY: pubget
pubget: ## flutter pub get (hydrate the pub cache).
ifeq ($(APP_PRESENT),yes)
flutter pub get
else
@echo "(pubspec.yaml not scaffolded yet; skipping)"
endif
test-snapshots:
$(BIN)/pytest tests/snapshots/
.PHONY: analyze
analyze: ## dart analyze / flutter analyze.
ifeq ($(APP_PRESENT),yes)
flutter analyze
else
@echo "(pubspec.yaml not scaffolded yet; skipping)"
endif
test-snapshots-update:
$(BIN)/pytest tests/snapshots/ --snapshot-update
.PHONY: format
format: ## dart format --set-exit-if-changed.
ifeq ($(APP_PRESENT),yes)
dart format --set-exit-if-changed .
else
@echo "(pubspec.yaml not scaffolded yet; skipping)"
endif
typecheck:
$(BIN)/mypy clide/
.PHONY: test
test: ## Fast: analyze + format + unit + widget + golden (<60s).
ci/test.sh
lint:
$(BIN)/ruff check clide/ tests/
.PHONY: test-a11y
test-a11y: ## A11y contract (semantic coverage + keyboard + contrast + i18n).
ci/test_a11y.sh
format:
$(BIN)/ruff format clide/ tests/
$(BIN)/ruff check --fix clide/ tests/
.PHONY: test-integration
test-integration: ## Integration tests (real app boot; xvfb on headless Linux).
ci/test_integration.sh
build:
$(BIN)/pyinstaller clide.spec --clean
.PHONY: test-e2e
test-e2e: build ## Daemon subprocess + web WASM Playwright smoke.
ci/test_e2e.sh
build-onefile:
$(BIN)/pyinstaller \
--name clide \
--onefile \
--clean \
--noconfirm \
clide/clide/__main__.py
.PHONY: test-all
test-all: test test-a11y test-integration test-e2e ## Everything, sequentially.
clean:
rm -rf $(VENV)
rm -rf dist/ build/
rm -rf .pytest_cache/ .mypy_cache/ .ruff_cache/
rm -rf htmlcov/ .coverage
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find . -type f -name "*.pyc" -delete 2>/dev/null || true
.PHONY: coverage
coverage: ## flutter test --coverage + lcov summary.
ci/test_coverage.sh
# CI targets (for GitHub Actions)
ci-lint:
pip install ruff mypy
ruff check clide/ tests/
mypy clide/
.PHONY: smoke-bundle
smoke-bundle: ## Build Linux release bundle and run it under xvfb for 5s.
ci/smoke_bundle.sh
ci-test:
pip install -e ".[dev]"
pytest --cov=clide/clide --cov-report=xml
# -- web UI harness (for Claude Code to drive the app) -----------------
ci-build:
pip install -e ".[build]"
pyinstaller clide.spec --clean
.PHONY: ui-dev
ui-dev: ## Build web WASM + start localhost:4280 in the background.
tools/ui/build.sh
tools/ui/serve.sh
# Distribution builds
build-macos:
@printf "$(BLUE)Building macOS distribution...$(RESET)\n"
./scripts/build-macos.sh $(VERSION)
.PHONY: ui-stop
ui-stop: ## Stop the background web server.
tools/ui/stop.sh
build-linux:
@printf "$(BLUE)Building Linux AppImage...$(RESET)\n"
./scripts/build-linux.sh $(VERSION)
.PHONY: ui-smoke
ui-smoke: ## Build + serve + run Playwright smoke + stop.
tools/ui/build.sh
tools/ui/serve.sh
@sh -c 'trap "tools/ui/stop.sh >/dev/null 2>&1" EXIT; cd tools/ui && npx playwright test smoke.spec.ts'
build-windows:
@echo "$(BLUE)Building Windows installer...$(RESET)"
powershell -ExecutionPolicy Bypass -File scripts/build-windows.ps1 -Version $(VERSION)
.PHONY: build-linux
build-linux: ## flutter build linux (desktop bundle).
ifeq ($(APP_PRESENT),yes)
flutter build linux
else
@echo "(pubspec.yaml not scaffolded yet; skipping)"
endif
build-all: build-linux
@printf "\n"
@printf "$(GREEN)Linux build complete.$(RESET)\n"
@printf "$(BLUE)Note:$(RESET) macOS build requires: make build-macos VERSION=$(VERSION)\n"
@printf "$(BLUE)Note:$(RESET) Windows build requires Windows: make build-windows VERSION=$(VERSION)\n"
.PHONY: build-macos
build-macos: ## flutter build macos (desktop bundle).
ifeq ($(APP_PRESENT),yes)
flutter build macos
else
@echo "(pubspec.yaml not scaffolded yet; skipping)"
endif
# -- ptyc (C supporter tool) --------------------------------------------
# Compiled alongside the main binary. Tiny, no deps beyond libc.
PTYX_PRESENT := $(shell test -f ptyc/Makefile && echo yes || echo no)
.PHONY: ptyc-build
ptyc-build: ## Build the ptyc PTY-spawn helper.
ifeq ($(PTYX_PRESENT),yes)
$(MAKE) -C ptyc
else
@echo "(ptyc/ not scaffolded yet; skipping)"
endif
.PHONY: ptyc-clean
ptyc-clean: ## Clean ptyc build artefacts.
ifeq ($(PTYX_PRESENT),yes)
$(MAKE) -C ptyc clean
else
@echo "(ptyc/ not scaffolded yet; skipping)"
endif
# -- security ------------------------------------------------------------
.PHONY: security
security: ## Dart advisory review + ptyc source review (manual — no floating deps).
@echo "security: Dart advisories reviewed manually before pubspec.yaml bumps;"
@echo " ptyc is reviewed by reading it (tiny libc-only C)."
@echo " Automated Dart CVE tooling lands here when a reliable option exists."
# -- pre-push gate -------------------------------------------------------
.PHONY: decisions-validate
decisions-validate: ## Parser dry-run over decisions/*.md (cheap pre-push gate).
tools/scripts/plan decisions validate
.PHONY: push-check
push-check: decisions-validate test test-a11y ## Pre-push gate: decisions + fast unit + widget + golden + a11y (<90s).
.PHONY: hooks
hooks: ## Install the repo's git hooks (points core.hooksPath at .githooks/).
git config core.hooksPath .githooks
@echo "hooks installed (core.hooksPath = .githooks)"
# -- housekeeping --------------------------------------------------------
.PHONY: clean
clean: ## Remove build artefacts.
rm -rf bin build .dart_tool
$(MAKE) ptyc-clean
.DEFAULT_GOAL := help
+13 -156
View File
@@ -1,168 +1,25 @@
# Clide
# clide
A terminal-based IDE that wraps Claude Code CLI, putting AI-assisted development at the center of your workflow.
A Flutter desktop IDE for Claude Code. Markdown-first content, pql-powered queries, canvas and graph surfaces, a Go sidecar handling PTYs / subprocesses / git / pql. Claude drives the UI through a `clide` CLI.
## Why Clide?
Currently pre-v2.0, scaffolding. The north-star design lives in [`docs/initial-plan.md`](docs/initial-plan.md); architectural decisions are captured in [`docs/ADRs/`](docs/ADRs/). The Python Textual v1.2.0 implementation is archived under [`legacy/`](legacy/) for reference.
Claude Code is powerful, but switching between terminal, editor, and project tools breaks your flow. Clide brings everything into one interface:
## Three surfaces, one tool
- **Claude stays visible** — Claude Code runs in the center panel, always accessible
- **Context at a glance** — File tree, git status, problems, and TODOs in dedicated panels
- **Panels appear when needed** — Editor, diff viewer, and terminal stay hidden until you need them
- **Git integration** — Commit, stash, pull, and push via Claude with built-in skills
- **22 themes** — From summer-night to dracula, with custom theme support
- **`app/`** — Flutter desktop application (Linux / macOS; Windows is a stretch).
- **`sidecar/`** — Go sidecar/CLI, single binary with two modes: `clide <subcommand>` (one-shot for Claude) and `clide --daemon` (long-running sidecar for the app). Owns PTYs, subprocesses, file watchers, git, pql invocations.
- **[`pql`](https://github.com/postmeridiem/pql)** — external supporter tool. clide wraps it for every query surface; never re-implements it.
## Screenshot
## Why the rebuild?
```
┌─────────────────┬─────────────────────────┬──────────────────┐
│ Sidebar │ Workspace │ Context │
│ │ [Editor][Diff][Terminal]│ │
│ [Files][Git] │ (appears when needed) │ [Jira][TODOs] │
│ [Tree] ├─────────────────────────┤ [Problems] │
│ │ │ │
│ │ Claude │ │
│ │ (always visible) │ │
│ │ │ │
├─────────────────┤ ├──────────────────┤
│ ⎇ main ▾ │ │ │
│ staged: 2 │ │ │
└─────────────────┴─────────────────────────┴──────────────────┘
```
The Python Textual implementation (`legacy/`) proved the pane model but capped at terminal-only rendering. A short-lived experiment (`claudian`, April 2026) explored an Obsidian-plugin approach and was abandoned because Obsidian is Electron, and the user's history with terminal-in-Electron ruled that host out.
## Features
Flutter desktop resolves both constraints: native Skia rendering avoids Electron's failure modes, `xterm.dart` gives us a solid terminal surface, and the pane model can be rebuilt natively. The Obsidian ideas worth keeping (canvas, graph) fold in; everything else (vault concept, inline query tables, plugin ecosystem inheritance) doesn't.
### Left Sidebar
- **Files** — Project file tree with syntax-aware icons
- **Git** — Staged/unstaged changes with action buttons
- **Tree** — Visual branch graph
- **Branch status** — Current branch with quick switcher
## Status
### Center
- **Claude Code** — Full PTY terminal integration, always visible
- **Editor** — Syntax highlighting via tree-sitter
- **Diff** — Side-by-side diff viewer
- **Terminal** — Command execution
### Right Context
- **Jira** — Issue display via CLI integration
- **TODOs** — Code comments and TODO.md items
- **Problems** — Linter errors and warnings
### Git Operations
Click buttons in the Git panel to delegate operations to Claude:
| Button | Skill | What Claude Does |
|--------|-------|------------------|
| Commit | `/commit` | Reviews changes, writes commit message |
| Stash | `/stash` | Stashes working changes |
| Pull | `/pull` | Pulls with rebase, helps resolve conflicts |
| Push | `/push` | Pushes to remote, sets upstream if needed |
Skills are installed automatically to your project's `.claude/skills/` directory.
## Installation
### Requirements
- Python 3.12+
- Git
- Claude Code CLI (installed and authenticated)
### Setup
```bash
git clone <repo-url>
cd clide
make setup
make run
```
Or install directly:
```bash
pip install -e .
clide
```
## Keybindings
All shortcuts use `Alt` to avoid conflicts with Claude Code input.
| Action | Binding |
|--------|---------|
| Toggle left sidebar | `Alt+B` |
| Toggle right sidebar | `Alt+Shift+B` |
| Toggle terminal | `` Alt+` `` |
| Focus Claude | `Alt+1` |
| Focus Editor | `Alt+2` |
| Focus Terminal | `Alt+3` |
| Toggle compact mode | `Alt+C` |
| Select theme | `Alt+T` |
| Quit | `Alt+Q` |
## Themes
22 built-in themes. Press `Alt+T` to switch.
| Category | Themes |
|----------|--------|
| Core | summer-night (default), summer-day |
| Popular | one-dark, one-dark-pro, one-light, dracula, nord, gruvbox-dark, gruvbox-light |
| Seasonal | winter-is-coming, monokai-winter, fall, dark-autumn |
| Special | all-hallows-eve, halloween, christmas, santa-baby |
| Hacker | pro-hacker, hacker-style |
Create custom themes in `~/.clide/themes/` as TOML files.
## Configuration
Settings stored in `~/.clide/settings.json`:
```json
{
"theme": "summer-night",
"compact_mode": false,
"jira_enabled": false
}
```
Override with environment variables:
```bash
CLIDE_THEME=dracula clide
```
## Tech Stack
| Component | Library |
|-----------|---------|
| Runtime | Python 3.12+ |
| TUI Framework | Textual |
| CLI | Typer |
| Data Validation | Pydantic v2 |
| Extensions | Pluggy |
| Syntax Highlighting | tree-sitter |
## Development
```bash
make setup # Create venv, install deps
make run # Run application
make test # Run all tests
make typecheck # Run mypy
make lint # Run ruff
make format # Format code
```
## Documentation
- [User Manual](docs/user-manual.md) — How to use Clide
- [UI/UX Specification](docs/tui-ide-spec.md) — Design decisions
- [Architecture](docs/ARCHITECTURE.md) — Technical overview
- [Code Organization](docs/code-organization.md) — Project structure
No build yet beyond the repo scaffold. Tier 0 acceptance: sidecar daemon runs, Flutter app connects, empty IDE shell appears with placeholders.
## License
MIT
MIT. See [`LICENSE`](LICENSE).
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "cc0734ac716fbb8b90f3f9db8020958b1553afa7"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
- platform: web
create_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
base_revision: cc0734ac716fbb8b90f3f9db8020958b1553afa7
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+3
View File
@@ -0,0 +1,3 @@
# clide_app
A new Flutter project.
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
+71
View File
@@ -0,0 +1,71 @@
import 'dart:io';
import 'package:clide_app/app.dart';
import 'package:clide_app/builtin/default_layout/default_layout.dart';
import 'package:clide_app/builtin/ipc_status/ipc_status.dart';
import 'package:clide_app/builtin/theme_picker/theme_picker.dart';
import 'package:clide_app/builtin/welcome/welcome.dart';
import 'package:clide_app/kernel/kernel.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../test/helpers/fake_ipc.dart';
/// The load-bearing startup gate.
///
/// Tests the *real* `ClideApp` boot path with real bindings (not a
/// mocked-up widget tree). Catches the class of regressions a widget
/// test can't see: initialization-order bugs, asset-not-bundled,
/// plugin-init failures, notifier-leak-on-boot.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'clide app boots with classic 3-column layout + welcome + statusbar',
(tester) async {
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_intg_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.ipc-status',
'builtin.theme-picker',
'builtin.default-layout',
],
daemonClientFactory: (log, events) =>
FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
..register(DefaultLayoutExtension())
..register(WelcomeExtension())
..register(IpcStatusExtension())
..register(ThemePickerExtension());
await services.extensions.activateAll();
await tester.pumpWidget(ClideApp(services: services));
await tester.pumpAndSettle();
// Shell — three columns and a statusbar all materialize.
expect(find.byType(RootLayout), findsOneWidget);
expect(find.byType(SlotHost), findsWidgets);
expect(find.byType(StatusbarHost), findsOneWidget);
// Welcome tab is mounted in the workspace.
expect(find.text('clide'), findsWidgets);
expect(find.text('Open project'), findsOneWidget);
// IPC status indicator reports disconnected (fake client never connects).
expect(find.text('disconnected'), findsOneWidget);
await services.dispose();
});
}
@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:clide_app/app.dart';
import 'package:clide_app/builtin/default_layout/default_layout.dart';
import 'package:clide_app/builtin/ipc_status/ipc_status.dart';
import 'package:clide_app/builtin/welcome/welcome.dart';
import 'package:clide_app/kernel/kernel.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../test/helpers/fake_ipc.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('disable + re-enable an extension mounts/unmounts its UI',
(tester) async {
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_lc_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.ipc-status',
'builtin.default-layout',
],
daemonClientFactory: (log, events) =>
FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
..register(DefaultLayoutExtension())
..register(WelcomeExtension())
..register(IpcStatusExtension());
await services.extensions.activateAll();
await tester.pumpWidget(ClideApp(services: services));
await tester.pumpAndSettle();
expect(find.text('disconnected'), findsOneWidget);
// Disable ipc-status; status item should disappear.
await services.extensions.setEnabled('builtin.ipc-status', false);
await tester.pumpAndSettle();
expect(find.text('disconnected'), findsNothing);
// Re-enable; status item reappears.
await services.extensions.setEnabled('builtin.ipc-status', true);
await tester.pumpAndSettle();
expect(find.text('disconnected'), findsOneWidget);
await services.dispose();
});
}
@@ -0,0 +1,61 @@
import 'dart:io';
import 'package:clide_app/app.dart';
import 'package:clide_app/builtin/default_layout/default_layout.dart';
import 'package:clide_app/builtin/theme_picker/theme_picker.dart';
import 'package:clide_app/builtin/welcome/welcome.dart';
import 'package:clide_app/kernel/kernel.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../test/helpers/fake_ipc.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('theme.pick command opens modal; selecting dismisses it',
(tester) async {
final themes = [
await const ThemeLoader().fromAsset(
rootBundle,
'lib/kernel/src/theme/themes/summer-night.yaml',
),
];
final services = await KernelServices.boot(
appDir: await Directory.systemTemp.createTemp('clide_theme_intg_'),
bundledThemes: themes,
i18nLoader: AssetCatalogLoader(bundle: rootBundle),
preloadNamespaces: const [
'builtin.welcome',
'builtin.theme-picker',
'builtin.default-layout',
],
daemonClientFactory: (log, events) =>
FakeDaemonClient(log: log, events: events),
autoStartDaemonClient: false,
);
services.extensions
..register(DefaultLayoutExtension())
..register(WelcomeExtension())
..register(ThemePickerExtension());
await services.extensions.activateAll();
await tester.pumpWidget(ClideApp(services: services));
await tester.pumpAndSettle();
// Invoke the command.
await services.commands.execute('theme.pick');
await tester.pumpAndSettle();
expect(find.text('Select theme'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
// Dismiss via Cancel.
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Select theme'), findsNothing);
await services.dispose();
});
}
+249
View File
@@ -0,0 +1,249 @@
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class ClideApp extends StatelessWidget {
const ClideApp({super.key, required this.services});
final KernelServices services;
@override
Widget build(BuildContext context) {
return ClideKernel(
services: services,
child: ClideTheme(
controller: services.theme,
child: _AppRoot(services: services),
),
);
}
}
class _AppRoot extends StatelessWidget {
const _AppRoot({required this.services});
final KernelServices services;
@override
Widget build(BuildContext context) {
return WidgetsApp(
title: 'clide',
color: const Color(0xFF000000),
pageRouteBuilder: <T>(RouteSettings settings, WidgetBuilder builder) =>
PageRouteBuilder<T>(
settings: settings,
pageBuilder: (ctx, _, __) => builder(ctx),
),
home: _RootShell(services: services),
);
}
}
class _RootShell extends StatefulWidget {
const _RootShell({required this.services});
final KernelServices services;
@override
State<_RootShell> createState() => _RootShellState();
}
class _RootShellState extends State<_RootShell> {
late final FocusNode _keyFocus;
@override
void initState() {
super.initState();
_keyFocus = FocusNode()..requestFocus();
}
@override
void dispose() {
_keyFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return DefaultTextStyle(
style: TextStyle(
color: tokens.globalForeground,
fontSize: 13,
fontFamilyFallback: const [
'Inter',
'Helvetica',
'Arial',
'sans-serif',
],
),
child: KeyboardListener(
focusNode: _keyFocus,
autofocus: true,
onKeyEvent: _onKey,
child: ColoredBox(
color: tokens.globalBackground,
child: DialogHost(
router: widget.services.dialog,
child: const RootLayout(),
),
),
),
);
}
void _onKey(KeyEvent event) {
final binding = KeybindingResolver.fromKeyEvent(
event,
HardwareKeyboard.instance,
);
if (binding == null) return;
final commandId = widget.services.keybindings.commandFor(binding);
if (commandId == null) return;
widget.services.commands.execute(commandId);
}
}
class RootLayout extends StatelessWidget {
const RootLayout({super.key});
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
return ListenableBuilder(
listenable: Listenable.merge([kernel.panels, kernel.arrangement]),
builder: (ctx, _) {
final a = kernel.arrangement;
final sidebarVisible = a.isVisible(Slots.sidebar);
final contextVisible = a.isVisible(Slots.contextPanel);
final statusVisible = a.isVisible(Slots.statusbar);
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 240;
final contextSize = a.sizeOf(Slots.contextPanel) ?? 280;
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
return Column(
children: [
Expanded(
child: Row(
children: [
if (sidebarVisible) ...[
SizedBox(
width: sidebarSize,
child: SlotHost(slot: Slots.sidebar),
),
DragResizeHandle(
arrangement: a,
slot: Slots.sidebar,
axis: Axis.horizontal,
),
],
const Expanded(child: SlotHost(slot: Slots.workspace)),
if (contextVisible) ...[
DragResizeHandle(
arrangement: a,
slot: Slots.contextPanel,
axis: Axis.horizontal,
),
SizedBox(
width: contextSize,
child: SlotHost(slot: Slots.contextPanel),
),
],
],
),
),
if (statusVisible)
SizedBox(
height: statusHeight,
child: const StatusbarHost(),
),
],
);
},
);
}
}
class SlotHost extends StatelessWidget {
const SlotHost({super.key, required this.slot});
final SlotId slot;
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
builder: (ctx, _) {
final tabs = kernel.panels.tabsFor(slot);
if (tabs.isEmpty) {
return Container(color: tokens.panelBackground);
}
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
final active = tabs.firstWhere(
(t) => t.id == activeId,
orElse: () => tabs.first,
);
return Container(
color: tokens.panelBackground,
child: Column(
children: [
ClideTabBar(
items: [
for (final t in tabs)
ClideTabItem(id: t.id, title: _resolveTitle(ctx, t)),
],
activeId: active.id,
onSelect: (id) => kernel.panels.activateTab(slot, id),
),
ClideDivider(),
Expanded(child: active.build(ctx)),
],
),
);
},
);
}
String _resolveTitle(BuildContext context, TabContribution t) {
final key = t.titleKey;
final ns = t.i18nNamespace;
if (key == null || ns == null) return t.title;
return ClideKernel.of(context).i18n.string(
key,
namespace: ns,
placeholder: t.title,
);
}
}
class StatusbarHost extends StatelessWidget {
const StatusbarHost({super.key});
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.panels,
builder: (ctx, _) {
final items = kernel.panels
.contributionsFor(Slots.statusbar)
.whereType<StatusItemContribution>()
.toList();
final left = items.where((i) => i.priority < 100).toList();
final right = items.where((i) => i.priority >= 100).toList();
return Container(
color: tokens.statusBarBackground,
child: Row(
children: [
for (final item in left) item.build(ctx),
const Spacer(),
for (final item in right) item.build(ctx),
],
),
);
},
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class CanvasExtension extends ClideExtension {
@override
String get id => 'builtin.canvas';
@override
String get title => 'Canvas';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class ClaudeExtension extends ClideExtension {
@override
String get id => 'builtin.claude';
@override
String get title => 'Claude Code';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,23 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-reserved stub. Will surface a sidebar tab with sub-tabs
/// Settings / Skills / Agents / Hooks / MCP — `.claude/` as a
/// first-class IDE surface. Orthogonal to pql; purely clide-internal.
/// Commands: `claude.settings.open`, `claude.skills.new`,
/// `claude.skills.edit`, `claude.agents.new`, `claude.agents.edit`,
/// `claude.hooks.log`, `claude.mcp.status`.
///
/// Distinct from `builtin.claude`, which is reserved for Tier 1's
/// "run Claude Code in a PTY pane."
class ClaudeControlExtension extends ClideExtension {
@override
String get id => 'builtin.claude-control';
@override
String get title => 'Claude control';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,19 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-reserved stub. Will surface a sidebar tab (filter by domain /
/// status, backlinks from current file) + commands (`decisions.open`,
/// `decisions.claim`, `decisions.amend`). Data source: `pql decisions
/// …` once `Q-021` resolves; `tools/scripts/plan decisions …` until
/// then.
class DecisionsExtension extends ClideExtension {
@override
String get id => 'builtin.decisions';
@override
String get title => 'Decisions';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,53 @@
import 'package:clide/clide.dart';
import 'package:clide_app/extension/extension.dart';
import 'package:clide_app/kernel/kernel.dart';
class DefaultLayoutExtension extends ClideExtension {
@override
String get id => 'builtin.default-layout';
@override
String get title => 'Default layout';
@override
String get version => '0.1.0';
LayoutPresetContribution? _preset;
ClideExtensionContext? _ctx;
@override
List<ContributionPoint> get contributions => [
_preset ?? classicPreset(),
CommandContribution(
id: 'layout.reset',
command: 'layout.reset',
title: 'Layout: Reset to Classic',
run: _reset,
),
];
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
_preset = classicPreset();
// Register the preset's slots into the panel registry and apply
// the arrangement so every SlotHost has something to render.
ctx.arrangement.registerSlotsInto(ctx.panels, _preset!);
ctx.arrangement.applyPreset(_preset!);
}
Future<IpcResponse> _reset(List<String> args) async {
final preset = _preset;
final ctx = _ctx;
if (preset == null || ctx == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'default-layout not activated',
),
);
}
ctx.arrangement.applyPreset(preset);
return IpcResponse.ok(id: '', data: {'preset': preset.id});
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class DiffExtension extends ClideExtension {
@override
String get id => 'builtin.diff';
@override
String get title => 'Diff';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class EditorExtension extends ClideExtension {
@override
String get id => 'builtin.editor';
@override
String get title => 'Editor';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class ExtensionsUiExtension extends ClideExtension {
@override
String get id => 'builtin.extensions-ui';
@override
String get title => 'Extensions UI';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class FilesExtension extends ClideExtension {
@override
String get id => 'builtin.files';
@override
String get title => 'Files';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class GitExtension extends ClideExtension {
@override
String get id => 'builtin.git';
@override
String get title => 'Git';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const ['builtin.diff'];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class GrammarsCoreExtension extends ClideExtension {
@override
String get id => 'builtin.grammars.core';
@override
String get title => 'Core grammars';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const ['builtin.editor'];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class GraphExtension extends ClideExtension {
@override
String get id => 'builtin.graph';
@override
String get title => 'Graph';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const ['builtin.pql'];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1,2 @@
export 'src/extension.dart';
export 'src/status_item.dart';
@@ -0,0 +1,33 @@
import 'package:clide_app/builtin/ipc_status/src/status_item.dart';
import 'package:clide_app/extension/extension.dart';
import 'package:clide_app/kernel/kernel.dart';
class IpcStatusExtension extends ClideExtension {
@override
String get id => 'builtin.ipc-status';
@override
String get title => 'Daemon connection';
@override
String get version => '0.1.0';
DaemonClient? _ipc;
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ipc = ctx.ipc;
}
@override
List<ContributionPoint> get contributions {
final ipc = _ipc;
if (ipc == null) return const [];
return [
StatusItemContribution(
id: 'ipc-status.indicator',
priority: 100, // right-side
listenable: ipc,
build: (_) => IpcStatusItem(ipc: ipc),
),
];
}
}
@@ -0,0 +1,53 @@
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class IpcStatusItem extends StatelessWidget {
const IpcStatusItem({super.key, required this.ipc});
final DaemonClient ipc;
static const _ns = 'builtin.ipc-status';
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: Listenable.merge([ipc, kernel.i18n]),
builder: (ctx, _) {
final connected = ipc.isConnected;
final color = connected ? tokens.statusSuccess : tokens.statusError;
final i = kernel.i18n;
final label = connected
? i.string('connected', namespace: _ns, placeholder: 'connected')
: i.string('disconnected',
namespace: _ns, placeholder: 'disconnected');
final hint = connected
? i.string('connected.hint',
namespace: _ns,
placeholder: 'clide daemon is reachable over the local socket')
: i.string('disconnected.hint',
namespace: _ns,
placeholder:
'clide daemon is not running — start it with `clide --daemon`');
return Semantics(
label: label,
hint: hint,
liveRegion: true,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ClideIcon(const PlugIcon(), size: 12, color: color),
const SizedBox(width: 6),
ClideText(label, fontSize: 12, color: color),
],
),
),
);
},
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class JiraExtension extends ClideExtension {
@override
String get id => 'builtin.jira';
@override
String get title => 'Jira';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class KeybindingsUiExtension extends ClideExtension {
@override
String get id => 'builtin.keybindings-ui';
@override
String get title => 'Keybindings UI';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class MarkdownExtension extends ClideExtension {
@override
String get id => 'builtin.markdown';
@override
String get title => 'Markdown';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const ['builtin.editor'];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class PqlExtension extends ClideExtension {
@override
String get id => 'builtin.pql';
@override
String get title => 'pql';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class ProblemsExtension extends ClideExtension {
@override
String get id => 'builtin.problems';
@override
String get title => 'Problems';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class SettingsUiExtension extends ClideExtension {
@override
String get id => 'builtin.settings-ui';
@override
String get title => 'Settings UI';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class TerminalExtension extends ClideExtension {
@override
String get id => 'builtin.terminal';
@override
String get title => 'Terminal';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,53 @@
import 'package:clide/clide.dart';
import 'package:clide_app/builtin/theme_picker/src/picker_view.dart';
import 'package:clide_app/extension/extension.dart';
class ThemePickerExtension extends ClideExtension {
@override
String get id => 'builtin.theme-picker';
@override
String get title => 'Theme picker';
@override
String get version => '0.1.0';
ClideExtensionContext? _ctx;
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
}
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'theme.pick',
command: 'theme.pick',
title: 'Theme: Pick…',
defaultBinding: 'ctrl+k',
run: _pick,
),
];
Future<IpcResponse> _pick(List<String> args) async {
final ctx = _ctx;
if (ctx == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'theme-picker not activated',
),
);
}
final selected = await ctx.dialog.show<String>(
(context, dismiss) => ThemePickerView(
controller: ctx.theme,
onDismiss: dismiss,
),
);
return IpcResponse.ok(id: '', data: {
'selected': selected ?? ctx.theme.currentName,
});
}
}
@@ -0,0 +1,172 @@
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ThemePickerView extends StatefulWidget {
const ThemePickerView({
super.key,
required this.controller,
required this.onDismiss,
});
final ThemeController controller;
final void Function([String? selected]) onDismiss;
static const ns = 'builtin.theme-picker';
@override
State<ThemePickerView> createState() => _ThemePickerViewState();
}
class _ThemePickerViewState extends State<ThemePickerView> {
String? _hovered;
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
final themes = widget.controller.available;
final currentName = widget.controller.currentName;
final i = kernel.i18n;
return Semantics(
container: true,
label: i.string('modal.title',
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
explicitChildNodes: true,
child: ClideSurface(
width: 420,
color: tokens.modalSurfaceBackground,
border: tokens.modalSurfaceBorder,
padding: const EdgeInsets.all(16),
borderRadius: BorderRadius.circular(4),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClideText(
i.string('modal.title',
namespace: ThemePickerView.ns, placeholder: 'Select theme'),
fontSize: 15,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 8),
ClideDivider(),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 360),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final t in themes)
_ThemeRow(
name: t.name,
displayName: t.displayName,
selected: t.name == currentName,
hovered: _hovered == t.name,
hint: i.string('row.select.hint',
namespace: ThemePickerView.ns,
placeholder: 'Activate this theme'),
onEnter: () => setState(() => _hovered = t.name),
onExit: () => setState(() => _hovered = null),
onTap: () {
widget.controller.select(t.name);
widget.onDismiss(t.name);
},
),
],
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ClideButton(
label: i.string('modal.cancel',
namespace: ThemePickerView.ns, placeholder: 'Cancel'),
semanticHint: i.string('modal.cancel.hint',
namespace: ThemePickerView.ns,
placeholder:
'Close the theme picker without changing the current theme'),
onPressed: () => widget.onDismiss(),
),
],
),
],
),
),
);
}
}
class _ThemeRow extends StatelessWidget {
const _ThemeRow({
required this.name,
required this.displayName,
required this.selected,
required this.hovered,
required this.hint,
required this.onEnter,
required this.onExit,
required this.onTap,
});
final String name;
final String displayName;
final bool selected;
final bool hovered;
final String hint;
final VoidCallback onEnter;
final VoidCallback onExit;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final bg = selected
? tokens.listItemSelectedBackground
: (hovered
? tokens.listItemHoverBackground
: tokens.listItemBackground);
final fg = selected
? tokens.listItemSelectedForeground
: tokens.listItemForeground;
return Semantics(
button: true,
selected: selected,
label: displayName,
hint: hint,
onTap: onTap,
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => onEnter(),
onExit: (_) => onExit(),
child: GestureDetector(
onTap: onTap,
child: Container(
color: bg,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
if (selected)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ClideIcon(const CheckIcon(), size: 12, color: fg),
)
else
const SizedBox(width: 20),
Expanded(
child: ClideText(displayName, color: fg),
),
ClideText(name, color: tokens.globalTextMuted, fontSize: 11),
],
),
),
),
),
);
}
}
@@ -0,0 +1,2 @@
export 'src/extension.dart';
export 'src/picker_view.dart';
@@ -0,0 +1,19 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-reserved stub. Will surface a sidebar tab (filtered list) + a
/// workspace tab (kanban board) + commands (`tickets.open`,
/// `tickets.new`, `tickets.move`, `tickets.block`). Data source: `pql
/// ticket …` once `Q-021` resolves; `tools/scripts/plan ticket …` until
/// then. Ticket persistence strategy open at `Q-022`.
class TicketsExtension extends ClideExtension {
@override
String get id => 'builtin.tickets';
@override
String get title => 'Tickets';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/extension/extension.dart';
/// Tier-0 stub. Real implementation lands in a later tier; the extension
/// is registered so the extensions-ui surface can list it as "installed,
/// not yet implemented" and its id is reserved.
class TodosExtension extends ClideExtension {
@override
String get id => 'builtin.todos';
@override
String get title => 'TODOs';
@override
String get version => '0.0.0-stub';
@override
List<String> get dependsOn => const [];
@override
List<ContributionPoint> get contributions => const [];
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,35 @@
import 'package:clide/clide.dart';
import 'package:clide_app/builtin/welcome/src/welcome_view.dart';
import 'package:clide_app/extension/extension.dart';
import 'package:clide_app/kernel/kernel.dart';
class WelcomeExtension extends ClideExtension {
@override
String get id => 'builtin.welcome';
@override
String get title => 'Welcome';
@override
String get version => '0.1.0';
@override
List<ContributionPoint> get contributions => [
TabContribution(
id: 'welcome.view',
slot: Slots.workspace,
title: 'Welcome',
titleKey: 'tab.title',
i18nNamespace: id,
priority: -100, // anchor at the far left of the workspace tabs
build: (_) => const WelcomeView(),
),
CommandContribution(
id: 'workspace.open-project',
command: 'workspace.open-project',
title: 'Workspace: Open project…',
run: (_) async => IpcResponse.ok(
id: '',
data: const {'note': 'project picker lands in a later tier'},
),
),
];
}
@@ -0,0 +1,67 @@
import 'package:clide_app/kernel/kernel.dart';
import 'package:clide_app/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class WelcomeView extends StatelessWidget {
const WelcomeView({super.key});
static const _ns = 'builtin.welcome';
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.i18n,
builder: (ctx, _) {
final i = kernel.i18n;
return ClideSurface(
color: tokens.globalBackground,
padding: const EdgeInsets.all(32),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ClideText(
i.string('title', namespace: _ns, placeholder: 'clide'),
fontSize: 40,
fontWeight: FontWeight.w300,
color: tokens.panelActiveBorder,
),
const SizedBox(height: 12),
ClideText(
i.string(
'subtitle',
namespace: _ns,
placeholder: 'Flutter desktop IDE for Claude Code',
),
muted: true,
fontSize: 14,
),
const SizedBox(height: 32),
ClideButton(
label: i.string(
'open-project',
namespace: _ns,
placeholder: 'Open project',
),
semanticHint: i.string(
'open-project.hint',
namespace: _ns,
placeholder:
'Pick a git repository to open as the workspace',
),
variant: ClideButtonVariant.primary,
onPressed: () {
// project open UI lands with the project picker tier
},
),
],
),
),
);
},
);
}
}
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
+11
View File
@@ -0,0 +1,11 @@
/// clide extension contract.
///
/// ClideExtension = shipping unit. ContributionPoint = atom contributed
/// into a kernel slot or service. One manifest may contribute N atoms
/// across multiple slots.
library;
export 'src/contribution.dart';
export 'src/extension.dart';
export 'src/host.dart';
export 'src/manifest.dart';
+157
View File
@@ -0,0 +1,157 @@
import 'package:clide/clide.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:flutter/widgets.dart';
/// One atom contributed by a [ClideExtension]. Extensions declare N of
/// these in a manifest; the kernel and slot hosts render them.
///
/// Adding a new contribution type: add a case to this sealed hierarchy,
/// extend the host dispatch in the default-layout extension, and bump
/// the extension manifest schema version.
sealed class ContributionPoint {
const ContributionPoint({required this.id});
/// Stable id for this contribution, unique within its extension.
final String id;
/// The slot this contribution targets, or `null` for non-slot
/// contributions (commands, events, grammars).
SlotId? get slot => null;
}
/// A tab in a slot that hosts tabs (sidebar / workspace / context).
class TabContribution extends ContributionPoint {
const TabContribution({
required super.id,
required this.slot,
required this.title,
required this.build,
this.icon,
this.priority = 0,
this.fileGlobs = const [],
this.listenable,
this.titleKey,
this.i18nNamespace,
});
@override
final SlotId slot;
final String title;
final WidgetBuilder build;
final Object? icon;
final int priority;
final List<String> fileGlobs;
final Listenable? listenable;
/// When set, the slot host resolves the display title via
/// `i18n.string(titleKey, namespace: i18nNamespace, placeholder: title)`.
/// [title] stays as the English fallback (also used in tests/logs).
final String? titleKey;
/// The i18n namespace to look up [titleKey] in. Extensions usually
/// pass their own `id`. Required when [titleKey] is set.
final String? i18nNamespace;
}
/// A status-bar item. Order is determined by [priority] within each
/// alignment group; negative priorities float left, positive right.
class StatusItemContribution extends ContributionPoint {
const StatusItemContribution({
required super.id,
required this.build,
this.priority = 0,
this.listenable,
});
@override
SlotId get slot => Slots.statusbar;
final WidgetBuilder build;
final int priority;
final Listenable? listenable;
}
/// A button in the main toolbar.
class ToolbarButtonContribution extends ContributionPoint {
const ToolbarButtonContribution({
required super.id,
required this.label,
required this.onPressed,
this.icon,
this.tooltip,
this.priority = 0,
});
@override
SlotId get slot => Slots.toolbar;
final String label;
final Object? icon;
final String? tooltip;
final int priority;
final VoidCallback onPressed;
}
/// A command extensions register with [CommandRegistry]. Surfaced by the
/// command palette, the keybinding resolver, and `clide` CLI subcommands.
class CommandContribution extends ContributionPoint {
const CommandContribution({
required super.id,
required this.command,
required this.run,
this.title,
this.defaultBinding,
});
final String command; // e.g. "git.commit"
final String? title; // "Git: Commit staged"
final String? defaultBinding; // e.g. "ctrl+shift+g"
final Future<IpcResponse> Function(List<String> args) run;
}
/// Registers an item in the OS tray / menu-bar.
class TrayItemContribution extends ContributionPoint {
const TrayItemContribution({
required super.id,
required this.label,
required this.onSelected,
this.priority = 0,
});
@override
SlotId get slot => Slots.tray;
final String label;
final int priority;
final VoidCallback onSelected;
}
/// A named layout arrangement. One "classic" preset ships with
/// `builtin.default-layout`; other presets can be contributed.
class LayoutPresetContribution extends ContributionPoint {
const LayoutPresetContribution({
required super.id,
required this.displayName,
required this.slots,
});
final String displayName;
final List<LayoutSlot> slots;
}
/// One slot in a [LayoutPresetContribution]. Describes where the slot
/// appears and its initial size/visibility.
class LayoutSlot {
const LayoutSlot({
required this.slot,
required this.position,
this.defaultSize,
this.minSize,
this.maxSize,
this.visible = true,
});
final SlotId slot;
final SlotPosition position;
final double? defaultSize;
final double? minSize;
final double? maxSize;
final bool visible;
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/src/clipboard.dart';
import 'package:clide_app/kernel/src/commands/palette.dart';
import 'package:clide_app/kernel/src/commands/registry.dart';
import 'package:clide_app/kernel/src/dialog.dart';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/files.dart';
import 'package:clide_app/kernel/src/focus.dart';
import 'package:clide_app/kernel/src/i18n/i18n.dart';
import 'package:clide_app/kernel/src/ipc/client.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:clide_app/kernel/src/net.dart';
import 'package:clide_app/kernel/src/notify.dart';
import 'package:clide_app/kernel/src/os.dart';
import 'package:clide_app/kernel/src/panels/arrangement.dart';
import 'package:clide_app/kernel/src/panels/registry.dart';
import 'package:clide_app/kernel/src/project.dart';
import 'package:clide_app/kernel/src/secrets.dart';
import 'package:clide_app/kernel/src/settings.dart';
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/kernel/src/tray.dart';
/// One shipping unit. Built-in extensions compile in as Dart subclasses;
/// third-party extensions run as Lua scripts wrapped by a `LuaExtension`
/// adapter (Tier 6).
abstract class ClideExtension {
String get id;
String get title;
String get version;
/// IDs of other extensions that must be activated before this one.
/// Missing deps → this extension is skipped at load with a warning.
List<String> get dependsOn => const [];
/// The atoms this extension contributes.
List<ContributionPoint> get contributions;
/// Called once after dependencies activate.
Future<void> activate(ClideExtensionContext ctx) async {}
/// Called when the extension is disabled or the app shuts down.
Future<void> deactivate() async {}
}
/// Handed to every [ClideExtension.activate]. Lists every kernel service
/// an extension may reach. The extension manager constructs a concrete
/// instance with refs; tests can pass fakes.
///
/// The interface deliberately lists services individually rather than
/// exposing a `KernelServices` aggregate — doing so would create an
/// import cycle between the kernel facade and this file.
abstract class ClideExtensionContext {
String get id;
Logger get log;
EventBus get events;
SettingsStore get settings;
ThemeController get theme;
I18n get i18n;
PanelRegistry get panels;
LayoutArrangement get arrangement;
CommandRegistry get commands;
PaletteController get palette;
ClideClipboard get clipboard;
FileServices get files;
Notifications get notify;
DialogRouter get dialog;
TrayRegistry get tray;
SecretsVault get secrets;
OsBridge get os;
NetworkStatus get net;
FocusTracker get focus;
ProjectManager get project;
DaemonClient get ipc;
}
/// Sugar for i18n lookups scoped to this extension's namespace.
extension ClideExtensionContextI18n on ClideExtensionContext {
/// `ctx.t('welcome.title', placeholder: 'clide')` →
/// `i18n.string('welcome.title', namespace: id, placeholder: 'clide')`.
String t(String key, {String? placeholder}) =>
i18n.string(key, namespace: id, placeholder: placeholder);
/// [t] with interpolation replacers.
String tr(
String key, {
String? placeholder,
List<I18nReplacer> replacers = const [],
}) =>
i18n.interpolated(
key,
namespace: id,
placeholder: placeholder,
replacers: replacers,
);
}
+36
View File
@@ -0,0 +1,36 @@
import 'dart:io';
import 'package:clide_app/extension/src/manifest.dart';
/// Scans the third-party extensions root for `manifest.yaml` files.
///
/// Built-ins are registered by the app at boot; this scanner handles
/// installed third-party extensions. Tier 0 returns an empty list
/// until the Lua adapter lands — every call is safe to make anyway.
class ExtensionScanner {
const ExtensionScanner();
/// Typical install root: `~/.clide/extensions/<id>/manifest.yaml`.
/// Override for tests.
Directory defaultRoot() {
final home = Platform.environment['HOME'] ?? '/tmp';
return Directory('$home/.clide/extensions');
}
Future<List<ExtensionManifest>> discover({Directory? root}) async {
final dir = root ?? defaultRoot();
if (!await dir.exists()) return const [];
final out = <ExtensionManifest>[];
await for (final entity in dir.list()) {
if (entity is! Directory) continue;
final m = File('${entity.path}/manifest.yaml');
if (!await m.exists()) continue;
try {
out.add(await ExtensionManifest.fromFile(m));
} on FormatException catch (_) {
// skip malformed manifests; the extensions-ui will surface them
}
}
return out;
}
}
+60
View File
@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:yaml/yaml.dart';
/// A parsed third-party extension manifest.
///
/// Built-in extensions don't need a manifest file — they compile in as
/// Dart subclasses of [ClideExtension]. Third-party extensions ship a
/// `manifest.yaml` under `~/.clide/extensions/<id>/` alongside their
/// Lua entrypoint; this class parses and validates that file.
class ExtensionManifest {
const ExtensionManifest({
required this.id,
required this.title,
required this.version,
required this.dependsOn,
required this.entry,
required this.schemaVersion,
});
final String id;
final String title;
final String version;
final List<String> dependsOn;
final String entry; // relative path to lua entrypoint
final int schemaVersion;
factory ExtensionManifest.fromYamlString(String text) {
final doc = loadYaml(text);
if (doc is! Map) {
throw const FormatException('manifest root is not a map');
}
final id = doc['id'];
if (id is! String || id.isEmpty) {
throw const FormatException('manifest missing `id`');
}
final title = (doc['title'] as String?) ?? id;
final version = (doc['version'] as String?) ?? '0.0.0';
final entry = (doc['entry'] as String?) ?? 'extension.lua';
final schemaVersion = (doc['schema_version'] as int?) ?? 1;
final depsYaml = doc['depends_on'];
final deps = <String>[];
if (depsYaml is YamlList) {
for (final d in depsYaml) {
if (d is String) deps.add(d);
}
}
return ExtensionManifest(
id: id,
title: title,
version: version,
dependsOn: deps,
entry: entry,
schemaVersion: schemaVersion,
);
}
static Future<ExtensionManifest> fromFile(File f) async =>
ExtensionManifest.fromYamlString(await f.readAsString());
}
+48
View File
@@ -0,0 +1,48 @@
/// clide kernel — registries, stores, and shared singleton services
/// consumed by every extension.
///
/// Admission rule: the kernel owns anything whose second concurrent user
/// would create incoherent state or divergent UX. External-interfacing
/// work generally belongs to extensions (git, pql, Linear, Jira);
/// external-interfacing *singletons* (OS clipboard, tray, keychain)
/// belong here.
///
/// Exports are added as each subsystem lands. See plan:
/// /home/jeroenschweitzer/.claude/plans/i-want-to-discuss-cozy-zebra.md
library;
export 'src/events/bus.dart';
export 'src/events/types.dart';
export 'src/ipc/client.dart';
export 'src/log.dart';
export 'src/settings.dart';
export 'src/facade.dart';
export 'src/clipboard.dart';
export 'src/commands/keybindings.dart';
export 'src/commands/palette.dart';
export 'src/commands/registry.dart';
export 'src/dialog.dart';
export 'src/extensions_manager.dart';
export 'src/files.dart';
export 'src/focus.dart';
export 'src/i18n/catalog_loader.dart';
export 'src/i18n/fallback_chain.dart';
export 'src/i18n/i18n.dart';
export 'src/net.dart';
export 'src/notify.dart';
export 'src/os.dart';
export 'src/panels/arrangement.dart';
export 'src/project.dart';
export 'src/secrets.dart';
export 'src/tray.dart';
export 'src/panels/drag_resize.dart';
export 'src/panels/layout_preset.dart';
export 'src/panels/registry.dart';
export 'src/panels/slot_id.dart';
export 'src/theme/contrast.dart';
export 'src/theme/controller.dart';
export 'src/theme/loader.dart';
export 'src/theme/palette.dart';
export 'src/theme/resolver.dart';
export 'src/theme/semantic.dart';
export 'src/theme/tokens.dart';
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' as flutter_services;
/// Typed, per-content-kind clipboard with a plaintext fallback.
///
/// Extensions write typed values (`write<GitHunk>(hunk)`) and read in
/// the same type (`readAs<GitHunk>()`). Anything with a `toPlain`
/// callback also syncs to the OS clipboard so external apps see
/// reasonable text. The history ring keeps the last [historyLimit]
/// entries per type for quick recall.
class ClideClipboard {
ClideClipboard({this.historyLimit = 16});
final int historyLimit;
final Map<Type, List<Object>> _history = {};
Future<void> write<T extends Object>(
T value, {
String Function(T)? toPlain,
}) async {
final bucket = _history.putIfAbsent(T, () => <Object>[]);
bucket.insert(0, value);
if (bucket.length > historyLimit) bucket.removeLast();
if (toPlain != null) {
await flutter_services.Clipboard.setData(
flutter_services.ClipboardData(text: toPlain(value)));
}
}
T? readAs<T extends Object>() {
final bucket = _history[T];
if (bucket == null || bucket.isEmpty) return null;
return bucket.first as T;
}
List<T> historyOf<T extends Object>() {
final bucket = _history[T];
if (bucket == null) return const [];
return bucket.cast<T>().toList(growable: false);
}
Future<String?> readPlain() async {
final d = await flutter_services.Clipboard.getData('text/plain');
return d?.text;
}
Future<void> writePlain(String text) async {
await flutter_services.Clipboard.setData(
flutter_services.ClipboardData(text: text));
final bucket = _history.putIfAbsent(String, () => <Object>[]);
bucket.insert(0, text);
if (bucket.length > historyLimit) bucket.removeLast();
}
@visibleForTesting
void clear() => _history.clear();
}
@@ -0,0 +1,78 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Key combo: modifiers + primary key. Canonicalized on construction
/// (modifiers sorted, lowercased) so equality works for lookup keys.
@immutable
class Keybinding {
Keybinding({required Set<String> modifiers, required String key})
: modifiers = _canonModifiers(modifiers),
key = key.toLowerCase();
final List<String> modifiers;
final String key;
static List<String> _canonModifiers(Set<String> m) {
final normalized = m.map((s) => s.toLowerCase()).toSet().toList()..sort();
return List.unmodifiable(normalized);
}
/// Parse "ctrl+shift+g", "cmd+k", "alt+f4".
static Keybinding parse(String spec) {
if (spec.trim().isEmpty) {
throw ArgumentError('empty keybinding');
}
final parts = spec.split('+').map((s) => s.trim()).toList();
final key = parts.removeLast();
if (key.isEmpty) {
throw ArgumentError('keybinding is missing a key: "$spec"');
}
return Keybinding(modifiers: parts.toSet(), key: key);
}
String get canonical {
if (modifiers.isEmpty) return key;
return '${modifiers.join('+')}+$key';
}
@override
bool operator ==(Object other) =>
other is Keybinding &&
other.key == key &&
listEquals(other.modifiers, modifiers);
@override
int get hashCode => Object.hash(key, Object.hashAll(modifiers));
@override
String toString() => 'Keybinding($canonical)';
}
class KeybindingResolver {
final Map<Keybinding, String> _bindings = {};
void bind(Keybinding b, String commandId) {
_bindings[b] = commandId;
}
void unbind(Keybinding b) {
_bindings.remove(b);
}
String? commandFor(Keybinding b) => _bindings[b];
Iterable<MapEntry<Keybinding, String>> get entries => _bindings.entries;
/// Map a Flutter [KeyEvent] to a [Keybinding] suitable for lookup.
static Keybinding? fromKeyEvent(KeyEvent event, HardwareKeyboard keyboard) {
if (event is! KeyDownEvent) return null;
final label = event.logicalKey.keyLabel;
if (label.isEmpty) return null;
final mods = <String>{};
if (keyboard.isControlPressed) mods.add('ctrl');
if (keyboard.isShiftPressed) mods.add('shift');
if (keyboard.isAltPressed) mods.add('alt');
if (keyboard.isMetaPressed) mods.add('cmd');
return Keybinding(modifiers: mods, key: label);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/kernel/src/commands/registry.dart';
import 'package:flutter/foundation.dart';
class PaletteController extends ChangeNotifier {
PaletteController(this._registry);
final CommandRegistry _registry;
bool _open = false;
String _filter = '';
bool get isOpen => _open;
String get filter => _filter;
void open() {
if (_open) return;
_open = true;
notifyListeners();
}
void close() {
if (!_open) return;
_open = false;
_filter = '';
notifyListeners();
}
void toggle() => _open ? close() : open();
void setFilter(String f) {
if (_filter == f) return;
_filter = f;
notifyListeners();
}
List<CommandContribution> filtered() {
if (_filter.isEmpty) return _registry.all.toList();
final q = _filter.toLowerCase();
return _registry.all.where((c) {
final haystack = (c.title ?? c.command).toLowerCase();
return haystack.contains(q);
}).toList();
}
Future<void> invoke(String command) async {
close();
await _registry.execute(command);
}
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:clide/clide.dart';
import 'package:clide_app/extension/src/contribution.dart';
import 'package:flutter/foundation.dart';
class CommandRegistry extends ChangeNotifier {
final Map<String, CommandContribution> _byCommand = {};
void register(CommandContribution cmd) {
_byCommand[cmd.command] = cmd;
notifyListeners();
}
void unregister(String command) {
if (_byCommand.remove(command) != null) notifyListeners();
}
Iterable<CommandContribution> get all => _byCommand.values;
CommandContribution? get(String command) => _byCommand[command];
Future<IpcResponse> execute(
String command, {
List<String> args = const [],
}) async {
final c = _byCommand[command];
if (c == null) {
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.notFound,
kind: IpcErrorKind.notFound,
message: 'no such command: $command',
),
);
}
return c.run(args);
}
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
typedef DialogBuilder<T> = Widget Function(
BuildContext context,
void Function([T? result]) dismiss,
);
/// Single-at-a-time modal router.
///
/// Extensions call [show] with a builder; the root widget (installed by
/// [DialogHost]) listens and renders the current dialog over a dimmed
/// backdrop. Only one dialog is active at a time — a second [show] call
/// while one is open awaits until the first dismisses.
class DialogRouter extends ChangeNotifier {
DialogBuilder<Object?>? _current;
Completer<Object?>? _completer;
final List<_Queued> _queue = [];
DialogBuilder<Object?>? get current => _current;
bool get isOpen => _current != null;
Future<T?> show<T extends Object>(DialogBuilder<T> builder) {
final completer = Completer<T?>();
final wrapped = _wrap<T>(builder);
if (_current == null) {
_current = wrapped;
_completer = Completer<Object?>();
// forward our generic completer to the typed one
_completer!.future.then((v) {
if (!completer.isCompleted) completer.complete(v as T?);
});
notifyListeners();
} else {
_queue.add(_Queued(wrapped, completer));
}
return completer.future;
}
void dismiss([Object? result]) {
if (_current == null) return;
final c = _completer;
_current = null;
_completer = null;
if (c != null && !c.isCompleted) c.complete(result);
if (_queue.isNotEmpty) {
final next = _queue.removeAt(0);
_current = next.builder;
_completer = Completer<Object?>();
_completer!.future.then((v) {
if (!next.completer.isCompleted) next.completer.complete(v);
});
}
notifyListeners();
}
DialogBuilder<Object?> _wrap<T>(DialogBuilder<T> builder) {
return (ctx, dismiss) => builder(ctx, ([T? v]) => dismiss(v));
}
}
class _Queued {
_Queued(this.builder, this.completer);
final DialogBuilder<Object?> builder;
// ignore: strict_raw_type
final Completer completer;
}
/// Hosts the current dialog from [DialogRouter]. Place high in the tree
/// (inside the WidgetsApp) so dialogs overlay every other surface.
class DialogHost extends StatelessWidget {
const DialogHost({
super.key,
required this.router,
required this.child,
this.backdropColor = const Color(0xC0000000),
});
final DialogRouter router;
final Widget child;
final Color backdropColor;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
child,
ListenableBuilder(
listenable: router,
builder: (ctx, _) {
final b = router.current;
if (b == null) return const SizedBox.shrink();
return Positioned.fill(
child: ColoredBox(
color: backdropColor,
child: Center(
child: b(ctx, router.dismiss),
),
),
);
},
),
],
);
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'dart:async';
import 'package:clide_app/kernel/src/events/types.dart';
class EventBus {
EventBus();
final StreamController<ClideEventEnvelope> _controller =
StreamController<ClideEventEnvelope>.broadcast();
Stream<ClideEventEnvelope> get stream => _controller.stream;
Stream<T> on<T extends ClideEvent>() =>
_controller.stream.where((e) => e.event is T).map((e) => e.event as T);
void emit(ClideEvent event) {
if (_controller.isClosed) return;
_controller.add(ClideEventEnvelope(event, DateTime.now().toUtc()));
}
Future<void> dispose() => _controller.close();
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/foundation.dart';
@immutable
abstract class ClideEvent {
const ClideEvent();
String get subsystem;
String get kind;
Map<String, Object?> payload() => const {};
}
@immutable
class ClideEventEnvelope {
const ClideEventEnvelope(this.event, this.timestamp);
final ClideEvent event;
final DateTime timestamp;
Map<String, Object?> toJson() => {
'v': 1,
'subsystem': event.subsystem,
'kind': event.kind,
'ts': timestamp.toIso8601String(),
'data': event.payload(),
};
}
class DaemonConnectionChanged extends ClideEvent {
const DaemonConnectionChanged({required this.connected});
final bool connected;
@override
String get subsystem => 'ipc';
@override
String get kind => 'connection-changed';
@override
Map<String, Object?> payload() => {'connected': connected};
}
class ThemeChanged extends ClideEvent {
const ThemeChanged({required this.themeName});
final String themeName;
@override
String get subsystem => 'theme';
@override
String get kind => 'changed';
@override
Map<String, Object?> payload() => {'theme': themeName};
}
class ProjectOpened extends ClideEvent {
const ProjectOpened({required this.path});
final String path;
@override
String get subsystem => 'project';
@override
String get kind => 'opened';
@override
Map<String, Object?> payload() => {'path': path};
}
class ProjectClosed extends ClideEvent {
const ProjectClosed();
@override
String get subsystem => 'project';
@override
String get kind => 'closed';
}
class ExtensionActivated extends ClideEvent {
const ExtensionActivated({required this.id});
final String id;
@override
String get subsystem => 'extensions';
@override
String get kind => 'activated';
@override
Map<String, Object?> payload() => {'id': id};
}
class ExtensionDeactivated extends ClideEvent {
const ExtensionDeactivated({required this.id});
final String id;
@override
String get subsystem => 'extensions';
@override
String get kind => 'deactivated';
@override
Map<String, Object?> payload() => {'id': id};
}
/// Forwarded from the daemon. Feature extensions subscribe to this and
/// narrow by subsystem+kind, or register a converter that emits a typed
/// `ClideEvent` subclass into the bus.
class DaemonEvent extends ClideEvent {
const DaemonEvent({
required this.subsystem,
required this.kind,
required this.data,
required this.ts,
});
@override
final String subsystem;
@override
final String kind;
final Map<String, Object?> data;
final DateTime ts;
@override
Map<String, Object?> payload() => {'ts': ts.toIso8601String(), ...data};
}
+279
View File
@@ -0,0 +1,279 @@
import 'dart:async';
import 'package:clide_app/extension/src/contribution.dart';
import 'package:clide_app/extension/src/extension.dart';
import 'package:clide_app/kernel/src/clipboard.dart';
import 'package:clide_app/kernel/src/commands/keybindings.dart';
import 'package:clide_app/kernel/src/commands/palette.dart';
import 'package:clide_app/kernel/src/commands/registry.dart';
import 'package:clide_app/kernel/src/dialog.dart';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/events/types.dart';
import 'package:clide_app/kernel/src/files.dart';
import 'package:clide_app/kernel/src/focus.dart';
import 'package:clide_app/kernel/src/i18n/i18n.dart';
import 'package:clide_app/kernel/src/ipc/client.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:clide_app/kernel/src/net.dart';
import 'package:clide_app/kernel/src/notify.dart';
import 'package:clide_app/kernel/src/os.dart';
import 'package:clide_app/kernel/src/panels/arrangement.dart';
import 'package:clide_app/kernel/src/panels/registry.dart';
import 'package:clide_app/kernel/src/project.dart';
import 'package:clide_app/kernel/src/secrets.dart';
import 'package:clide_app/kernel/src/settings.dart';
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/kernel/src/tray.dart';
import 'package:flutter/foundation.dart';
class ExtensionManager extends ChangeNotifier {
ExtensionManager({
required this.log,
required this.events,
required this.settings,
required this.theme,
required this.i18n,
required this.panels,
required this.arrangement,
required this.commands,
required this.palette,
required this.keybindings,
required this.clipboard,
required this.files,
required this.notify,
required this.dialog,
required this.tray,
required this.secrets,
required this.os,
required this.net,
required this.focus,
required this.project,
required this.ipc,
});
final Logger log;
final EventBus events;
final SettingsStore settings;
final ThemeController theme;
final I18n i18n;
final PanelRegistry panels;
final LayoutArrangement arrangement;
final CommandRegistry commands;
final PaletteController palette;
final KeybindingResolver keybindings;
final ClideClipboard clipboard;
final FileServices files;
final Notifications notify;
final DialogRouter dialog;
final TrayRegistry tray;
final SecretsVault secrets;
final OsBridge os;
final NetworkStatus net;
final FocusTracker focus;
final ProjectManager project;
final DaemonClient ipc;
final Map<String, ClideExtension> _known = {};
final Set<String> _activated = {};
void register(ClideExtension ext) {
if (_known.containsKey(ext.id)) {
log.warn('extensions', 'duplicate registration: ${ext.id}');
return;
}
_known[ext.id] = ext;
notifyListeners();
}
Iterable<ClideExtension> get all => _known.values;
bool isActivated(String id) => _activated.contains(id);
bool isEnabled(String id) {
final v = settings.get<bool>('app.extensions.$id.enabled');
return v ?? true;
}
Future<void> setEnabled(String id, bool enabled) async {
await settings.set<bool>('app.extensions.$id.enabled', enabled);
if (enabled && !isActivated(id)) {
await activate(id);
} else if (!enabled && isActivated(id)) {
await deactivate(id);
}
}
/// Activate every enabled extension in dependency order. Missing
/// deps warn and skip.
Future<void> activateAll() async {
final order = _topoSort();
for (final id in order) {
if (!isEnabled(id)) continue;
await activate(id);
}
}
Future<void> activate(String id) async {
if (_activated.contains(id)) return;
final ext = _known[id];
if (ext == null) {
log.warn('extensions', 'unknown extension: $id');
return;
}
for (final dep in ext.dependsOn) {
if (!_activated.contains(dep)) {
log.warn(
'extensions', 'skipping ${ext.id}: dependency not activated: $dep');
return;
}
}
final ctx = _ExtensionContext(manager: this, id: ext.id);
try {
await ext.activate(ctx);
for (final c in ext.contributions) {
_applyContribution(c);
}
_activated.add(id);
events.emit(ExtensionActivated(id: id));
notifyListeners();
log.info('extensions', 'activated $id');
} catch (e, st) {
log.error('extensions', 'activate failed for $id',
error: e, stackTrace: st);
}
}
Future<void> deactivate(String id) async {
if (!_activated.contains(id)) return;
final ext = _known[id];
if (ext == null) return;
try {
await ext.deactivate();
for (final c in ext.contributions) {
_removeContribution(c);
}
_activated.remove(id);
events.emit(ExtensionDeactivated(id: id));
notifyListeners();
log.info('extensions', 'deactivated $id');
} catch (e, st) {
log.error('extensions', 'deactivate failed for $id',
error: e, stackTrace: st);
}
}
void _applyContribution(ContributionPoint c) {
switch (c) {
case TabContribution _:
case StatusItemContribution _:
case ToolbarButtonContribution _:
panels.contribute(c);
case CommandContribution cmd:
commands.register(cmd);
final binding = cmd.defaultBinding;
if (binding != null) {
keybindings.bind(Keybinding.parse(binding), cmd.command);
}
case TrayItemContribution t:
tray.add(t);
case LayoutPresetContribution _:
// Presets are consumed by the default-layout extension in its
// own activate(); nothing for the kernel to do here.
break;
}
}
void _removeContribution(ContributionPoint c) {
switch (c) {
case TabContribution _:
case StatusItemContribution _:
case ToolbarButtonContribution _:
panels.uncontribute(c.id);
case CommandContribution cmd:
commands.unregister(cmd.command);
final binding = cmd.defaultBinding;
if (binding != null) {
keybindings.unbind(Keybinding.parse(binding));
}
case TrayItemContribution t:
tray.remove(t.id);
case LayoutPresetContribution _:
break;
}
}
List<String> _topoSort() {
final order = <String>[];
final seen = <String>{};
final visiting = <String>{};
void visit(String id) {
if (seen.contains(id)) return;
if (visiting.contains(id)) {
log.warn('extensions', 'dependency cycle touching $id');
return;
}
final ext = _known[id];
if (ext == null) return;
visiting.add(id);
for (final dep in ext.dependsOn) {
visit(dep);
}
visiting.remove(id);
seen.add(id);
order.add(id);
}
for (final id in _known.keys) {
visit(id);
}
return order;
}
}
class _ExtensionContext implements ClideExtensionContext {
_ExtensionContext({required this.manager, required this.id});
final ExtensionManager manager;
@override
final String id;
@override
Logger get log => manager.log;
@override
EventBus get events => manager.events;
@override
SettingsStore get settings => manager.settings;
@override
ThemeController get theme => manager.theme;
@override
I18n get i18n => manager.i18n;
@override
PanelRegistry get panels => manager.panels;
@override
LayoutArrangement get arrangement => manager.arrangement;
@override
CommandRegistry get commands => manager.commands;
@override
PaletteController get palette => manager.palette;
@override
ClideClipboard get clipboard => manager.clipboard;
@override
FileServices get files => manager.files;
@override
Notifications get notify => manager.notify;
@override
DialogRouter get dialog => manager.dialog;
@override
TrayRegistry get tray => manager.tray;
@override
SecretsVault get secrets => manager.secrets;
@override
OsBridge get os => manager.os;
@override
NetworkStatus get net => manager.net;
@override
FocusTracker get focus => manager.focus;
@override
ProjectManager get project => manager.project;
@override
DaemonClient get ipc => manager.ipc;
}
+236
View File
@@ -0,0 +1,236 @@
import 'dart:async';
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide_app/kernel/src/clipboard.dart';
import 'package:clide_app/kernel/src/commands/keybindings.dart';
import 'package:clide_app/kernel/src/commands/palette.dart';
import 'package:clide_app/kernel/src/commands/registry.dart';
import 'package:clide_app/kernel/src/dialog.dart';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/extensions_manager.dart';
import 'package:clide_app/kernel/src/files.dart';
import 'package:clide_app/kernel/src/focus.dart';
import 'package:clide_app/kernel/src/i18n/catalog_loader.dart';
import 'package:clide_app/kernel/src/i18n/i18n.dart';
import 'package:clide_app/kernel/src/ipc/client.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:clide_app/kernel/src/net.dart';
import 'package:clide_app/kernel/src/notify.dart';
import 'package:clide_app/kernel/src/os.dart';
import 'package:clide_app/kernel/src/panels/arrangement.dart';
import 'package:clide_app/kernel/src/panels/registry.dart';
import 'package:clide_app/kernel/src/project.dart';
import 'package:clide_app/kernel/src/secrets.dart';
import 'package:clide_app/kernel/src/settings.dart';
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/kernel/src/theme/loader.dart';
import 'package:clide_app/kernel/src/tray.dart';
import 'package:flutter/widgets.dart';
/// Aggregated kernel services. Feature code that runs outside a
/// BuildContext (extensions, background tasks) holds a [KernelServices]
/// ref directly; widget code reaches them via [ClideKernel.of].
class KernelServices {
KernelServices({
required this.log,
required this.settings,
required this.events,
required this.ipc,
required this.theme,
required this.i18n,
required this.panels,
required this.arrangement,
required this.commands,
required this.palette,
required this.keybindings,
required this.clipboard,
required this.files,
required this.notify,
required this.dialog,
required this.tray,
required this.secrets,
required this.os,
required this.net,
required this.focus,
required this.project,
required this.extensions,
});
final Logger log;
final SettingsStore settings;
final EventBus events;
final DaemonClient ipc;
final ThemeController theme;
final I18n i18n;
final PanelRegistry panels;
final LayoutArrangement arrangement;
final CommandRegistry commands;
final PaletteController palette;
final KeybindingResolver keybindings;
final ClideClipboard clipboard;
final FileServices files;
final Notifications notify;
final DialogRouter dialog;
final TrayRegistry tray;
final SecretsVault secrets;
final OsBridge os;
final NetworkStatus net;
final FocusTracker focus;
final ProjectManager project;
final ExtensionManager extensions;
static Future<KernelServices> boot({
required Directory appDir,
required List<ThemeDefinition> bundledThemes,
required CatalogLoader i18nLoader,
List<String> preloadNamespaces = const [],
Locale defaultLocale = const Locale('en', 'US'),
Locale? initialLocale,
List<Locale> availableLocales = const [Locale('en', 'US')],
String? socketPath,
DaemonClient Function(Logger, EventBus)? daemonClientFactory,
bool autoStartDaemonClient = true,
}) async {
final log = Logger();
final events = EventBus();
final settings = SettingsStore(appDir: appDir);
await settings.load();
final i18n = I18n(
loader: i18nLoader,
log: log,
defaultLocale: defaultLocale,
initialLocale: initialLocale,
availableLocales: availableLocales,
);
for (final ns in preloadNamespaces) {
await i18n.ensureNamespaceLoaded(ns);
}
final theme = ThemeController(bundled: bundledThemes);
final panels = PanelRegistry();
final arrangement = LayoutArrangement();
final commands = CommandRegistry();
final keybindings = KeybindingResolver();
final palette = PaletteController(commands);
final clipboard = ClideClipboard();
final files = FileServices(events);
final notify = Notifications();
final dialog = DialogRouter();
final tray = TrayRegistry();
final secrets = SecretsVault();
final os = OsBridge(log: log, events: events);
final net = NetworkStatus();
final focus = FocusTracker();
final project = ProjectManager(
log: log,
events: events,
settings: settings,
);
final ipc = daemonClientFactory != null
? daemonClientFactory(log, events)
: DaemonClient(
socketPath: socketPath ?? defaultSocketPath(),
log: log,
events: events,
);
final extensions = ExtensionManager(
log: log,
events: events,
settings: settings,
theme: theme,
i18n: i18n,
panels: panels,
arrangement: arrangement,
commands: commands,
palette: palette,
keybindings: keybindings,
clipboard: clipboard,
files: files,
notify: notify,
dialog: dialog,
tray: tray,
secrets: secrets,
os: os,
net: net,
focus: focus,
project: project,
ipc: ipc,
);
if (autoStartDaemonClient) {
unawaited(ipc.start());
}
return KernelServices(
log: log,
settings: settings,
events: events,
ipc: ipc,
theme: theme,
i18n: i18n,
panels: panels,
arrangement: arrangement,
commands: commands,
palette: palette,
keybindings: keybindings,
clipboard: clipboard,
files: files,
notify: notify,
dialog: dialog,
tray: tray,
secrets: secrets,
os: os,
net: net,
focus: focus,
project: project,
extensions: extensions,
);
}
Future<void> dispose() async {
await ipc.stop();
ipc.dispose();
settings.dispose();
theme.dispose();
panels.dispose();
arrangement.dispose();
commands.dispose();
palette.dispose();
i18n.dispose();
notify.dispose();
dialog.dispose();
tray.dispose();
net.dispose();
focus.dispose();
project.dispose();
extensions.dispose();
await log.dispose();
await events.dispose();
}
}
class ClideKernel extends InheritedWidget {
const ClideKernel({
super.key,
required this.services,
required super.child,
});
final KernelServices services;
static KernelServices of(BuildContext context) {
final w = context.dependOnInheritedWidgetOfExactType<ClideKernel>();
if (w == null) {
throw FlutterError(
'ClideKernel.of() called with a context that is not a descendant of a ClideKernel.');
}
return w.services;
}
@override
bool updateShouldNotify(ClideKernel oldWidget) =>
services != oldWidget.services;
}
+61
View File
@@ -0,0 +1,61 @@
import 'dart:async';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/events/types.dart';
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
class FilesDropped extends ClideEvent {
const FilesDropped({required this.paths, required this.slot});
final List<String> paths;
final SlotId slot;
@override
String get subsystem => 'files';
@override
String get kind => 'dropped';
@override
Map<String, Object?> payload() => {
'paths': paths,
'slot': slot.value,
};
}
/// Tier-0 stub for file pickers and drop targets.
///
/// Flutter desktop has no native picker API without a dep; rather than
/// add one now, pickOpen/pickSave/pickDirectory throw UnimplementedError
/// and the drop target is a no-op until we wire it through the
/// platform channel. This lets the rest of the kernel compile and makes
/// the service surface real.
class FileServices {
FileServices(this._events);
final EventBus _events;
Future<List<String>> pickOpen({
List<String> extensions = const [],
bool multiple = false,
}) async {
throw UnimplementedError('pickOpen — wired in a later tier');
}
Future<String?> pickSave({
String? defaultName,
List<String> extensions = const [],
}) async {
throw UnimplementedError('pickSave — wired in a later tier');
}
Future<String?> pickDirectory() async {
throw UnimplementedError('pickDirectory — wired in a later tier');
}
/// Invoked by the platform drop-target wiring when files land on a
/// slot. Emits a [FilesDropped] event; the slot-owning extension
/// subscribes.
@visibleForTesting
void notifyDropped({required List<String> paths, required SlotId slot}) {
_events.emit(FilesDropped(paths: paths, slot: slot));
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:clide_app/kernel/src/panels/slot_id.dart';
import 'package:flutter/foundation.dart';
/// Tracks the currently focused contribution (tab id + slot). Backs
/// `clide active`; extensions that need "which tab does the user care
/// about right now?" read from here instead of poking Flutter's
/// FocusScope directly.
class FocusTracker extends ChangeNotifier {
SlotId? _slot;
String? _contributionId;
SlotId? get activeSlot => _slot;
String? get activeContributionId => _contributionId;
void setActive({required SlotId slot, required String contributionId}) {
if (_slot == slot && _contributionId == contributionId) return;
_slot = slot;
_contributionId = contributionId;
notifyListeners();
}
void clear() {
if (_slot == null && _contributionId == null) return;
_slot = null;
_contributionId = null;
notifyListeners();
}
}
@@ -0,0 +1,4 @@
{
"command.reset": { "translation": "Layout: Reset to Classic" },
"preset.classic": { "translation": "Classic" }
}
@@ -0,0 +1,6 @@
{
"connected": { "translation": "connected" },
"connected.hint": { "translation": "clide daemon is reachable over the local socket" },
"disconnected": { "translation": "disconnected" },
"disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" }
}
@@ -0,0 +1,7 @@
{
"command.pick": { "translation": "Theme: Pick…" },
"modal.title": { "translation": "Select theme" },
"modal.cancel": { "translation": "Cancel" },
"modal.cancel.hint": { "translation": "Close the theme picker without changing the current theme" },
"row.select.hint": { "translation": "Activate this theme" }
}
@@ -0,0 +1,7 @@
{
"title": { "translation": "clide" },
"subtitle": { "translation": "Flutter desktop IDE for Claude Code" },
"open-project": { "translation": "Open project" },
"open-project.hint": { "translation": "Pick a git repository to open as the workspace" },
"tab.title": { "translation": "Welcome" }
}
@@ -0,0 +1,93 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:clide_app/kernel/src/i18n/fallback_chain.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Loads catalog JSON for a given `(namespace, locale)` pair.
///
/// The file format (mirrors fframe verbatim):
/// `{namespace}_{lang}_{country}.json` — or `{namespace}_{lang}.json`
/// Content: `{ "key": { "translation": "...", ...extras }, ... }`.
///
/// Two reader shapes:
/// * Asset bundle (built-in catalogs shipped under `lib/kernel/src/i18n/catalog/`).
/// * Filesystem (third-party extensions under `~/.clide/extensions/<id>/`).
///
/// Missing files return an empty map — not an error. The fallback chain
/// walker handles "nothing for this locale" by trying the next one.
abstract class CatalogLoader {
Future<Map<String, Object?>> load(String namespace, Locale locale);
}
class AssetCatalogLoader implements CatalogLoader {
AssetCatalogLoader({required this.bundle, this.rootDir = _defaultRoot});
final AssetBundle bundle;
final String rootDir;
static const String _defaultRoot = 'lib/kernel/src/i18n/catalog';
@override
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
final suffix = FallbackChain.filenameSuffix(locale);
final path = '$rootDir/${namespace}_$suffix.json';
try {
final text = await bundle.loadString(path);
if (text.trim().isEmpty) return const {};
final obj = jsonDecode(text);
if (obj is Map) return obj.cast<String, Object?>();
return const {};
} on FlutterError {
// Asset missing. Return empty map; fallback chain handles the miss.
return const {};
} on FormatException {
return const {};
}
}
}
class FileCatalogLoader implements CatalogLoader {
const FileCatalogLoader({required this.rootDir});
final Directory rootDir;
@override
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
final suffix = FallbackChain.filenameSuffix(locale);
final f = File('${rootDir.path}/${namespace}_$suffix.json');
if (!await f.exists()) return const {};
try {
final text = await f.readAsString();
if (text.trim().isEmpty) return const {};
final obj = jsonDecode(text);
if (obj is Map) return obj.cast<String, Object?>();
} on FormatException {
// malformed — return empty; caller will fall back.
}
return const {};
}
}
/// Preloaded-in-memory loader for tests and synthesized catalogs.
class InMemoryCatalogLoader implements CatalogLoader {
InMemoryCatalogLoader(this._map);
final Map<String, Map<Locale, Map<String, Object?>>> _map;
@override
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
final byNs = _map[namespace];
if (byNs == null) return const {};
// match by canonical comparison so Locale("en") == registered Locale("en")
for (final entry in byNs.entries) {
if (_eq(entry.key, locale)) return entry.value;
}
return const {};
}
static bool _eq(Locale a, Locale b) =>
a.languageCode == b.languageCode && a.countryCode == b.countryCode;
}
@@ -0,0 +1,55 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
/// Resolves the ordered list of locales to try when looking up a key.
///
/// Order, starting from the current locale:
/// 1. exact (language + country) — e.g. nl_NL
/// 2. language-only — e.g. nl
/// 3. default language + country — e.g. en_US
/// 4. default language-only — e.g. en
///
/// Duplicates are removed while preserving order. `null` country code is
/// canonicalized by omitting it (not empty string) so equality works.
@immutable
class FallbackChain {
const FallbackChain({
required this.current,
required this.defaultLocale,
});
final Locale current;
final Locale defaultLocale;
List<Locale> resolve() {
final out = <Locale>[];
for (final l in [
current,
Locale(current.languageCode),
defaultLocale,
Locale(defaultLocale.languageCode),
]) {
final canon = _canon(l);
if (!out.any((e) => _canon(e) == canon)) {
out.add(l);
}
}
return out;
}
static String _canon(Locale l) {
final country = l.countryCode;
if (country == null || country.isEmpty) return l.languageCode;
return '${l.languageCode}_$country';
}
/// Canonical filename suffix for a locale, matching fframe: `en_us`
/// (lowercase, country only when present).
static String filenameSuffix(Locale l) {
final lang = l.languageCode.toLowerCase();
final country = l.countryCode?.toLowerCase();
if (country == null || country.isEmpty) return lang;
return '${lang}_$country';
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:ui';
import 'package:clide_app/kernel/src/i18n/catalog_loader.dart';
import 'package:clide_app/kernel/src/i18n/fallback_chain.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:flutter/foundation.dart';
@immutable
class I18nReplacer {
const I18nReplacer({required this.from, required this.replace});
final String from;
final String replace;
}
/// Text-driven i18n — fframe-style. Keys are strings, lookups are by
/// `(namespace, key)`, missing keys fall back through a locale chain
/// and finally to the caller-supplied placeholder.
///
/// Singleton-per-kernel: `kernel.i18n`. Extensions write:
/// final t = ctx.i18n;
/// t.string('key', placeholder: '...', namespace: ext.id);
class I18n extends ChangeNotifier {
I18n({
required this.loader,
required this.log,
required Locale defaultLocale,
Locale? initialLocale,
List<Locale> availableLocales = const [Locale('en', 'US')],
}) : _defaultLocale = defaultLocale,
_current = initialLocale ?? defaultLocale,
_available = List<Locale>.unmodifiable(availableLocales);
final CatalogLoader loader;
final Logger log;
final Locale _defaultLocale;
Locale _current;
final List<Locale> _available;
/// namespace -> locale -> flat key map
final Map<String, Map<Locale, Map<String, Object?>>> _cache = {};
/// Keys we've already warned about for a given (namespace, key, locale).
/// Keeps the log quiet across repeated lookups.
final Set<String> _warnedMisses = {};
Locale get currentLocale => _current;
Locale get defaultLocale => _defaultLocale;
List<Locale> get availableLocales => _available;
/// Register a catalog that was loaded outside of [loader] — e.g. by the
/// ExtensionManager when a third-party extension activates.
void registerCatalog(
String namespace,
Locale locale,
Map<String, Object?> catalog,
) {
_cache.putIfAbsent(
namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
notifyListeners();
}
/// Remove every entry for a namespace (extension deactivated).
void unregisterCatalog(String namespace) {
if (_cache.remove(namespace) != null) {
notifyListeners();
}
}
/// Set the current locale and reload every already-cached namespace
/// for the new chain. Listeners fire once at the end.
Future<void> setLocale(Locale locale) async {
if (locale == _current) return;
_current = locale;
_warnedMisses.clear();
for (final ns in _cache.keys.toList()) {
await _ensureLoaded(ns);
}
notifyListeners();
}
/// Eagerly load a namespace across the whole fallback chain. Safe to
/// call more than once (subsequent calls only fill missing locales).
Future<void> ensureNamespaceLoaded(String namespace) async {
await _ensureLoaded(namespace);
}
Future<void> _ensureLoaded(String namespace) async {
final byLocale = _cache.putIfAbsent(
namespace,
() => <Locale, Map<String, Object?>>{},
);
final chain = FallbackChain(
current: _current,
defaultLocale: _defaultLocale,
).resolve();
for (final l in chain) {
if (byLocale.containsKey(l)) continue;
byLocale[l] = await loader.load(namespace, l);
}
}
/// Look up a key, walking the locale fallback chain. Returns the
/// placeholder if nothing hits; returns the key itself when placeholder
/// is null (developer fallback — keys are more useful than blanks).
String string(
String key, {
required String namespace,
String? placeholder,
}) {
final byLocale = _cache[namespace];
if (byLocale == null) {
_warnOnce(
'$namespace::MISSING_NAMESPACE::$key',
'i18n: namespace not registered: $namespace (key: $key)',
);
return placeholder ?? key;
}
final chain = FallbackChain(
current: _current,
defaultLocale: _defaultLocale,
).resolve();
for (final locale in chain) {
final catalog = byLocale[locale];
if (catalog == null) continue;
final hit = _extract(catalog, key);
if (hit != null) return hit;
}
_warnOnce(
'$namespace::${_current.languageCode}::$key',
'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})',
);
return placeholder ?? key;
}
/// [string] + naive `replaceAll` interpolation per replacer.
/// Matches fframe: replacers whose [from] isn't present are silent no-ops.
String interpolated(
String key, {
required String namespace,
String? placeholder,
List<I18nReplacer> replacers = const [],
}) {
var out = string(key, namespace: namespace, placeholder: placeholder);
for (final r in replacers) {
out = out.replaceAll(r.from, r.replace);
}
return out;
}
/// Walks fframe's nested shape: `{ "translation": "..." }`. If the
/// value is a plain string we accept that too (forward-compat).
String? _extract(Map<String, Object?> catalog, String key) {
final v = catalog[key];
if (v == null) return null;
if (v is String) return v;
if (v is Map && v['translation'] is String) {
return v['translation'] as String;
}
return null;
}
void _warnOnce(String dedupeKey, String message) {
if (_warnedMisses.add(dedupeKey)) {
log.warn('i18n', message);
}
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:clide/clide.dart';
import 'package:clide_app/kernel/src/events/bus.dart';
import 'package:clide_app/kernel/src/events/types.dart';
import 'package:clide_app/kernel/src/log.dart';
import 'package:flutter/foundation.dart';
class DaemonClient extends ChangeNotifier {
DaemonClient({
required this.socketPath,
required Logger log,
required EventBus events,
}) : _log = log,
_events = events;
final String socketPath;
final Logger _log;
final EventBus _events;
Socket? _socket;
bool _connected = false;
bool _disposed = false;
Timer? _reconnectTimer;
Duration _backoff = const Duration(milliseconds: 200);
int _nextId = 0;
final Map<String, Completer<IpcResponse>> _pending = {};
bool get isConnected => _connected;
Future<void> start() async {
_disposed = false;
await _connect();
}
Future<void> stop() async {
_disposed = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
final s = _socket;
_socket = null;
await s?.close();
_failPending('client stopped');
_setConnected(false);
}
Future<IpcResponse> request(
String cmd, {
Map<String, Object?> args = const {},
}) {
if (!_connected || _socket == null) {
return Future.value(IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: 'daemon not connected',
hint: 'is `clide --daemon` running?',
),
));
}
final id = '${_nextId++}';
final completer = Completer<IpcResponse>();
_pending[id] = completer;
final req = IpcRequest(id: id, cmd: cmd, args: args);
_socket!.writeln(req.encode());
return completer.future;
}
Future<void> _connect() async {
if (_disposed) return;
try {
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
final socket = await Socket.connect(addr, 0);
_socket = socket;
_backoff = const Duration(milliseconds: 200);
_setConnected(true);
_log.info('ipc', 'connected to $socketPath');
socket
.cast<List<int>>()
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(
_handleLine,
onDone: _handleDisconnect,
onError: (Object e) {
_log.warn('ipc', 'socket error', error: e);
_handleDisconnect();
},
cancelOnError: true,
);
} catch (e) {
_log.debug(
'ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
_scheduleReconnect();
}
}
void _handleLine(String line) {
if (line.isEmpty) return;
try {
final msg = IpcMessage.decode(line);
switch (msg) {
case IpcResponse r:
final c = _pending.remove(r.id);
if (c != null && !c.isCompleted) c.complete(r);
case IpcEvent e:
_events.emit(DaemonEvent(
subsystem: e.subsystem,
kind: e.kind,
data: e.data,
ts: e.timestamp,
));
case IpcRequest _:
_log.warn('ipc', 'daemon sent a request — unexpected');
}
} on FormatException catch (e) {
_log.warn('ipc', 'bad line from daemon: $e');
}
}
void _handleDisconnect() {
_socket = null;
_failPending('daemon disconnected');
_setConnected(false);
_scheduleReconnect();
}
void _failPending(String reason) {
final err = IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: reason,
);
for (final entry in _pending.entries) {
if (!entry.value.isCompleted) {
entry.value.complete(IpcResponse.err(id: entry.key, error: err));
}
}
_pending.clear();
}
void _scheduleReconnect() {
if (_disposed) return;
_reconnectTimer?.cancel();
_reconnectTimer = Timer(_backoff, _connect);
_backoff = Duration(
milliseconds: math.min(_backoff.inMilliseconds * 2, 5000),
);
}
void _setConnected(bool v) {
if (_connected == v) return;
_connected = v;
_events.emit(DaemonConnectionChanged(connected: v));
notifyListeners();
}
@override
void dispose() {
_disposed = true;
_reconnectTimer?.cancel();
unawaited(_socket?.close());
_socket = null;
_failPending('client disposed');
super.dispose();
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:io';
enum LogLevel { trace, debug, info, warn, error }
class LogRecord {
LogRecord({
required this.level,
required this.source,
required this.message,
required this.timestamp,
this.error,
this.stackTrace,
});
final LogLevel level;
final String source;
final String message;
final DateTime timestamp;
final Object? error;
final StackTrace? stackTrace;
@override
String toString() {
final lv = level.name.toUpperCase().padRight(5);
final buf =
StringBuffer('${timestamp.toIso8601String()} $lv [$source] $message');
if (error != null) buf.write(' | error=$error');
return buf.toString();
}
}
typedef LogSink = void Function(LogRecord);
class Logger {
Logger({this.minLevel = LogLevel.info, List<LogSink>? sinks})
: _sinks = List<LogSink>.from(sinks ?? <LogSink>[stderrSink]);
LogLevel minLevel;
final List<LogSink> _sinks;
final StreamController<LogRecord> _stream =
StreamController<LogRecord>.broadcast();
Stream<LogRecord> get records => _stream.stream;
void addSink(LogSink sink) => _sinks.add(sink);
void trace(String source, String message) =>
_emit(LogLevel.trace, source, message);
void debug(String source, String message) =>
_emit(LogLevel.debug, source, message);
void info(String source, String message) =>
_emit(LogLevel.info, source, message);
void warn(String source, String message, {Object? error}) =>
_emit(LogLevel.warn, source, message, error: error);
void error(String source, String message,
{Object? error, StackTrace? stackTrace}) =>
_emit(LogLevel.error, source, message,
error: error, stackTrace: stackTrace);
void _emit(LogLevel level, String source, String message,
{Object? error, StackTrace? stackTrace}) {
if (level.index < minLevel.index) return;
final rec = LogRecord(
level: level,
source: source,
message: message,
timestamp: DateTime.now().toUtc(),
error: error,
stackTrace: stackTrace,
);
for (final sink in _sinks) {
try {
sink(rec);
} catch (_) {
// a broken sink must not kill logging
}
}
if (!_stream.isClosed) _stream.add(rec);
}
Future<void> dispose() => _stream.close();
}
void stderrSink(LogRecord r) {
stderr.writeln(r);
if (r.stackTrace != null) stderr.writeln(r.stackTrace);
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
enum Reachability { online, offline, metered }
/// Tier-0 stub reachability observable. Hardcoded to `online`; real
/// detection via a platform channel lands in a later tier.
class NetworkStatus extends ChangeNotifier {
Reachability _state = Reachability.online;
Reachability get state => _state;
bool get isOnline => _state != Reachability.offline;
@visibleForTesting
void setState(Reachability r) {
if (_state == r) return;
_state = r;
notifyListeners();
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
enum NotificationLevel { info, warning, error, success }
@immutable
class ClideNotification {
ClideNotification({
required this.id,
required this.level,
required this.message,
this.title,
this.duration = const Duration(seconds: 4),
}) : createdAt = DateTime.now().toUtc();
final String id;
final NotificationLevel level;
final String? title;
final String message;
final DateTime createdAt;
final Duration duration;
}
class Notifications extends ChangeNotifier {
final List<ClideNotification> _active = [];
final Map<String, Timer> _timers = {};
int _seq = 0;
List<ClideNotification> get active => List.unmodifiable(_active);
void info(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.info, message, title: title, duration: duration);
void warn(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.warning, message,
title: title, duration: duration);
void error(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.error, message, title: title, duration: duration);
void success(String message, {String? title, Duration? duration}) =>
_push(NotificationLevel.success, message,
title: title, duration: duration);
void dismiss(String id) {
_timers.remove(id)?.cancel();
final before = _active.length;
_active.removeWhere((n) => n.id == id);
if (_active.length != before) notifyListeners();
}
void _push(
NotificationLevel level,
String message, {
String? title,
Duration? duration,
}) {
final id = 'n${_seq++}';
final n = ClideNotification(
id: id,
level: level,
message: message,
title: title,
duration: duration ?? const Duration(seconds: 4),
);
_active.add(n);
_timers[id] = Timer(n.duration, () => dismiss(id));
notifyListeners();
}
@override
void dispose() {
for (final t in _timers.values) {
t.cancel();
}
_timers.clear();
super.dispose();
}
}

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