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:
@@ -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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user