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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user