import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:clide/clide.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; import 'package:clide/src/terminal/terminal.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; /// Cached in didChangeDependencies — ancestor lookups are illegal in /// dispose(), and the old lookup-and-swallow there meant pane.close /// was never sent, leaking the backend PTY + daemon pane (T-366). KernelServices? _kernel; @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 didChangeDependencies() { super.didChangeDependencies(); _kernel = ClideKernel.of(context); } @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(_kernel?.ipc.request('pane.close', args: {'id': id})); } super.dispose(); } Future _spawn() async { if (!mounted) return; final ipc = _kernel?.ipc; if (ipc == null || !ipc.isConnected) { setState(() => _error = 'Backend not connected.'); return; } // Windows has no $SHELL convention and no login-shell flag — // PowerShell 7 first, classic PowerShell as the always-there // fallback. final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash'); final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo']; // The open workspace, not Directory.current — a desktop launch starts // in $HOME and a project switch doesn't move the process CWD (T-381). final cwd = _kernel?.project.current?.path ?? Directory.current.path; final response = await ipc.request( 'pane.spawn', args: {'argv': argv, '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) { // writeBytes keeps UTF-8 decode state across chunks — a rune // split across PTY reads must not become U+FFFD (T-373). _terminal.writeBytes(base64Decode(b64)); } 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; _kernel?.ipc.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; _kernel?.ipc.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows}); } @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'), const SizedBox(height: 4), ClideText(message, 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;