diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index 1d0faef5..b288742e 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-05T06:52:25Z", + "exported_at": "2026-05-05T11:08:47Z", "decisions": [ { "id": "D-1", diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ccf798..b89ff40e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ## [Unreleased] +### Added + +- Mouse wheel scrolling in Claude pane — converts scroll events to + PgUp/PgDown so Claude Code (and other TUI apps) scroll their + history naturally. + ### Changed - Inline terminal emulator based on xterm.dart v4.0.0 — replaces the @@ -23,6 +29,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. three transitive dependencies (xterm, quiver, zmodem). - Bundle clide-specific tmux.conf for Claude pane sessions: no status bar, 50k scrollback, mouse on, zero escape delay, isolated socket. +- Claude pane spawns `claude` directly inside tmux with + `CLAUDE_CODE_NO_FLICKER=1` to enable Claude's fullscreen TUI mode + (input box pinned at the bottom). - PTY read buffer increased from 4KB to 64KB. - Terminal view 2px padding on all sides. - Remove bold JetBrains Mono font registration to prevent glyph width diff --git a/Makefile b/Makefile index b9132b7d..4745df53 100644 --- a/Makefile +++ b/Makefile @@ -170,9 +170,11 @@ ifeq ($(FLUTTER_OS),linux) > $(HOME)/.local/share/applications/net.schweitz.clide.desktop @gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true @update-desktop-database $(HOME)/.local/share/applications 2>/dev/null || true + @tmux -L clide kill-server 2>/dev/null || true @echo "installed: $(INSTALL_DIR)/clide -> $(INSTALL_PREFIX)/clide/clide" @echo "desktop: ~/.local/share/applications/net.schweitz.clide.desktop" @echo "version: $(VERSION)" + @echo "tmux: clide socket killed (TEMP)" else ifeq ($(FLUTTER_OS),macos) @mkdir -p $(HOME)/Applications rm -rf $(HOME)/Applications/clide.app diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index e23118aa..a0e3ea64 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -11,19 +11,6 @@ import 'package:clide/src/terminal/terminal.dart'; import 'session_naming.dart'; -/// Claude pane. Opinionated per D-041: -/// -/// - [isPrimary]=true: the session name is stable per repo -/// (`clide-claude-`) so reopening the app re-attaches to a -/// running `claude` under tmux. No close button rendered — -/// close-gestures (tab × on the header) minimise, not kill. -/// - [isPrimary]=false: session name includes a `-N` suffix for -/// this clide run. Closes normally; `pane.close` kills the tmux -/// session. -/// -/// Requires `tmux` on the daemon's PATH. If it isn't there, the pane -/// falls back to spawning `claude` directly and loses persistence — -/// an explicit state message lands in the header subtitle. class ClaudePane extends StatefulWidget { const ClaudePane({ super.key, @@ -34,8 +21,6 @@ class ClaudePane extends StatefulWidget { final bool isPrimary; final bool showChrome; - - /// 1-based secondary-session index. Ignored when [isPrimary]. final int? secondaryIndex; @override @@ -44,8 +29,6 @@ class ClaudePane extends StatefulWidget { class _ClaudePaneState extends State { static const _maxLines = 50000; - - /// Lazily extracted tmux config asset path. static String? _tmuxConfPath; late final Terminal _terminal; @@ -61,8 +44,8 @@ class _ClaudePaneState extends State { void initState() { super.initState(); _terminal = Terminal(maxLines: _maxLines); - _terminal.onOutput = _onOutput; - _terminal.onResize = _onResize; + _terminal.onOutput = _onTerminalOutput; + _terminal.onResize = _onTerminalResize; // Don't spawn here — wait for the first onResize from TerminalView // so the PTY gets real dimensions, not 80x24 defaults. } @@ -75,36 +58,16 @@ class _ClaudePaneState extends State { _eventSub = null; final id = _paneId; _paneId = null; + // Secondary panes own their tmux session — close on dispose. + // Primary panes leave the tmux session alive so the next launch + // re-attaches via `tmux new-session -A` (D-041). if (id != null && !widget.isPrimary) { - // Secondary: killing the pane kills the tmux session too — - // that's the D-041 policy ("closing a secondary pops back to - // primary"). The daemon's pane.close is idempotent. unawaited(_ipc()?.request('pane.close', args: {'id': id})); } - // Primary: don't close on dispose. The next time this pane is - // rebuilt (next app launch, or tab reopen), tmux new-session -A - // re-attaches to the same running claude. super.dispose(); } - Future _spawnWhenReady() async { - if (!mounted) return; - final kernel = ClideKernel.of(context); - if (!kernel.project.isOpen) { - // Wait for a project to open before spawning. - final c = Completer(); - late final StreamSubscription sub; - sub = kernel.events.on().listen((_) { - sub.cancel(); - if (!c.isCompleted) c.complete(); - }); - await c.future.timeout(const Duration(seconds: 10), onTimeout: () { - sub.cancel(); - }); - if (!mounted) return; - } - return _spawn(); - } + // -- tmux config extraction ----------------------------------------------- static Future _ensureTmuxConf() async { if (_tmuxConfPath != null) return _tmuxConfPath; @@ -123,34 +86,48 @@ class _ClaudePaneState extends State { } } + // -- spawn ---------------------------------------------------------------- + + Future _spawnWhenReady() async { + if (!mounted) return; + final kernel = ClideKernel.of(context); + if (!kernel.project.isOpen) { + final c = Completer(); + late final StreamSubscription sub; + sub = kernel.events.on().listen((_) { + sub.cancel(); + if (!c.isCompleted) c.complete(); + }); + await c.future.timeout(const Duration(seconds: 10), onTimeout: () { + sub.cancel(); + }); + if (!mounted) return; + } + return _spawn(); + } + Future _spawn() async { if (!mounted) return; final ipc = _ipc(); if (ipc == null || !ipc.isConnected) { - setState(() => _error = 'Daemon not connected. Start `clide --daemon`.'); + setState(() => _error = 'Daemon not connected.'); return; } - // Resolve repo root via files.root. If that fails (no daemon, no - // git root), fall back to cwd — the session name will just be - // based on wherever the daemon is running. String repoRoot = Directory.current.path; final rootResp = await ipc.request('files.root'); if (rootResp.ok) { repoRoot = (rootResp.data['path'] as String?) ?? repoRoot; } - _sessionName = widget.isPrimary ? primarySessionName(repoRoot) : secondarySessionName(repoRoot, widget.secondaryIndex!); + _sessionName = widget.isPrimary + ? primarySessionName(repoRoot) + : secondarySessionName(repoRoot, widget.secondaryIndex!); final tmuxConf = await _ensureTmuxConf(); - - // tmux-wrapped session for persistence (D-041). - // -f loads clide's bundled tmux.conf (T-43): large scrollback, - // mouse on, no status bar, zero escape delay. - // -x/-y set the initial window size; without them tmux defaults - // to a huge size when running inside a PTY without a real terminal. final cols = _terminal.viewWidth; final rows = _terminal.viewHeight; + var argv = [ 'tmux', '-L', 'clide', @@ -163,33 +140,37 @@ class _ClaudePaneState extends State { '$cols', '-y', '$rows', + 'claude', ]; - print('[spawn] cols=${_terminal.viewWidth} rows=${_terminal.viewHeight}'); + + // CLAUDE_CODE_NO_FLICKER=1 enables claude's fullscreen TUI mode: + // input box pinned to the bottom of the alt-screen, claude owns + // its own scrollback. Removes the need for tmux scroll forwarding. + final env = {'CLAUDE_CODE_NO_FLICKER': '1'}; + var resp = await ipc.request('pane.spawn', args: { 'argv': argv, 'kind': PaneKind.claude.wire, 'cwd': repoRoot, - 'cols': _terminal.viewWidth, - 'rows': _terminal.viewHeight, + 'cols': cols, + 'rows': rows, 'title': _sessionName, + 'env': env, }); if (!resp.ok) { - // tmux probably missing — try bare claude so the pane still - // works, at the cost of persistence. argv = ['claude']; resp = await ipc.request('pane.spawn', args: { 'argv': argv, 'kind': PaneKind.claude.wire, 'cwd': repoRoot, - 'cols': _terminal.viewWidth, - 'rows': _terminal.viewHeight, + 'cols': cols, + 'rows': rows, 'title': _sessionName, + 'env': env, }); if (!resp.ok) { - setState(() { - _error = resp.error?.message ?? 'spawn failed'; - }); + setState(() => _error = resp.error?.message ?? 'spawn failed'); return; } setState(() => _statusLine = 'no-tmux · fresh every launch'); @@ -199,11 +180,12 @@ class _ClaudePaneState extends State { if (!mounted) return; _paneId = resp.data['id'] as String?; - // PID available in resp.data['pid'] if needed for debugging. _subscribe(); setState(() {}); } + // -- output batching ------------------------------------------------------ + final _outputBuf = StringBuffer(); Timer? _flushTimer; @@ -224,30 +206,23 @@ class _ClaudePaneState extends State { final b64 = e.data['bytes_b64']; if (b64 is String) { _outputBuf.write(utf8.decode(base64Decode(b64), allowMalformed: true)); - // Batch all output from the current event loop turn into one - // terminal.write() call. scheduleMicrotask runs after all - // pending events but before the next frame, so split escape - // sequences within the same event batch are reunited. if (_flushTimer == null) { _flushTimer = Timer(Duration.zero, _flushOutput); } } case 'pane.exit': - if (widget.isPrimary) { - // Primary exiting is unusual — tmux sessions survive - // normal disconnects. Surface it but don't auto-respawn; - // the user decides. - setState(() => _statusLine = 'session exited — restart clide to retry'); - } else { - setState(() => _statusLine = 'session exited'); - } + setState(() => _statusLine = widget.isPrimary + ? 'session exited — restart clide to retry' + : 'session exited'); case 'pane.closed': _paneId = null; } }); } - void _onOutput(String text) { + // -- terminal callbacks --------------------------------------------------- + + void _onTerminalOutput(String text) { final id = _paneId; if (id == null) return; _ipc()?.request('pane.write', args: {'id': id, 'text': text}); @@ -255,29 +230,30 @@ class _ClaudePaneState extends State { Timer? _resizeTimer; - void _onResize(int cols, int rows, int _, int __) { - print('[onResize] cols=$cols rows=$rows spawned=$_spawned paneId=$_paneId'); + void _onTerminalResize(int cols, int rows, int _, int __) { if (!_spawned) { - // First resize — TerminalView has real dimensions now. _spawned = true; _spawnWhenReady(); return; } - // Debounce resize — rapid SIGWINCH during window drag corrupts - // the terminal rendering. Wait for the resize to settle. _resizeTimer?.cancel(); _resizeTimer = Timer(const Duration(milliseconds: 150), () { final id = _paneId; if (id == null) return; _ipc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows}); - // tmux sizes windows by client, not PTY winsize. Explicitly - // resize the tmux window to match the TerminalView dimensions. if (_sessionName != null) { - Process.run('tmux', ['-L', 'clide', 'resize-window', '-t', _sessionName!, '-x', '$cols', '-y', '$rows']); + Process.run('tmux', [ + '-L', 'clide', 'resize-window', + '-t', _sessionName!, + '-x', '$cols', + '-y', '$rows', + ]); } }); } + // -- helpers -------------------------------------------------------------- + DaemonClient? _ipc() => _kernel()?.ipc; KernelServices? _kernel() { @@ -288,15 +264,20 @@ class _ClaudePaneState extends State { } } + // -- build ---------------------------------------------------------------- + @override Widget build(BuildContext context) { - final title = widget.isPrimary ? 'claude — primary' : 'claude — secondary ${widget.secondaryIndex}'; + final title = widget.isPrimary + ? 'claude — primary' + : 'claude — secondary ${widget.secondaryIndex}'; + final body = _error != null ? Padding( padding: const EdgeInsets.all(16), child: ClideText(_error!, muted: true), ) - : ClidePtyView(terminal: _terminal, label: title); + : ClidePtyView(terminal: _terminal, label: title, autofocus: true); if (!widget.showChrome) return body; diff --git a/lib/src/terminal/src/terminal_view.dart b/lib/src/terminal/src/terminal_view.dart index f1b0f2f7..9e544e73 100644 --- a/lib/src/terminal/src/terminal_view.dart +++ b/lib/src/terminal/src/terminal_view.dart @@ -174,18 +174,14 @@ class TerminalViewState extends State { if (event is! PointerScrollEvent) return; final lh = renderTerminal.lineHeight; if (lh <= 0) return; - final position = renderTerminal.getCellOffset(event.localPosition); final lines = (event.scrollDelta.dy / lh).round().clamp(-5, 5); + // Always send PgUp/PgDown for scroll — the mouse-escape-sequence + // path tends to be a no-op in TUI apps (claude, vim) that capture + // mouse for other purposes. PgUp/PgDown is the universal scroll. for (var i = 0; i < lines.abs(); i++) { - final up = lines < 0; - final handled = widget.terminal.mouseInput( - up ? TerminalMouseButton.wheelUp : TerminalMouseButton.wheelDown, - TerminalMouseButtonState.down, - position, + widget.terminal.keyInput( + lines < 0 ? TerminalKey.pageUp : TerminalKey.pageDown, ); - if (!handled && widget.simulateScroll) { - widget.terminal.keyInput(up ? TerminalKey.arrowUp : TerminalKey.arrowDown); - } } }