spawn Claude in fullscreen mode and route mouse-wheel to PgUp/PgDown

Spawn `claude` directly as the tmux command with
CLAUDE_CODE_NO_FLICKER=1 so Claude Code runs in its fullscreen TUI
mode (input box pinned at bottom, owns its own scrollback). Mouse
wheel events are converted to PgUp/PgDown key input — universal
scroll signal that Claude, less, vim normal mode all respect, and
sidesteps the mouse-mode-but-no-scroll dead end where TUI apps
capture mouse without binding the wheel.

Drops the 1000-row tmux canvas + SingleChildScrollView experiment
in favor of viewport-sized tmux and Claude's native bottom-pinning.

Makefile install target now kills the clide tmux server so the
new config takes effect immediately. Marked TEMP — to be removed
once we no longer need the rapid-iteration loop.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-05 13:08:47 +02:00
co-authored by Claude
parent 8a5c626892
commit c378503e26
5 changed files with 86 additions and 98 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"exported_at": "2026-05-05T06:52:25Z",
"exported_at": "2026-05-05T11:08:47Z",
"decisions": [
{
"id": "D-1",
+9
View File
@@ -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
+2
View File
@@ -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
+69 -88
View File
@@ -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-<hash>`) 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<ClaudePane> {
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<ClaudePane> {
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<ClaudePane> {
_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<void> _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<void>();
late final StreamSubscription<ProjectOpened> sub;
sub = kernel.events.on<ProjectOpened>().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<String?> _ensureTmuxConf() async {
if (_tmuxConfPath != null) return _tmuxConfPath;
@@ -123,34 +86,48 @@ class _ClaudePaneState extends State<ClaudePane> {
}
}
// -- spawn ----------------------------------------------------------------
Future<void> _spawnWhenReady() async {
if (!mounted) return;
final kernel = ClideKernel.of(context);
if (!kernel.project.isOpen) {
final c = Completer<void>();
late final StreamSubscription<ProjectOpened> sub;
sub = kernel.events.on<ProjectOpened>().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<void> _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 = <String>[
'tmux',
'-L', 'clide',
@@ -163,33 +140,37 @@ class _ClaudePaneState extends State<ClaudePane> {
'$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<ClaudePane> {
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<ClaudePane> {
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<ClaudePane> {
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<ClaudePane> {
}
}
// -- 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;
+5 -9
View File
@@ -174,18 +174,14 @@ class TerminalViewState extends State<TerminalView> {
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);
}
}
}