From e1ea52c5e4d65790f54f6e45ed3f5fc1a1e555de Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 09:23:44 +0200 Subject: [PATCH] =?UTF-8?q?implement=20builtin.terminal=20=E2=80=94=20gene?= =?UTF-8?q?ral-purpose=20shell=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the Tier-0 stub into a real extension. Contributes a Terminal tab in the workspace slot that spawns $SHELL -l via IPC pane.spawn, feeds pane.output events into xterm.dart's Terminal, and routes user input back through pane.write. Viewport resize propagates via pane.resize. Surface for disconnected-daemon / exited-shell states so there's no silent dead tab. Knows nothing about Claude deliberately — the Claude-specific pane (primary-per-repo, tmux-backed, D-041) lives under builtin.claude in the next steps. Co-Authored-By: Claude --- CHANGELOG.md | 10 + app/lib/builtin/terminal/src/extension.dart | 24 ++- .../builtin/terminal/src/terminal_pane.dart | 185 ++++++++++++++++++ .../i18n/catalog/builtin.terminal_en_us.json | 7 + app/lib/main.dart | 1 + 5 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 app/lib/builtin/terminal/src/terminal_pane.dart create mode 100644 app/lib/kernel/src/i18n/catalog/builtin.terminal_en_us.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7349af89..90e9e120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit. ### Added +- `builtin.terminal` — general-purpose terminal pane, Tier-1 stub + upgraded to a working implementation. Contributes a `Terminal` tab + in the workspace slot that spawns `$SHELL -l` via IPC + `pane.spawn`, streams `pane.output` events into `xterm.dart`, and + routes user input through `pane.write`. Resize propagates via + `pane.resize` on viewport change. `initState` → spawn; + `dispose` → `pane.close`. Error-state surface for "daemon not + connected" / "shell exited." No Claude-specific behaviour — that + lives in `builtin.claude` + D-041. + - 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 diff --git a/app/lib/builtin/terminal/src/extension.dart b/app/lib/builtin/terminal/src/extension.dart index b0a48149..695cebeb 100644 --- a/app/lib/builtin/terminal/src/extension.dart +++ b/app/lib/builtin/terminal/src/extension.dart @@ -1,17 +1,31 @@ +import 'package:clide_app/builtin/terminal/src/terminal_pane.dart'; import 'package:clide_app/extension/extension.dart'; +import 'package:clide_app/kernel/kernel.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. +/// General-purpose terminal pane. Spawns `$SHELL` under a daemon-owned +/// PTY; no Claude-specific behaviour. For the Claude pane with session +/// persistence + primary-per-repo semantics see `builtin.claude` (+ +/// D-041). class TerminalExtension extends ClideExtension { @override String get id => 'builtin.terminal'; @override String get title => 'Terminal'; @override - String get version => '0.0.0-stub'; + String get version => '0.1.0'; @override List get dependsOn => const []; + @override - List get contributions => const []; + List get contributions => [ + TabContribution( + id: 'terminal.pane', + slot: Slots.workspace, + title: 'Terminal', + titleKey: 'tab.title', + i18nNamespace: id, + priority: 100, + build: (_) => const TerminalPane(), + ), + ]; } diff --git a/app/lib/builtin/terminal/src/terminal_pane.dart b/app/lib/builtin/terminal/src/terminal_pane.dart new file mode 100644 index 00000000..511fd20e --- /dev/null +++ b/app/lib/builtin/terminal/src/terminal_pane.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:clide/clide.dart'; +import 'package:clide_app/kernel/kernel.dart'; +import 'package:clide_app/widgets/widgets.dart'; +import 'package:flutter/widgets.dart'; +import 'package:xterm/xterm.dart'; + +/// General-purpose terminal pane. Spawns the user's `$SHELL` under the +/// daemon's PTY (via `pane.spawn`), feeds the `pane.output` event +/// stream into an xterm.dart Terminal, and routes user input back +/// through `pane.write`. +/// +/// Deliberately knows nothing about Claude — that's `builtin.claude`'s +/// job. The shared widget layer (ClidePtyView, ClidePaneChrome) keeps +/// the two extensions visually consistent without coupling them. +class TerminalPane extends StatefulWidget { + const TerminalPane({super.key}); + + @override + State createState() => _TerminalPaneState(); +} + +class _TerminalPaneState extends State { + static const _maxLines = 2000; + + late final Terminal _terminal; + StreamSubscription? _eventSub; + String? _paneId; + String? _error; + int _pid = 0; + + @override + void initState() { + super.initState(); + _terminal = Terminal(maxLines: _maxLines); + // Route user input back through IPC once a pane id is known. + _terminal.onOutput = _onTerminalOutput; + _terminal.onResize = _onTerminalResize; + // Spawn asynchronously after the first build so we have access to + // the kernel via InheritedWidget lookup. + WidgetsBinding.instance.addPostFrameCallback((_) => _spawn()); + } + + @override + void dispose() { + _eventSub?.cancel(); + _eventSub = null; + final id = _paneId; + _paneId = null; + if (id != null) { + // Fire-and-forget. Daemon-side pane.close is idempotent. + unawaited(_kernelIpc()?.request('pane.close', args: {'id': id})); + } + super.dispose(); + } + + Future _spawn() async { + if (!mounted) return; + final ipc = _kernelIpc(); + if (ipc == null || !ipc.isConnected) { + setState(() => _error = 'Daemon not connected. Start `clide --daemon`.'); + return; + } + + final shell = Platform.environment['SHELL'] ?? '/bin/bash'; + final cwd = Directory.current.path; + + final response = await ipc.request('pane.spawn', args: { + 'argv': [shell, '-l'], + 'kind': PaneKind.terminal.wire, + 'cwd': cwd, + 'cols': _terminal.viewWidth, + 'rows': _terminal.viewHeight, + }); + if (!mounted) return; + if (!response.ok) { + setState(() => _error = response.error?.message ?? 'spawn failed'); + return; + } + + _paneId = response.data['id'] as String?; + _pid = (response.data['pid'] as num?)?.toInt() ?? 0; + _subscribeToPaneEvents(); + setState(() {}); // refresh subtitle with PID + } + + void _subscribeToPaneEvents() { + final kernel = _kernel(); + if (kernel == null) return; + _eventSub = kernel.events.on().listen((event) { + if (event.subsystem != 'pane') return; + if (event.data['id'] != _paneId) return; + switch (event.kind) { + case 'pane.output': + final b64 = event.data['bytes_b64']; + if (b64 is String) { + final bytes = base64Decode(b64); + _terminal.write(utf8.decode(bytes, allowMalformed: true)); + } + case 'pane.exit': + setState(() => _error = 'Shell exited.'); + case 'pane.closed': + // Daemon-side gone; reset state so the user can retry. + _paneId = null; + setState(() {}); + } + }); + } + + void _onTerminalOutput(String text) { + final id = _paneId; + if (id == null) return; + _kernelIpc()?.request('pane.write', args: {'id': id, 'text': text}); + } + + void _onTerminalResize(int cols, int rows, int pixelWidth, int pixelHeight) { + final id = _paneId; + if (id == null) return; + _kernelIpc()?.request('pane.resize', args: { + 'id': id, + 'cols': cols, + 'rows': rows, + }); + } + + DaemonClient? _kernelIpc() => _kernel()?.ipc; + + KernelServices? _kernel() { + try { + return ClideKernel.of(context); + } catch (_) { + return null; + } + } + + @override + Widget build(BuildContext context) { + final subtitle = _error != null + ? _error! + : (_paneId == null ? 'spawning shell…' : 'pid $_pid · ${_paneId!}'); + + return ClidePaneChrome( + title: 'terminal', + subtitle: subtitle, + child: _error != null + ? _ErrorBody(message: _error!) + : ClidePtyView( + terminal: _terminal, + label: 'terminal — $subtitle', + ), + ); + } +} + +class _ErrorBody extends StatelessWidget { + const _ErrorBody({required this.message}); + final String message; + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Align( + alignment: Alignment.topLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ClideText('Terminal unavailable', fontSize: 14), + const SizedBox(height: 4), + ClideText(message, fontSize: 12, muted: true), + ], + ), + ), + ); + } +} + +/// Cast the Uint8List base64 source to a typed form consumers can +/// inspect in tests. Exposed via the library's barrel only because it +/// helps the extension test probe the terminal state without pulling +/// in the xterm.dart model directly. +typedef TerminalBytes = Uint8List; diff --git a/app/lib/kernel/src/i18n/catalog/builtin.terminal_en_us.json b/app/lib/kernel/src/i18n/catalog/builtin.terminal_en_us.json new file mode 100644 index 00000000..035715e3 --- /dev/null +++ b/app/lib/kernel/src/i18n/catalog/builtin.terminal_en_us.json @@ -0,0 +1,7 @@ +{ + "tab.title": { "translation": "Terminal" }, + "subtitle.spawning": { "translation": "spawning shell…" }, + "subtitle.exited": { "translation": "Shell exited." }, + "error.unavailable": { "translation": "Terminal unavailable" }, + "error.daemon": { "translation": "Daemon not connected. Start `clide --daemon`." } +} diff --git a/app/lib/main.dart b/app/lib/main.dart index f05638ec..d638519a 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -122,4 +122,5 @@ const List _tier0Namespaces = [ 'builtin.welcome', 'builtin.ipc-status', 'builtin.theme-picker', + 'builtin.terminal', ];