diff --git a/CHANGELOG.md b/CHANGELOG.md index 316cdc21..7349af89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,24 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- Shared pane widgets under `app/lib/widgets/`: `ClidePtyView` wraps + `xterm.dart` with clide-theme token bindings, JetBrains Mono as the + face, and a Semantics live-region wrapper; `ClidePaneChrome` is the + reusable title strip + optional close button. Consumers of the new + widgets (`builtin.terminal`, `builtin.claude`) drive the xterm + `Terminal` model and route bytes through IPC `pane.write` / + `pane.output` events themselves — the widgets are rendering only, + no IPC coupling. + +- `xterm: 4.0.0` Dart dependency on the Flutter app — MIT, listed in + `licenses.yaml` per D-042. Hand-rolling a VT100 / xterm / truecolour + parser + renderer would be weeks for no fidelity win. + +- `Q-023` — open question on SSH-remote development (run clide against + a workspace on another host). Local-first stays the Tier-1 target; + this records the constraint so the daemon / IPC / extension seams + don't unknowingly accrete local-only assumptions. + - IPC `pane` subsystem in the daemon (per D-006). Commands: `pane.spawn | list | focus | close | write | resize | tail`. Events: `pane.spawned`, `pane.output` (base64-framed), `pane.exit`, diff --git a/app/assets/licenses.yaml b/app/assets/licenses.yaml index 414f6f20..731a5fa7 100644 --- a/app/assets/licenses.yaml +++ b/app/assets/licenses.yaml @@ -75,6 +75,18 @@ dependencies: YAML parser for theme files and extension manifests. Justified exception to prefer-zero-deps; Dart-team maintained. + - name: xterm + kind: dart-package + version: "4.0.0" + homepage: https://pub.dev/packages/xterm + license: MIT + purpose: >- + Flutter-native terminal emulator (ANSI / xterm / truecolor + parser + renderer). Powers every pane that renders a PTY — + general terminal, Claude, diff views running shell commands. + Writing a vt100 / ANSI parser is weeks of work for no fidelity + gain. + - name: ffi kind: dart-package version: "2.1.3" diff --git a/app/lib/widgets/src/clide_pane_chrome.dart b/app/lib/widgets/src/clide_pane_chrome.dart new file mode 100644 index 00000000..35b5b8b7 --- /dev/null +++ b/app/lib/widgets/src/clide_pane_chrome.dart @@ -0,0 +1,182 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:flutter/widgets.dart'; + +import 'clide_divider.dart'; +import 'clide_icon.dart'; +import 'clide_text.dart'; +import 'icons/x.dart'; + +/// Shared chrome for any pane that sits in a tab or split: a title +/// strip at the top, an optional close button, and the pane body +/// underneath. `ClidePtyView`, diff views, canvas tabs, graph tabs — +/// everything with a "pane header" surface reuses this. +class ClidePaneChrome extends StatelessWidget { + const ClidePaneChrome({ + super.key, + required this.title, + required this.child, + this.subtitle, + this.leading, + this.onClose, + this.trailing, + }); + + /// Primary label in the header — typically the pane kind + an + /// abbreviated path / session name (`terminal — ~/clide`). + final String title; + + /// Optional secondary line (cwd hint, session id, status). + final String? subtitle; + + /// Icon or badge drawn before the title. + final Widget? leading; + + /// Main pane content. + final Widget child; + + /// If provided, renders an `x` close button on the right. Primary + /// Claude panes deliberately pass `null` so the user can't hide the + /// primary (D-041). + final VoidCallback? onClose; + + /// Extra trailing widgets (status indicator, menu button, etc.). + /// Drawn before the close button when both are present. + final List? trailing; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return ColoredBox( + color: tokens.panelBackground, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Header( + title: title, + subtitle: subtitle, + leading: leading, + onClose: onClose, + trailing: trailing, + ), + const ClideDivider(), + Expanded(child: child), + ], + ), + ); + } +} + +class _CloseButton extends StatefulWidget { + const _CloseButton({required this.onPressed}); + final VoidCallback onPressed; + + @override + State<_CloseButton> createState() => _CloseButtonState(); +} + +class _CloseButtonState extends State<_CloseButton> { + bool _hover = false; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + button: true, + label: 'Close pane', + onTap: widget.onPressed, + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hover = true), + onExit: (_) => setState(() => _hover = false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + child: Container( + width: 20, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _hover ? tokens.tabCloseHover : null, + ), + child: ClideIcon( + const CloseIcon(), + size: 10, + color: tokens.panelHeaderForeground, + ), + ), + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + const _Header({ + required this.title, + required this.subtitle, + required this.leading, + required this.onClose, + required this.trailing, + }); + + final String title; + final String? subtitle; + final Widget? leading; + final VoidCallback? onClose; + final List? trailing; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + container: true, + explicitChildNodes: true, + label: 'pane header: $title', + child: ColoredBox( + color: tokens.panelHeader, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 6)], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ClideText( + title, + fontSize: 12, + color: tokens.panelHeaderForeground, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) + ClideText( + subtitle!, + fontSize: 11, + muted: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (trailing != null) ...trailing!.map( + (w) => Padding( + padding: const EdgeInsets.only(left: 6), + child: w, + ), + ), + if (onClose != null) + Padding( + padding: const EdgeInsets.only(left: 6), + child: _CloseButton(onPressed: onClose!), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/widgets/src/clide_pty_view.dart b/app/lib/widgets/src/clide_pty_view.dart new file mode 100644 index 00000000..e11f6d05 --- /dev/null +++ b/app/lib/widgets/src/clide_pty_view.dart @@ -0,0 +1,105 @@ +import 'package:clide_app/kernel/src/theme/controller.dart'; +import 'package:clide_app/kernel/src/theme/tokens.dart'; +import 'package:clide_app/widgets/src/typography.dart'; +import 'package:flutter/widgets.dart'; +import 'package:xterm/xterm.dart'; + +/// Theme-aware terminal view. Wraps xterm.dart's [TerminalView] with +/// clide token bindings, JetBrainsMono as the face, and a Semantics +/// wrapper that exposes the pane as a live region with the terminal's +/// aria label. +/// +/// Callers provide the [Terminal] model; hooking its `onOutput` to an +/// IPC `pane.write` call and feeding `pane.output` event bytes into +/// `terminal.write()` is the consumer's job (typically a builtin +/// extension — see `builtin.terminal` / `builtin.claude`). +class ClidePtyView extends StatelessWidget { + const ClidePtyView({ + super.key, + required this.terminal, + this.label, + this.focusNode, + this.autofocus = false, + this.fontSize = 13, + }); + + final Terminal terminal; + + /// A11y label — typically the pane title ("terminal — ~/repo", + /// "claude — primary", …). + final String? label; + + final FocusNode? focusNode; + final bool autofocus; + final double fontSize; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return Semantics( + label: label, + textField: true, + multiline: true, + focusable: true, + liveRegion: true, + child: ColoredBox( + color: tokens.panelBackground, + child: TerminalView( + terminal, + focusNode: focusNode, + autofocus: autofocus, + theme: _buildTheme(tokens), + textStyle: TerminalStyle( + fontSize: fontSize, + fontFamily: clideMonoFamily, + fontFamilyFallback: clideMonoFamilyFallback, + ), + padding: const EdgeInsets.all(8), + backgroundOpacity: 1, + cursorType: TerminalCursorType.block, + ), + ), + ); + } +} + +/// Derive an xterm [TerminalTheme] from our surface tokens. +/// +/// Foreground / background pull from the editor tokens so the terminal +/// visually matches the rest of the IDE. The 16-color ANSI palette is +/// chosen to read well on our current two bundled themes +/// (summer-night + whatever else ships); a future pass lets themes +/// override the palette directly in their YAML. +TerminalTheme _buildTheme(SurfaceTokens t) { + // Selection tint derived from the focus accent at 40% alpha — no + // dedicated token yet; revisit when the theme layer grows an + // editor.selection.* token family. + final selection = t.globalFocus.withAlpha(0x66); + return TerminalTheme( + cursor: t.globalForeground, + selection: selection, + foreground: t.globalForeground, + background: t.panelBackground, + // ANSI palette — reasonable defaults tuned for dark themes. The + // bright variants are the same hue with higher luminance. + black: const Color(0xFF1b1d23), + red: const Color(0xFFe06c75), + green: const Color(0xFF98c379), + yellow: const Color(0xFFe5c07b), + blue: const Color(0xFF61afef), + magenta: const Color(0xFFc678dd), + cyan: const Color(0xFF56b6c2), + white: const Color(0xFFabb2bf), + brightBlack: const Color(0xFF5c6370), + brightRed: const Color(0xFFff7b85), + brightGreen: const Color(0xFFabd486), + brightYellow: const Color(0xFFffd89a), + brightBlue: const Color(0xFF82c5ff), + brightMagenta: const Color(0xFFdb8fe4), + brightCyan: const Color(0xFF6fcbd6), + brightWhite: const Color(0xFFffffff), + searchHitBackground: const Color(0xFFffeb8c), + searchHitBackgroundCurrent: const Color(0xFFffd54a), + searchHitForeground: const Color(0xFF1b1d23), + ); +} diff --git a/app/lib/widgets/widgets.dart b/app/lib/widgets/widgets.dart index 97532a96..fb3f18a0 100644 --- a/app/lib/widgets/widgets.dart +++ b/app/lib/widgets/widgets.dart @@ -8,6 +8,8 @@ library; export 'src/clide_button.dart'; export 'src/clide_divider.dart'; export 'src/clide_icon.dart'; +export 'src/clide_pane_chrome.dart'; +export 'src/clide_pty_view.dart'; export 'src/clide_scrollbar.dart'; export 'src/clide_surface.dart'; export 'src/clide_tab_bar.dart'; diff --git a/app/pubspec.lock b/app/pubspec.lock index 8ccb9af9..f8e14420 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -56,6 +56,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" equatable: dependency: transitive description: @@ -72,6 +80,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" file: dependency: transitive description: @@ -201,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.5" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" sky_engine: dependency: transitive description: flutter @@ -262,6 +286,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: @@ -286,6 +318,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + xterm: + dependency: "direct main" + description: + name: xterm + sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" + url: "https://pub.dev" + source: hosted + version: "4.0.0" yaml: dependency: "direct main" description: @@ -294,6 +334,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + zmodem: + dependency: transitive + description: + name: zmodem + sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" + url: "https://pub.dev" + source: hosted + version: "0.0.6" sdks: dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.32.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 35359f4f..59ef1843 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -31,6 +31,12 @@ dependencies: # maintained, used for theme files and extension manifests. yaml: 3.1.3 + # xterm.dart — Flutter-native terminal emulator (ANSI / xterm / truecolor + # parser + renderer). Writing a vt100/ANSI parser that handles everything + # tmux / neovim / claude emit would be weeks of work for no fidelity + # win. Listed in licenses.yaml per D-042. MIT. + xterm: 4.0.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/app/test/widgets/clide_pane_chrome_test.dart b/app/test/widgets/clide_pane_chrome_test.dart new file mode 100644 index 00000000..dd0b2c03 --- /dev/null +++ b/app/test/widgets/clide_pane_chrome_test.dart @@ -0,0 +1,65 @@ +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../helpers/kernel_fixture.dart'; +import '../helpers/widget_harness.dart'; + +void main() { + group('ClidePaneChrome', () { + late KernelFixture f; + setUp(() async { + f = await KernelFixture.create(); + }); + tearDown(() => f.dispose()); + + testWidgets('renders title + subtitle', (tester) async { + await tester.pumpWidget( + harness( + f, + const ClidePaneChrome( + title: 'terminal — ~/clide', + subtitle: 'bash · 80×24', + child: SizedBox.shrink(), + ), + ), + ); + expect(find.text('terminal — ~/clide'), findsOneWidget); + expect(find.text('bash · 80×24'), findsOneWidget); + }); + + testWidgets('no close button when onClose is null', (tester) async { + await tester.pumpWidget( + harness( + f, + const ClidePaneChrome( + title: 'primary claude', + child: SizedBox.shrink(), + ), + ), + ); + final handle = tester.ensureSemantics(); + expect(find.bySemanticsLabel('Close pane'), findsNothing); + handle.dispose(); + }); + + testWidgets('close button invokes onClose when present', (tester) async { + var pressed = false; + await tester.pumpWidget( + harness( + f, + ClidePaneChrome( + title: 'terminal', + onClose: () => pressed = true, + child: const SizedBox.shrink(), + ), + ), + ); + final handle = tester.ensureSemantics(); + expect(find.bySemanticsLabel('Close pane'), findsOneWidget); + await tester.tap(find.bySemanticsLabel('Close pane')); + expect(pressed, isTrue); + handle.dispose(); + }); + }); +} diff --git a/decisions/questions-architecture.md b/decisions/questions-architecture.md index 15039b52..7aac2b4e 100644 --- a/decisions/questions-architecture.md +++ b/decisions/questions-architecture.md @@ -53,6 +53,12 @@ ticket persistence. - **Context:** User is leaning (A). This plan assumes (A) without committing. If (A) doesn't land, [D-040](process.md#d-040-python-stopgap-under-toolsscriptsplan)'s sunset condition changes. Gates all tooling work. Integration constraints that shape this question are captured in [D-039](process.md#d-039-planning-tooling-lives-in-pql) / [R-009](rejected.md#r-009-port-planning-tooling-into-clide). - **Source:** 2026-04-21 planning. +### Q-023: SSH-remote development — run clide against a remote workspace +- **Status:** Open +- **Question:** Clide today assumes the workspace, the daemon, and the Flutter UI all run on the same machine. A growing class of users edits on remote systems (build servers, GPU boxes, cloud dev environments). What's the architecture for "open repo on host-B from UI on host-A"? Two shapes: (A) daemon-on-remote — clide's Dart daemon runs on the remote; the app talks to it over an SSH-tunnelled unix socket or a dedicated TCP socket (mTLS?), pty/process/filesystem work stays server-side; local app is pure UI. (B) filesystem-mounted — remote mounted via sshfs/9p/rclone, daemon runs locally against the mount; simpler but every fs op + git call crosses the network, and PTYs get complicated (local shell on remote filesystem? ssh-exec per command?). (A) matches VS Code Remote / JetBrains Gateway; (B) matches nothing load-bearing. Sub-questions either way: auth (ssh-agent? per-project keys? OIDC?), tmux / Claude session persistence semantics (does primary-per-repo re-key on host + repo?), multi-host identity in `.pql/pql.db`, latency tolerance for the event stream, re-sync on disconnect. +- **Context:** Surfaced 2026-04-22 during Tier-1 planning. Not a Tier 1 concern — terminal + Claude panes land local-first — but the daemon/IPC seam decisions (notably `D-005` and `D-006`) constrain the future answer. Worth scoping before Tier 6 (extension API) so third-party extensions don't accrue assumptions the remote path would have to unwind. +- **Source:** 2026-04-22 planning (user-raised). + ### Q-022: Ticket persistence strategy - **Status:** Open - **Question:** Once [Q-021](#q-021-pql-absorbs-planning-vs-keeps-separate) resolves in favour of (A), how do tickets handle shared team state? (1) Never commit (per-dev, ephemeral — works for solo). (2) Commit on milestone (settled-reach's sprint-close pattern — kanban has no natural equivalent, `release` or `tier-cut` is the closest). (3) Markdown mirror — every mutation writes `tickets/T-NNN.md` alongside SQLite; git-legible authoritative record; DB is rebuildable. (3) is probably the eventual answer.