xuty 2 yıl önce
ebeveyn
işleme
96d5dedba5

+ 16 - 16
example/.metadata

@@ -4,8 +4,8 @@
 # This file should be version controlled.
 
 version:
-  revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
-  channel: master
+  revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+  channel: stable
 
 project_type: app
 
@@ -13,26 +13,26 @@ project_type: app
 migration:
   platforms:
     - platform: root
-      create_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
-      base_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: android
-      create_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
-      base_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: ios
-      create_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
-      base_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: linux
-      create_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
-      base_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: macos
-      create_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
-      base_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: web
-      create_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
-      base_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
     - platform: windows
-      create_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
-      base_revision: 845c12fb1091fe02f336cb06b60b09fa6f389481
+      create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
+      base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8
 
   # User provided section
 

+ 0 - 386
example/lib/completion.dart

@@ -1,386 +0,0 @@
-import 'dart:convert';
-import 'dart:io';
-import 'dart:math';
-
-import 'package:example/src/platform_menu.dart';
-import 'package:example/src/suggestion.dart';
-import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
-import 'package:flutter_pty/flutter_pty.dart';
-import 'package:xterm/xterm.dart';
-
-final engine = SuggestionEngine();
-
-Future<Map<String, dynamic>> loadSuggestion() async {
-  final data = await rootBundle.load('assets/specs_v1.json.gz');
-  return await Stream.value(data.buffer.asUint8List())
-      .cast<List<int>>()
-      .transform(gzip.decoder)
-      .transform(utf8.decoder)
-      .transform(json.decoder)
-      .first as Map<String, dynamic>;
-}
-
-void main() async {
-  WidgetsFlutterBinding.ensureInitialized();
-  engine.load(await loadSuggestion());
-  runApp(MyApp());
-}
-
-class MyApp extends StatelessWidget {
-  @override
-  Widget build(BuildContext context) {
-    return MaterialApp(
-      title: 'xterm.dart demo',
-      debugShowCheckedModeBanner: false,
-      home: AppPlatformMenu(child: Home()),
-    );
-  }
-}
-
-class Home extends StatefulWidget {
-  Home({Key? key}) : super(key: key);
-
-  @override
-  // ignore: library_private_types_in_public_api
-  _HomeState createState() => _HomeState();
-}
-
-class _HomeState extends State<Home> {
-  late final terminal = Terminal(
-    maxLines: 10000,
-    onPrivateOSC: _handlePrivateOSC,
-  );
-
-  final terminalController = TerminalController();
-
-  final terminalKey = GlobalKey<TerminalViewState>();
-
-  final suggestionOverlay = OverlayPortalController();
-
-  late final Pty pty;
-
-  @override
-  void initState() {
-    super.initState();
-    terminal.addListener(_handleTerminalChanged);
-
-    WidgetsBinding.instance.endOfFrame.then(
-      (_) {
-        if (mounted) _startPty();
-      },
-    );
-  }
-
-  @override
-  void dispose() {
-    super.dispose();
-    terminal.removeListener(_handleTerminalChanged);
-  }
-
-  void _startPty() {
-    pty = Pty.start(
-      shell,
-      columns: terminal.viewWidth,
-      rows: terminal.viewHeight,
-    );
-
-    pty.output
-        .cast<List<int>>()
-        .transform(Utf8Decoder())
-        .listen(terminal.write);
-
-    pty.exitCode.then((code) {
-      terminal.write('the process exited with exit code $code');
-    });
-
-    terminal.onOutput = (data) {
-      pty.write(const Utf8Encoder().convert(data));
-    };
-
-    terminal.onResize = (w, h, pw, ph) {
-      pty.resize(h, w);
-    };
-  }
-
-  CellAnchor? _promptStart;
-  CellAnchor? _commandStart;
-  CellAnchor? _commandEnd;
-  CellAnchor? _commandFinished;
-
-  void _handlePrivateOSC(String code, List<String> args) {
-    switch (code) {
-      case '133':
-        _handleFinalTermOSC(args);
-    }
-  }
-
-  void _handleFinalTermOSC(List<String> args) {
-    switch (args) {
-      case ['A']:
-        _promptStart?.dispose();
-        _promptStart = terminal.buffer.createAnchorFromCursor();
-        _commandStart?.dispose();
-        _commandStart = null;
-        _commandEnd?.dispose();
-        _commandEnd = null;
-        _commandFinished?.dispose();
-        _commandFinished = null;
-      case ['B']:
-        _commandStart?.dispose();
-        _commandStart = terminal.buffer.createAnchorFromCursor();
-        break;
-      case ['C', ..._]:
-        _commandEnd?.dispose();
-        _commandEnd = terminal.buffer.createAnchorFromCursor();
-        // _handleCommandEnd();
-        break;
-      case ['D', String exitCode]:
-        _commandFinished?.dispose();
-        _commandFinished = terminal.buffer.createAnchorFromCursor();
-        // _handleCommandFinished(int.tryParse(exitCode));
-        break;
-    }
-  }
-
-  // void _handleCommandEnd() {
-  //   if (_commandStart == null || _commandEnd == null) return;
-  //   final command = terminal.buffer
-  //       .getText(BufferRangeLine(_commandStart!.offset, _commandEnd!.offset))
-  //       .trim();
-  //   print('command: $command');
-  // }
-
-  // void _handleCommandFinished(int? exitCode) {
-  //   if (_commandEnd == null || _commandFinished == null) return;
-  //   final result = terminal.buffer
-  //       .getText(BufferRangeLine(_commandEnd!.offset, _commandFinished!.offset))
-  //       .trim();
-  //   print('result: $result');
-  //   print('exit code $exitCode');
-  // }
-
-  final suggestions = ValueNotifier<List<FigSuggestion>>([]);
-
-  void _handleTerminalChanged() {
-    final commandStart = _commandStart;
-    if (commandStart == null || _commandEnd != null) {
-      suggestionOverlay.hide();
-      return;
-    }
-
-    var commandRange = BufferRangeLine(
-      commandStart.offset,
-      CellOffset(
-        terminal.buffer.cursorX,
-        terminal.buffer.absoluteCursorY,
-      ),
-    );
-    final command = terminal.buffer.getText(commandRange).trim();
-
-    if (command.isEmpty) {
-      suggestionOverlay.hide();
-      return;
-    }
-
-    print('command: $command');
-
-    suggestions.value = engine.getSuggestions(command).toList();
-
-    print(suggestions.value);
-
-    if (suggestions.value.isNotEmpty) {
-      suggestionOverlay.show();
-    } else {
-      suggestionOverlay.hide();
-    }
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      backgroundColor: Colors.transparent,
-      body: OverlayPortal(
-        controller: suggestionOverlay,
-        overlayChildBuilder: (context) {
-          return ValueListenableBuilder<List<FigSuggestion>>(
-            valueListenable: suggestions,
-            builder: (context, suggestions, _) {
-              return SuggestionOverlay(
-                suggestions,
-                cursorRect: terminalKey.currentState!.cursorRect,
-              );
-            },
-          );
-        },
-        child: TerminalView(
-          terminal,
-          key: terminalKey,
-          controller: terminalController,
-          autofocus: true,
-          backgroundOpacity: 0.7,
-        ),
-      ),
-    );
-  }
-}
-
-class SuggestionOverlay extends StatelessWidget {
-  const SuggestionOverlay(
-    this.suggestions, {
-    super.key,
-    required this.cursorRect,
-  });
-
-  final Rect cursorRect;
-
-  final List<FigSuggestion> suggestions;
-
-  @override
-  Widget build(BuildContext context) {
-    print('build suggestions');
-
-    const kScreenPadding = 8.0;
-    const kPanelContentDistance = 8.0;
-    const kPanelWidth = 300.0;
-    const kPanelHeight = 300.0;
-    final paddingAbove = MediaQuery.paddingOf(context).top + kScreenPadding;
-    final availableHeight =
-        cursorRect.top - kPanelContentDistance - paddingAbove;
-    final fitsAbove = kPanelHeight <= availableHeight;
-
-    return CustomSingleChildLayout(
-      delegate: _SuggestionOverlayDelegate(cursorRect, fitsAbove),
-      child: ConstrainedBox(
-        constraints: BoxConstraints(
-          maxWidth: kPanelWidth,
-          maxHeight: kPanelHeight,
-        ),
-        child: _buildSuggestions(context),
-      ),
-    );
-  }
-
-  Widget _buildSuggestions(BuildContext context) {
-    final list = ListView.builder(
-      itemCount: suggestions.length,
-      itemBuilder: (context, index) {
-        final suggestion = suggestions[index];
-        final (icon, color, content) = _suggestionContent(suggestion);
-        return SuggestionTile(icon: icon, color: color, content: content ?? '');
-      },
-    );
-
-    return Container(
-      decoration: BoxDecoration(
-        color: Colors.grey[800],
-        borderRadius: BorderRadius.circular(4),
-        // border: Border.all(color: Colors.grey[900]!),
-      ),
-      child: DefaultTextStyle(
-        style: TerminalStyle().toTextStyle().copyWith(height: 1.5),
-        child: Column(
-          children: [
-            Expanded(
-              child: list,
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  static (IconData, Color, String?) _suggestionContent(
-      FigSuggestion suggestion) {
-    return switch (suggestion) {
-      FigSubCommand(:final names) => (
-          Icons.subdirectory_arrow_right,
-          Colors.blue,
-          names.join(', '),
-        ),
-      FigOption(:final name) => (
-          Icons.settings,
-          Colors.green,
-          name.join(', '),
-        ),
-      FigArgument(:final name) => (
-          Icons.text_fields,
-          Colors.yellow,
-          name,
-        ),
-    };
-  }
-}
-
-class _SuggestionOverlayDelegate extends SingleChildLayoutDelegate {
-  _SuggestionOverlayDelegate(this.cursorRect, this.fitsAbove);
-
-  final Rect cursorRect;
-
-  final bool fitsAbove;
-
-  @override
-  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
-    return constraints.loosen();
-  }
-
-  @override
-  Offset getPositionForChild(Size size, Size childSize) {
-    TextSelectionToolbarLayoutDelegate;
-
-    const kPanelContentDistance = 8.0;
-    final dx = min(cursorRect.left, size.width - childSize.width);
-    final dy = fitsAbove
-        ? cursorRect.top - childSize.height
-        : cursorRect.bottom + kPanelContentDistance;
-    return Offset(dx, dy);
-  }
-
-  @override
-  bool shouldRelayout(_SuggestionOverlayDelegate oldDelegate) {
-    return cursorRect != oldDelegate.cursorRect;
-  }
-}
-
-class SuggestionTile extends StatelessWidget {
-  const SuggestionTile({
-    super.key,
-    required this.icon,
-    required this.content,
-    required this.color,
-  });
-
-  final IconData icon;
-  final Color color;
-
-  final String content;
-
-  @override
-  Widget build(BuildContext context) {
-    return Row(
-      crossAxisAlignment: CrossAxisAlignment.center,
-      children: [
-        SizedBox(width: 4),
-        Icon(
-          icon,
-          size: 14,
-          color: color,
-        ),
-        SizedBox(width: 4),
-        Text(content),
-      ],
-    );
-  }
-}
-
-String get shell {
-  if (Platform.isMacOS || Platform.isLinux) {
-    return Platform.environment['SHELL'] ?? 'bash';
-  }
-
-  if (Platform.isWindows) {
-    return 'cmd.exe';
-  }
-
-  return 'sh';
-}

+ 59 - 61
example/lib/src/suggestion.dart → example/lib/src/suggestion_engine.dart

@@ -1,48 +1,50 @@
 class SuggestionEngine {
-  final _specs = <String, FigSubCommand>{};
+  final _specs = <String, FigCommand>{};
 
   void load(Map<String, dynamic> specs) {
     for (var spec in specs.entries) {
-      addSpec(spec.key, FigSubCommand.fromJson(spec.value));
+      addSpec(spec.key, FigCommand.fromJson(spec.value));
     }
   }
 
-  void addSpec(String name, FigSubCommand spec) {
+  void addSpec(String name, FigCommand spec) {
     _specs[name] = spec;
   }
 
-  Iterable<FigSuggestion> getSuggestions(String command) {
+  Iterable<FigToken> getSuggestions(String command) {
     final args = command.split(' ').where((e) => e.isNotEmpty).toList();
 
     if (args.isEmpty) {
       return [];
     }
 
-    return _getSuggestions(args, _specs);
+    final isComplete = command.endsWith(' ');
+    return _getSuggestions(args, _specs, isComplete);
   }
 
-  Iterable<FigSuggestion> _getSuggestions(
+  Iterable<FigToken> _getSuggestions(
     List<String> input,
-    Map<String, FigSubCommand> specs,
+    Map<String, FigCommand> searchList,
+    bool isComplete,
   ) sync* {
     assert(input.isNotEmpty);
 
     // The subcommand scope we are currently in.
-    FigSubCommand? currentCommand;
+    FigCommand? currentCommand;
 
     // The last suggestion we recongnized. This is used to determine what to
     // suggest next. Valid values are:
     // - null: We are at the root of the command.
     // - currentCommand
     // - option of currentCommand
-    FigSuggestion? last;
+    FigToken? last;
 
     for (final part in input) {
       if (currentCommand == null) {
-        currentCommand = specs[part];
+        currentCommand = searchList[part];
         if (currentCommand == null) {
           if (part.length >= 4) {
-            yield* specs.values.matchPrefix(input.last);
+            yield* searchList.values.matchPrefix(input.last);
           }
           return;
         }
@@ -70,13 +72,14 @@ class SuggestionEngine {
       return;
     }
 
-    if (last is FigSubCommand) {
-      yield* last.args;
-      yield* last.subCommands;
-      yield* last.options;
+    if (last is FigCommand) {
+      if (isComplete) {
+        yield* last.subCommands;
+        yield* last.options;
+      }
     } else if (last is FigOption) {
-      if (last.args.isEmpty) {
-        yield* currentCommand.args;
+      if (isComplete) {
+        yield* last.args;
         yield* currentCommand.options;
       } else {
         yield* last.args;
@@ -89,54 +92,46 @@ class SuggestionEngine {
   }
 }
 
-extension on Iterable<FigSubCommand> {
-  FigSubCommand? match(String name) {
-    for (final command in this) {
-      if (command.names.contains(name)) {
-        return command;
-      }
-    }
-    return null;
-  }
-
-  Iterable<FigSubCommand> matchPrefix(String name) sync* {
-    for (final command in this) {
-      if (command.names.any((e) => e.startsWith(name))) {
-        yield command;
-      }
-    }
-  }
-}
-
-extension on Iterable<FigOption> {
-  FigOption? match(String name) {
-    for (final option in this) {
-      if (option.name.contains(name)) {
-        return option;
+extension _FigSuggestionSearch<T extends FigSuggestion> on Iterable<T> {
+  /// Finds the first suggestion that matches [name].
+  T? match(String name) {
+    for (final suggestion in this) {
+      if (suggestion.names.contains(name)) {
+        return suggestion;
       }
     }
     return null;
   }
 
-  Iterable<FigOption> matchPrefix(String name) sync* {
-    for (final option in this) {
-      if (option.name.any((e) => e.startsWith(name))) {
-        yield option;
+  /// Finds all suggestions that start with [name].
+  Iterable<T> matchPrefix(String name) sync* {
+    for (final suggestion in this) {
+      if (suggestion.names.any((e) => e.startsWith(name))) {
+        yield suggestion;
       }
     }
   }
 }
 
-sealed class FigSuggestion {
+/// A token of a command.
+sealed class FigToken {
   final String? description;
 
-  FigSuggestion({this.description});
+  const FigToken({this.description});
 }
 
-class FigSubCommand extends FigSuggestion {
+/// A token of a command that can be suggested.
+sealed class FigSuggestion extends FigToken {
   final List<String> names;
 
-  final List<FigSubCommand> subCommands;
+  const FigSuggestion({
+    required this.names,
+    super.description,
+  });
+}
+
+class FigCommand extends FigSuggestion {
+  final List<FigCommand> subCommands;
 
   final bool requiresSubCommand;
 
@@ -144,8 +139,8 @@ class FigSubCommand extends FigSuggestion {
 
   final List<FigArgument> args;
 
-  FigSubCommand({
-    required this.names,
+  FigCommand({
+    required super.names,
     super.description,
     required this.subCommands,
     required this.requiresSubCommand,
@@ -153,12 +148,12 @@ class FigSubCommand extends FigSuggestion {
     required this.args,
   });
 
-  factory FigSubCommand.fromJson(Map<String, dynamic> json) {
-    return FigSubCommand(
+  factory FigCommand.fromJson(Map<String, dynamic> json) {
+    return FigCommand(
       names: singleOrList<String>(json['name']),
       description: json['description'],
       subCommands: singleOrList(json['subcommands'])
-          .map<FigSubCommand>((e) => FigSubCommand.fromJson(e))
+          .map<FigCommand>((e) => FigCommand.fromJson(e))
           .toList(),
       requiresSubCommand: json['requiresSubCommand'] ?? false,
       options: singleOrList(json['options'])
@@ -177,8 +172,6 @@ class FigSubCommand extends FigSuggestion {
 }
 
 class FigOption extends FigSuggestion {
-  final List<String> name;
-
   final List<FigArgument> args;
 
   final bool isPersistent;
@@ -194,7 +187,7 @@ class FigOption extends FigSuggestion {
   final List<String> dependsOn;
 
   FigOption({
-    required this.name,
+    required super.names,
     super.description,
     required this.args,
     required this.isPersistent,
@@ -207,7 +200,7 @@ class FigOption extends FigSuggestion {
 
   factory FigOption.fromJson(Map<String, dynamic> json) {
     return FigOption(
-      name: singleOrList(json['name']).cast<String>(),
+      names: singleOrList(json['name']).cast<String>(),
       description: json['description'],
       args: singleOrList(json['args'])
           .map<FigArgument>((e) => FigArgument.fromJson(e))
@@ -215,7 +208,12 @@ class FigOption extends FigSuggestion {
       isPersistent: json['isPersistent'] ?? false,
       isRequired: json['isRequired'] ?? false,
       separator: json['separator'],
-      repeat: json['repeat'],
+      // ignore: prefer-trailing-comma
+      repeat: switch (json['isRepeatable']) {
+        true => 0xFFFF,
+        int count => count,
+        _ => 0,
+      },
       exclusiveOn: singleOrList<String>(json['exclusiveOn']),
       dependsOn: singleOrList<String>(json['dependsOn']),
     );
@@ -223,11 +221,11 @@ class FigOption extends FigSuggestion {
 
   @override
   String toString() {
-    return 'FigOption($name)';
+    return 'FigOption($names)';
   }
 }
 
-class FigArgument extends FigSuggestion {
+class FigArgument extends FigToken {
   final String? name;
 
   final bool isDangerous;

+ 584 - 0
example/lib/suggestion.dart

@@ -0,0 +1,584 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:example/src/platform_menu.dart';
+import 'package:example/src/suggestion_engine.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_pty/flutter_pty.dart';
+import 'package:xterm/xterm.dart';
+import 'package:xterm/suggestion.dart';
+
+final engine = SuggestionEngine();
+
+Future<Map<String, dynamic>> loadSuggestion() async {
+  final data = await rootBundle.load('assets/specs_v1.json.gz');
+  return await Stream.value(data.buffer.asUint8List())
+      .cast<List<int>>()
+      .transform(gzip.decoder)
+      .transform(utf8.decoder)
+      .transform(json.decoder)
+      .first as Map<String, dynamic>;
+}
+
+void main() async {
+  WidgetsFlutterBinding.ensureInitialized();
+  engine.load(await loadSuggestion());
+  runApp(MyApp());
+}
+
+class MyApp extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: 'xterm.dart demo',
+      debugShowCheckedModeBanner: false,
+      home: AppPlatformMenu(child: Home()),
+    );
+  }
+}
+
+class Home extends StatefulWidget {
+  Home({Key? key}) : super(key: key);
+
+  @override
+  // ignore: library_private_types_in_public_api
+  _HomeState createState() => _HomeState();
+}
+
+class _HomeState extends State<Home> {
+  late final terminal = Terminal(
+    maxLines: 10000,
+    onPrivateOSC: _handlePrivateOSC,
+  );
+
+  final terminalController = TerminalController();
+
+  final terminalKey = GlobalKey<TerminalViewState>();
+
+  final suggestionOverlay = SuggestionPortalController();
+
+  late final Pty pty;
+
+  @override
+  void initState() {
+    super.initState();
+    terminal.addListener(_handleTerminalChanged);
+
+    WidgetsBinding.instance.endOfFrame.then(
+      (_) {
+        if (mounted) _startPty();
+      },
+    );
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+    terminal.removeListener(_handleTerminalChanged);
+  }
+
+  void _startPty() {
+    pty = Pty.start(
+      shell,
+      columns: terminal.viewWidth,
+      rows: terminal.viewHeight,
+    );
+
+    pty.output
+        .cast<List<int>>()
+        .transform(Utf8Decoder())
+        .listen(terminal.write);
+
+    pty.exitCode.then((code) {
+      terminal.write('the process exited with exit code $code');
+    });
+
+    terminal.onOutput = (data) {
+      pty.write(const Utf8Encoder().convert(data));
+    };
+
+    terminal.onResize = (w, h, pw, ph) {
+      pty.resize(h, w);
+    };
+  }
+
+  /// Where the current shell prompt starts
+  CellAnchor? _promptStart;
+
+  /// Where the current user input starts
+  CellAnchor? _commandStart;
+
+  /// Where the current user input ends and the command starts to execute
+  CellAnchor? _commandEnd;
+
+  /// Where the command finishes
+  CellAnchor? _commandFinished;
+
+  void _handlePrivateOSC(String code, List<String> args) {
+    switch (code) {
+      case '133':
+        _handleFinalTermOSC(args);
+    }
+  }
+
+  void _handleFinalTermOSC(List<String> args) {
+    switch (args) {
+      case ['A']:
+        _promptStart?.dispose();
+        _promptStart = terminal.buffer.createAnchorFromCursor();
+        _commandStart?.dispose();
+        _commandStart = null;
+        _commandEnd?.dispose();
+        _commandEnd = null;
+        _commandFinished?.dispose();
+        _commandFinished = null;
+      case ['B']:
+        _commandStart?.dispose();
+        _commandStart = terminal.buffer.createAnchorFromCursor();
+        break;
+      case ['C', ..._]:
+        _commandEnd?.dispose();
+        _commandEnd = terminal.buffer.createAnchorFromCursor();
+        _handleCommandEnd();
+        break;
+      case ['D', String exitCode]:
+        _commandFinished?.dispose();
+        _commandFinished = terminal.buffer.createAnchorFromCursor();
+        _handleCommandFinished(int.tryParse(exitCode));
+        break;
+    }
+  }
+
+  void _handleCommandEnd() {
+    if (_commandStart == null || _commandEnd == null) return;
+    final command = terminal.buffer
+        .getText(BufferRangeLine(_commandStart!.offset, _commandEnd!.offset))
+        .trim();
+    print('command: $command');
+  }
+
+  void _handleCommandFinished(int? exitCode) {
+    if (_commandEnd == null || _commandFinished == null) return;
+    final result = terminal.buffer
+        .getText(BufferRangeLine(_commandEnd!.offset, _commandFinished!.offset))
+        .trim();
+    print('result: $result');
+    print('exitCode: $exitCode');
+  }
+
+  final suggestionView = SuggestionViewController();
+
+  String? get commandBuffer {
+    final commandStart = _commandStart;
+    if (commandStart == null || _commandEnd != null) {
+      return null;
+    }
+
+    var commandRange = BufferRangeLine(
+      commandStart.offset,
+      CellOffset(
+        terminal.buffer.cursorX,
+        terminal.buffer.absoluteCursorY,
+      ),
+    );
+    return terminal.buffer.getText(commandRange).trimRightNewline();
+  }
+
+  void _handleTerminalChanged() {
+    final command = commandBuffer;
+
+    if (command == null || command.isEmpty) {
+      suggestionOverlay.hide();
+      return;
+    }
+
+    final suggestions = engine.getSuggestions(command).toList();
+    suggestionView.update(suggestions);
+
+    print('suggestions: $suggestions');
+
+    if (suggestions.isNotEmpty) {
+      suggestionOverlay.update(terminalKey.currentState!.cursorRect);
+    } else {
+      suggestionOverlay.hide();
+    }
+  }
+
+  void _handleSuggestionSelected(FigToken suggestion) {
+    final command = commandBuffer;
+    if (command == null) {
+      return;
+    }
+
+    final incompleteCommand =
+        command.endsWith(' ') ? null : command.split(' ').last;
+
+    switch (suggestion) {
+      case FigCommand(:var names):
+        if (incompleteCommand == null) {
+          _emitSuggestion(names.first);
+        } else {
+          for (final name in names) {
+            if (name.startsWith(incompleteCommand)) {
+              _emitSuggestion(name.substring(incompleteCommand.length));
+              break;
+            }
+          }
+        }
+      case FigOption(:var names):
+        if (incompleteCommand == null) {
+          _emitSuggestion(names.first);
+        } else {
+          for (final name in names) {
+            if (name.startsWith(incompleteCommand)) {
+              _emitSuggestion(name.substring(incompleteCommand.length));
+              break;
+            }
+          }
+        }
+        break;
+      default:
+    }
+  }
+
+  void _emitSuggestion(String text) {
+    pty.write(const Utf8Encoder().convert(text));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      backgroundColor: Colors.transparent,
+      body: SuggestionPortal(
+        controller: suggestionOverlay,
+        overlayBuilder: (context) {
+          return SuggestionView(
+            suggestionView,
+            onSuggestionSelected: _handleSuggestionSelected,
+          );
+        },
+        child: TerminalView(
+          terminal,
+          key: terminalKey,
+          controller: terminalController,
+          autofocus: true,
+          backgroundOpacity: 0.7,
+          onKey: (node, event) {
+            if (event is! RawKeyDownEvent) {
+              return KeyEventResult.ignored;
+            }
+
+            if (suggestionOverlay.isShowing) {
+              switch (event.logicalKey) {
+                case LogicalKeyboardKey.escape:
+                  suggestionOverlay.hide();
+                  return KeyEventResult.handled;
+                case LogicalKeyboardKey.tab:
+                  final suggestion = suggestionView.currentSuggestion;
+                  if (suggestion != null) {
+                    _handleSuggestionSelected(suggestion);
+                    return KeyEventResult.handled;
+                  }
+                case LogicalKeyboardKey.arrowUp:
+                  suggestionView.selectPrevious();
+                  return KeyEventResult.handled;
+                case LogicalKeyboardKey.arrowDown:
+                  suggestionView.selectNext();
+                  return KeyEventResult.handled;
+                default:
+              }
+            }
+
+            return KeyEventResult.ignored;
+          },
+        ),
+      ),
+    );
+  }
+}
+
+class SuggestionViewController extends ChangeNotifier {
+  final scrollController = ScrollController();
+
+  List<FigToken> get suggestions => _suggestions;
+  List<FigToken> _suggestions = [];
+
+  var _selected = 0;
+  set selected(int index) {
+    _selected = max(0, min(index, suggestions.length - 1));
+    notifyListeners();
+  }
+
+  double get itemExtent => _itemExtent;
+  double _itemExtent = 20;
+  set itemExtent(double value) {
+    if (value == _itemExtent) return;
+    _itemExtent = value;
+    notifyListeners();
+  }
+
+  FigToken? get currentSuggestion {
+    if (_suggestions.isEmpty) return null;
+    return _suggestions[_selected];
+  }
+
+  void update(List<FigToken> suggestions) {
+    _suggestions = suggestions;
+    _selected = 0;
+    notifyListeners();
+  }
+
+  void selectNext() {
+    _selected = (_selected + 1) % _suggestions.length;
+    ensureVisible(_selected);
+    notifyListeners();
+  }
+
+  void selectPrevious() {
+    _selected = (_selected - 1) % _suggestions.length;
+    ensureVisible(_selected);
+    notifyListeners();
+  }
+
+  void ensureVisible(int index) {
+    if (!scrollController.hasClients) {
+      return;
+    }
+    final position = scrollController.position;
+
+    final targetOffset = itemExtent * index;
+    final viewportBottomOffset = position.pixels + position.viewportDimension;
+
+    if (targetOffset < position.pixels) {
+      position.jumpTo(targetOffset);
+    } else if (targetOffset + itemExtent > viewportBottomOffset) {
+      position.jumpTo(
+        max(0, targetOffset + itemExtent - position.viewportDimension),
+      );
+    }
+  }
+}
+
+class SuggestionView extends StatelessWidget {
+  const SuggestionView(
+    this.controller, {
+    super.key,
+    this.onSuggestionSelected,
+  });
+
+  final SuggestionViewController controller;
+
+  final void Function(FigToken)? onSuggestionSelected;
+
+  @override
+  Widget build(BuildContext context) {
+    return ListenableBuilder(
+      listenable: controller,
+      builder: (context, child) {
+        return _build(context);
+      },
+    );
+  }
+
+  Widget _build(BuildContext context) {
+    return ClipRRect(
+      borderRadius: BorderRadius.circular(4),
+      child: Container(
+        constraints: BoxConstraints(
+          maxWidth: 300,
+          maxHeight: 200,
+        ),
+        decoration: BoxDecoration(
+          color: Colors.grey[800],
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Expanded(
+              child: ListView.builder(
+                controller: controller.scrollController,
+                itemExtent: controller.itemExtent,
+                itemCount: controller._suggestions.length,
+                itemBuilder: (context, index) {
+                  final suggestion = controller._suggestions[index];
+                  return GestureDetector(
+                    onTapDown: (_) => controller.selected = index,
+                    onDoubleTapDown: (_) =>
+                        onSuggestionSelected?.call(suggestion),
+                    child: ClipRect(
+                      child: SuggestionTile(
+                        selected: index == controller._selected,
+                        suggestion: suggestion,
+                      ),
+                    ),
+                  );
+                },
+              ),
+            ),
+            if (controller.currentSuggestion != null) ...[
+              Divider(
+                height: 1,
+                thickness: 1,
+                color: Colors.grey[700],
+              ),
+              SuggestionDescriptionView(controller.currentSuggestion!),
+            ],
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class SuggestionDescriptionView extends StatefulWidget {
+  const SuggestionDescriptionView(
+    this.suggestion, {
+    super.key,
+  });
+
+  final FigToken suggestion;
+
+  @override
+  State<SuggestionDescriptionView> createState() =>
+      _SuggestionDescriptionViewState();
+}
+
+class _SuggestionDescriptionViewState extends State<SuggestionDescriptionView> {
+  var isHovering = false;
+
+  @override
+  Widget build(BuildContext context) {
+    return MouseRegion(
+      onEnter: (event) {
+        setState(() {
+          isHovering = true;
+        });
+      },
+      onExit: (event) {
+        setState(() {
+          isHovering = false;
+        });
+      },
+      child: Container(
+        padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+        child: Text(
+          widget.suggestion.description ?? '',
+          maxLines: isHovering ? null : 1,
+          overflow: isHovering ? null : TextOverflow.ellipsis,
+          style: TextStyle(color: Colors.grey[400], fontSize: 12),
+        ),
+      ),
+    );
+  }
+}
+
+/// An item in the suggestion list.
+class SuggestionTile extends StatelessWidget {
+  const SuggestionTile({
+    super.key,
+    required this.selected,
+    required this.suggestion,
+  });
+
+  final bool selected;
+
+  final FigToken suggestion;
+
+  static final primaryStyle = TerminalStyle().toTextStyle().copyWith(
+        leadingDistribution: TextLeadingDistribution.even,
+      );
+
+  static final argumentStyle = TerminalStyle().toTextStyle().copyWith(
+        leadingDistribution: TextLeadingDistribution.even,
+        color: Colors.grey[500],
+        fontSize: 12,
+      );
+
+  @override
+  Widget build(BuildContext context) {
+    final (icon, iconColor) = _getIcon(suggestion);
+    final canSelect = suggestion is! FigArgument;
+
+    return Container(
+      color: selected ? Colors.blue[800] : Colors.transparent,
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.center,
+        children: [
+          SizedBox(width: 4),
+          Icon(icon, size: 14, color: iconColor),
+          SizedBox(width: 4),
+          Expanded(
+            child: RichText(
+              overflow: TextOverflow.ellipsis,
+              maxLines: 1,
+              text: TextSpan(
+                text: _getContent(suggestion) ?? '',
+                children: [...buildArgs()],
+                style: canSelect
+                    ? primaryStyle
+                    : primaryStyle.copyWith(fontStyle: FontStyle.italic),
+              ),
+            ),
+          ),
+          if (selected && canSelect) ...[
+            Icon(Icons.keyboard_tab_rounded, size: 16, color: Colors.grey[400]),
+            SizedBox(width: 4),
+          ],
+        ],
+      ),
+    );
+  }
+
+  Iterable<InlineSpan> buildArgs() sync* {
+    final args = switch (suggestion) {
+      FigCommand(:var args) => args,
+      FigOption(:var args) => args,
+      _ => <FigArgument>[],
+    };
+
+    const indent = ' ';
+
+    for (final arg in args) {
+      yield TextSpan(
+        text: arg.isOptional ? '$indent[${arg.name}]' : '$indent<${arg.name}>',
+        style: argumentStyle,
+      );
+    }
+  }
+
+  static (IconData, Color) _getIcon(FigToken suggestion) {
+    return switch (suggestion) {
+      FigCommand() => (Icons.subdirectory_arrow_right, Colors.blue),
+      FigOption() => (Icons.settings, Colors.green),
+      FigArgument() => (Icons.text_fields, Colors.yellow),
+    };
+  }
+
+  static String? _getContent(FigToken suggestion) {
+    return switch (suggestion) {
+      FigCommand(:final names) => names.join(', '),
+      FigOption(names: final name) => name.join(', '),
+      FigArgument(:final name) => name,
+    };
+  }
+}
+
+String get shell {
+  if (Platform.isMacOS || Platform.isLinux) {
+    return Platform.environment['SHELL'] ?? 'bash';
+  }
+
+  if (Platform.isWindows) {
+    return 'cmd.exe';
+  }
+
+  return 'sh';
+}
+
+extension on String {
+  String trimRightNewline() {
+    return endsWith('\n') ? substring(0, length - 1) : this;
+  }
+}

+ 5 - 13
example/pubspec.lock

@@ -202,10 +202,10 @@ packages:
     dependency: "direct main"
     description:
       name: flutter_pty
-      sha256: "1f3114f125e4c447866511560818d6ac368471712cc952a25b5a06586aa80b64"
+      sha256: "08b6f37a4f394159e3a6adb6f07295e6fb6acb71270c87a4d0be187db34535cd"
       url: "https://pub.dev"
     source: hosted
-    version: "0.3.1"
+    version: "0.4.0"
   flutter_test:
     dependency: "direct dev"
     description: flutter
@@ -336,14 +336,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "3.1.0"
-  platform_info:
-    dependency: transitive
-    description:
-      name: platform_info
-      sha256: "012e73712166cf0b56d3eb95c0d33491f56b428c169eca385f036448474147e4"
-      url: "https://pub.dev"
-    source: hosted
-    version: "3.2.0"
   plugin_platform_interface:
     dependency: transitive
     description:
@@ -465,10 +457,10 @@ packages:
     dependency: transitive
     description:
       name: watcher
-      sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0"
+      sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
       url: "https://pub.dev"
     source: hosted
-    version: "1.0.2"
+    version: "1.1.0"
   win32:
     dependency: transitive
     description:
@@ -510,4 +502,4 @@ packages:
     version: "0.0.6"
 sdks:
   dart: ">=3.0.0 <4.0.0"
-  flutter: ">=3.0.0"
+  flutter: ">=3.10.0"

+ 1 - 1
example/pubspec.yaml

@@ -26,7 +26,7 @@ dependencies:
 
   dartssh2: ^2.5.0
 
-  flutter_pty: ^0.3.0
+  flutter_pty: ^0.4.0
 
   flutter:
     sdk: flutter

+ 17 - 0
lib/src/base/disposable.dart

@@ -14,6 +14,11 @@ mixin Disposable {
     _disposables.add(disposable);
   }
 
+  void registerCallback(void Function() callback) {
+    assert(!_disposed);
+    _disposables.add(_DisposeCallback(callback));
+  }
+
   void dispose() {
     _disposed = true;
     for (final disposable in _disposables) {
@@ -22,3 +27,15 @@ mixin Disposable {
     _onDisposed.emit(null);
   }
 }
+
+class _DisposeCallback with Disposable {
+  final void Function() callback;
+
+  _DisposeCallback(this.callback);
+
+  @override
+  void dispose() {
+    super.dispose();
+    callback();
+  }
+}

+ 15 - 13
lib/src/ui/controller.dart

@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:meta/meta.dart';
+import 'package:xterm/src/base/disposable.dart';
 import 'package:xterm/src/core/buffer/cell_offset.dart';
 import 'package:xterm/src/core/buffer/line.dart';
 import 'package:xterm/src/core/buffer/range.dart';
@@ -119,29 +120,34 @@ class TerminalController with ChangeNotifier {
         : _pointerInputs.inputs.contains(pointerInput);
   }
 
-  TerminalHighlight addHighlight({
+  /// Creates a new highlight on the terminal from [p1] to [p2] with the given
+  /// [color]. The highlight will be removed when the returned object is
+  /// disposed.
+  TerminalHighlight highlight({
     required CellAnchor p1,
     required CellAnchor p2,
     required Color color,
   }) {
-    final highlight = TerminalHighlight._(
+    final highlight = TerminalHighlight(
       this,
       p1: p1,
       p2: p2,
       color: color,
     );
+
     _highlights.add(highlight);
     notifyListeners();
-    return highlight;
-  }
 
-  void removeHighlight(TerminalHighlight highlight) {
-    _highlights.remove(highlight);
-    notifyListeners();
+    highlight.registerCallback(() {
+      _highlights.remove(highlight);
+      notifyListeners();
+    });
+
+    return highlight;
   }
 }
 
-class TerminalHighlight {
+class TerminalHighlight with Disposable {
   final TerminalController owner;
 
   final CellAnchor p1;
@@ -150,7 +156,7 @@ class TerminalHighlight {
 
   final Color color;
 
-  TerminalHighlight._(
+  TerminalHighlight(
     this.owner, {
     required this.p1,
     required this.p2,
@@ -165,8 +171,4 @@ class TerminalHighlight {
     }
     return BufferRangeLine(p1.offset, p2.offset);
   }
-
-  void dispose() {
-    owner.removeHighlight(this);
-  }
 }

+ 17 - 0
test/src/ui/controller_test.dart

@@ -77,4 +77,21 @@ void main() {
       expect(terminalView.selection, isNull);
     });
   });
+
+  group('TerminalController.highlight', () {
+    test('works', () {
+      final terminal = Terminal();
+      final controller = TerminalController();
+
+      final highlight = controller.highlight(
+        p1: terminal.buffer.createAnchor(5, 5),
+        p2: terminal.buffer.createAnchor(5, 10),
+        color: Colors.yellow,
+      );
+      assert(controller.highlights.length == 1);
+
+      highlight.dispose();
+      assert(controller.highlights.isEmpty);
+    });
+  });
 }