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
+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),
);
}