Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
## 1.67.0

### Added

- **Scroll the IDE with the mouse wheel or trackpad.** In `omnyshell ide` /
`:ide`, scrolling moves through the pane under the pointer, whichever pane
has focus:
- **Editor:** moves the caret up/down a line per step, exactly like the
arrow keys, and the view follows.
- **File tree:** moves the selection, like the arrow keys.
- **Terminal and AI panels:** scroll their output (their Up/Down keys stay
command history). Scrolling stops at the top instead of overshooting, so
scrolling back down responds at once — this also fixes `Home`/`PgUp`
there needing extra presses to come back down.
- Ignored while a dialog (go to line, find, run confirmation) is open.
- **Text selection while the IDE is open** now needs Shift-drag (Option- or
Fn-drag in macOS terminals), and middle-click paste on Linux/X11 needs
Shift+middle-click, since the terminal reports the mouse to the IDE. Clicks
are otherwise ignored: they never clear a message or cancel a pending
`Ctrl-Q`/`Ctrl-W`. The dashboard does not turn mouse reporting on and
behaves as before.
- Works in any terminal that supports mouse reporting, including the web
client's xterm.js terminal; both the SGR (1006) and legacy encodings are
understood. The IDE restores the terminal (mouse mode included) even when
closing its workspace fails on exit.

### Changed

- **`TerminalDriver.enter` takes `{bool mouseWheel = false}`.** Drivers that
extend `AnsiTerminalDriver` need no change; a class that implements
`TerminalDriver` directly must add the parameter.

## 1.66.0

### Added
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,9 @@ agent are shared with the CLI.
- **IDE mode (`omnyshell ide` / `:ide`).** A full-screen, IntelliJ/VS Code-style
terminal IDE: a file-tree sidebar, tabs of open files, per-extension syntax
highlighting (Dart, YAML, JSON, Markdown), a git-change gutter, and a built-in
editor with `Ctrl-S` save. Open it standalone on a directory with `omnyshell
editor with `Ctrl-S` save. The mouse wheel / trackpad scrolls the pane under
the pointer (while the IDE is open, hold Shift — Option/Fn on macOS — to
select text with a drag or to middle-click paste). Open it standalone on a directory with `omnyshell
ide [path]`, or as `:ide` inside a session. Operates on the local filesystem
(the synced OmnyDrive workspace), so it is instant; built on a self-contained,
dependency-free TUI engine. Works in both `connect` and `local` modes.
Expand Down
22 changes: 7 additions & 15 deletions lib/src/application/client/ide/agent/agent_panel.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import '../model/scrollback_offset.dart';
import '../tui/key.dart';
import 'agent_backend.dart';

/// State and behaviour of the IDE's AI agent panel: a conversation scrollback
/// ([lines]), the current [input] prompt, the working [contextLabel], and the
/// multi-turn history sent to the [AgentBackend]. Pure logic — rendering lives
/// in `widgets/agent_view.dart`.
class AgentPanel {
/// in `widgets/agent_view.dart`; the scroll position comes from
/// [ScrollbackOffset].
class AgentPanel with ScrollbackOffset {
AgentPanel({
required AgentBackend backend,
required void Function() onChange,
Expand All @@ -22,14 +24,12 @@ class AgentPanel {
final List<String> _lines = [];
final List<AgentTurn> _history = [];
String _input = '';
int _scroll = 0;
bool _busy = false;

String get contextLabel => _context.label;
List<String> get lines => List.unmodifiable(_lines);
String get input => _input;
bool get isBusy => _busy;
int get scroll => _scroll;

/// Updates the working context (called when the panel is opened/focused so it
/// tracks the active file or selected directory).
Expand All @@ -47,24 +47,16 @@ class AgentPanel {
}
case KeyType.char:
_input += key.text;
_scroll = 0;
case KeyType.pageUp:
_scroll += 5;
case KeyType.pageDown:
_scroll = (_scroll - 5).clamp(0, 1 << 30);
case KeyType.home:
_scroll = 1 << 30; // clamped to the top on render
case KeyType.end:
_scroll = 0;
scrollToBottom();
default:
break;
handleScrollKey(key); // PgUp/PgDn/Home/End; anything else is ignored
}
}

void _submit() {
final prompt = _input.trim();
_input = '';
_scroll = 0;
scrollToBottom();
if (prompt.isEmpty) return;
_append('› $prompt');
if (!_backend.available) {
Expand Down
82 changes: 71 additions & 11 deletions lib/src/application/client/ide/ide_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,26 @@ class IdeApp {
String _lastQuery = '';

ScreenBuffer? _screen;

/// The panes' screen rects as laid out by the last [_render]: the file tree,
/// the editor's text area (below the tab bar) and the bottom dock (terminal or agent
/// panel; [Rect.empty] while hidden). A wheel event carries only the
/// pointer's cell, so [_handleWheel] hit-tests it against these to scroll
/// the pane under the pointer rather than the focused one.
({Rect tree, Rect editor, Rect dock}) _paneRectsCache = (
tree: Rect.empty,
editor: Rect.empty,
dock: Rect.empty,
);

final Completer<void> _done = Completer<void>();

/// Runs the IDE until the user quits, restoring the terminal afterwards.
Future<void> run() async {
_terminal.enter();
final decoder = KeyDecoder();
// Report the mouse wheel so it scrolls the pane under the pointer (see
// [_handleWheel]).
_terminal.enter(mouseWheel: true);
final decoder = KeyDecoder(mouseReports: true);
StreamSubscription<List<int>>? inputSub;
StreamSubscription<void>? resizeSub;
LoneEscapeTimer? escape;
Expand Down Expand Up @@ -247,16 +261,23 @@ class IdeApp {
});
await _done.future;
} finally {
escape?.cancel();
await inputSub?.cancel();
await resizeSub?.cancel();
await _terminalPanel?.dispose();
if (_confirm != null && !_confirm!.completer.isCompleted) {
_confirm!.completer.complete(false);
// The terminal is restored even if a teardown step throws (e.g. closing
// a remote workspace whose connection dropped): a skipped leave() would
// strand the user's shell in the alternate screen with mouse reporting
// on, printing a report for every click and scroll.
try {
escape?.cancel();
await inputSub?.cancel();
await resizeSub?.cancel();
await _terminalPanel?.dispose();
if (_confirm != null && !_confirm!.completer.isCompleted) {
_confirm!.completer.complete(false);
}
_agentBackend?.close();
await _workspace.close();
} finally {
_terminal.leave();
}
_agentBackend?.close();
await _workspace.close();
_terminal.leave();
}
}

Expand Down Expand Up @@ -320,6 +341,9 @@ class IdeApp {

// Tab bar + editor.
final (tabRect, editorRect) = rightArea.splitTop(1);
// The editor's wheel target is its text area only: over the tab bar there
// is no text to scroll.
_paneRectsCache = (tree: treePane, editor: editorRect, dock: dockRect);
TabBar.render(screen, tabRect, tabs: _tabs, active: _activeTab);

({int x, int y})? cursor;
Expand Down Expand Up @@ -496,6 +520,14 @@ class IdeApp {
// ---- Key handling --------------------------------------------------------

Future<void> _handleKey(KeyEvent key) async {
// The wheel is not a keypress: it neither answers a modal nor clears the
// status message or a pending Ctrl-Q/Ctrl-W confirmation, so it is routed
// before all of that.
if (key.isWheel) {
await _handleWheel(key);
return;
}

// A pending command confirmation takes precedence over everything.
final confirm = _confirm;
if (confirm != null) {
Expand Down Expand Up @@ -590,6 +622,34 @@ class IdeApp {
}
}

/// Scrolls the pane under the pointer one step for a wheel [key], without
/// moving focus. The tree and the editor take it exactly as Up/Down (moving
/// the selection / caret, which drags the view along); the dock panels scroll
/// their output instead, since Up/Down there is the input's history.
/// Ignored while a modal (prompt or confirmation) is open, and outside the
/// panes (the hint and status rows).
Future<void> _handleWheel(KeyEvent key) async {
if (_confirm != null || _prompt != null) return;
final up = key.type == KeyType.wheelUp;
final x = key.x ?? -1;
final y = key.y ?? -1;
final panes = _paneRectsCache;
if (panes.dock.contains(x, y)) {
if (_showTerminal) {
_terminalPanel?.scrollLines(up ? 1 : -1);
} else if (_showAgent) {
_agentPanel?.scrollLines(up ? 1 : -1);
}
return;
}
final arrow = KeyEvent(up ? KeyType.up : KeyType.down);
if (panes.tree.contains(x, y)) {
await _handleTreeKey(arrow);
} else if (panes.editor.contains(x, y)) {
_handleEditorKey(arrow);
}
}

Future<void> _handleTreeKey(KeyEvent key) async {
final nodes = _tree.visibleNodes();
switch (key.type) {
Expand Down
56 changes: 56 additions & 0 deletions lib/src/application/client/ide/model/scrollback_offset.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import '../tui/key.dart';

/// The scroll position of a bottom-anchored output pane (the IDE's terminal
/// and AI agent panels): how many lines the view is scrolled up from the
/// newest output, 0 meaning "following the bottom".
///
/// Both panels share these rules, so they live here once: PgUp/PgDn move a
/// page of [pageLines], Home/End jump to the top/bottom, the mouse wheel moves
/// by [scrollLines], and the view clamps the offset with [clampScroll] on each
/// render (the mixin cannot know the top, which depends on the output length
/// and the pane's size and wrap width).
mixin ScrollbackOffset {
/// Lines moved by PgUp/PgDn.
static const pageLines = 5;

/// Stands in for "the top" until the next render clamps it, for Home.
static const _top = 1 << 30;

int _scrollOffset = 0;

/// How many lines the view is scrolled up from the bottom (0 = bottom).
int get scroll => _scrollOffset;

/// Scrolls by [lines] (positive = up, toward older output), e.g. for the
/// mouse wheel. Never below 0; the top is applied by [clampScroll].
void scrollLines(int lines) =>
_scrollOffset = (_scrollOffset + lines).clamp(0, _top);

/// Returns to the newest output (after typing or submitting input).
void scrollToBottom() => _scrollOffset = 0;

/// Clamps the stored offset to [max] (the most the view can scroll up at its
/// current size) and returns it. The view calls it on every render so the
/// stored offset never runs past the top: otherwise a burst of wheel steps
/// (or Home's sentinel) would leave it far above, and scrolling back down
/// would do nothing visible until it had unwound.
int clampScroll(int max) => _scrollOffset = _scrollOffset.clamp(0, max);

/// Applies PgUp/PgDn/Home/End to the offset. Returns whether [key] was one
/// of them, so a panel's key handler can try it for keys it does not own.
bool handleScrollKey(KeyEvent key) {
switch (key.type) {
case KeyType.pageUp:
scrollLines(pageLines);
case KeyType.pageDown:
scrollLines(-pageLines);
case KeyType.home:
_scrollOffset = _top;
case KeyType.end:
scrollToBottom();
default:
return false;
}
return true;
}
}
25 changes: 8 additions & 17 deletions lib/src/application/client/ide/terminal/terminal_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'dart:async';

import '../ide_command_support.dart'
show idePathContext, normalizeIdePath, posixQuote;
import '../model/scrollback_offset.dart';
import '../tui/key.dart';
import 'command_runner.dart';

Expand All @@ -14,8 +15,10 @@ import 'command_runner.dart';
/// does not, since each command runs in a fresh shell).
///
/// Rendering lives in `widgets/terminal_view.dart`; this class is `dart:io`-free
/// pure logic so it can run in tests and the web client.
class TerminalPanel {
/// pure logic so it can run in tests and the web client. The output's scroll
/// position comes from [ScrollbackOffset]; Up/Down are command history, so the
/// mouse wheel scrolls through [scrollLines] instead.
class TerminalPanel with ScrollbackOffset {
TerminalPanel({
required String cwd,
required CommandRunner runner,
Expand All @@ -38,7 +41,6 @@ class TerminalPanel {
String _cwd;
final List<String> _lines = [];
String _input = '';
int _scroll = 0; // lines scrolled up from the bottom
CommandExecution? _running;
StreamSubscription<String>? _outputSub;
final List<String> _history = [];
Expand All @@ -49,9 +51,6 @@ class TerminalPanel {
List<String> get lines => List.unmodifiable(_lines);
bool get isRunning => _running != null;

/// How many lines the view is scrolled up from the bottom (0 = bottom).
int get scroll => _scroll;

// ---- Key handling --------------------------------------------------------

/// Handles a key while the panel has focus. Does not trigger a repaint itself
Expand All @@ -66,28 +65,20 @@ class TerminalPanel {
}
case KeyType.char:
_input += key.text;
_scroll = 0;
scrollToBottom();
case KeyType.up:
_historyPrev();
case KeyType.down:
_historyNext();
case KeyType.pageUp:
_scroll += 5;
case KeyType.pageDown:
_scroll = (_scroll - 5).clamp(0, 1 << 30);
case KeyType.home:
_scroll = 1 << 30; // clamped to the top on render
case KeyType.end:
_scroll = 0;
default:
break;
handleScrollKey(key); // PgUp/PgDn/Home/End; anything else is ignored
}
}

void _submit() {
final cmd = _input.trim();
_input = '';
_scroll = 0;
scrollToBottom();
_append('\$ $cmd');
if (cmd.isEmpty) return;
_history.add(cmd);
Expand Down
Loading
Loading