add shared pane widgets + Q-023 ssh-remote question

ClidePtyView is a theme-bound wrapper around xterm.dart's TerminalView
— token-derived TerminalTheme, JetBrainsMono as the face, Semantics
live-region label so screen readers + Playwright both hear it. The
consumer (terminal / Claude extensions) owns the `Terminal` model and
wires IPC pane.write / pane.output → terminal.write() themselves; the
widget deliberately has no IPC dependency so it stays trivially
testable.

ClidePaneChrome is the shared pane header — title + subtitle + leading
icon + trailing widgets + optional close button. The close button is
null-conditional so primary Claude panes (D-041, landing in step 7)
can render without one.

xterm 4.0.0 added as a justified runtime dep + logged in
licenses.yaml per D-042. 3 new widget tests cover header rendering,
close-button presence, and the close-tap round-trip.

Q-023 records the SSH-remote-development question so the daemon + IPC
seams don't accrete local-only assumptions during Tier 1-5.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-22 09:19:24 +02:00
co-authored by Claude
parent 3715a2e191
commit 50d9e00ea5
9 changed files with 444 additions and 0 deletions
+18
View File
@@ -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`,
+12
View File
@@ -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"
+182
View File
@@ -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<Widget>? 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<Widget>? 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!),
),
],
),
),
),
);
}
}
+105
View File
@@ -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),
);
}
+2
View File
@@ -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';
+48
View File
@@ -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"
+6
View File
@@ -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
@@ -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();
});
});
}
+6
View File
@@ -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.