docs/src/content/docs/principles/terminal.md
The SSH terminal is one of the most complex features, built on a custom xterm.dart fork.
┌─────────────────────────────────────────────┐
│ Terminal UI Layer │
│ - Tab management │
│ - Virtual keyboard │
│ - Text selection │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ xterm.dart Emulator │
│ - PTY (Pseudo Terminal) │
│ - VT100/ANSI emulation │
│ - Rendering engine │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ SSH Client Layer │
│ - SSH session │
│ - Channel management │
│ - Data streaming │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Remote Server │
│ - Shell process │
│ - Command execution │
└─────────────────────────────────────────────┘
Future<TerminalSession> createSession(Spi spi) async {
// 1. Get SSH client
final client = await genClient(spi);
// 2. Create PTY
final pty = await client.openPty(
term: 'xterm-256color',
cols: 80,
rows: 24,
);
// 3. Initialize terminal emulator
final terminal = Terminal(
backend: PtyBackend(pty),
);
// 4. Setup resize handler
terminal.onResize.listen((size) {
pty.resize(size.cols, size.rows);
});
return TerminalSession(
terminal: terminal,
pty: pty,
client: client,
);
}
The xterm.dart fork provides:
VT100/ANSI Emulation:
Rendering:
User Input
↓
Virtual Keyboard / Physical Keyboard
↓
Terminal Emulator (key → escape sequence)
↓
SSH Channel (send)
↓
Remote PTY
↓
Remote Shell
↓
Command Output
↓
SSH Channel (receive)
↓
Terminal Emulator (parse ANSI codes)
↓
Render to Screen
class TerminalTabs {
final Map<String, TabData> _tabs = {};
String? _activeTabId;
void createTab(Server server) {
final id = _generateTabId(server);
_tabs[id] = TabData(
id: id,
name: _generateTabName(server),
session: createSession(server),
);
_activeTabId = id;
}
String _generateTabName(Server server) {
final count = _tabs.values
.where((t) => t.name.startsWith(server.name))
.length;
return count == 0 ? server.name : '${server.name}($count)';
}
}
Tabs maintain state across navigation:
iOS:
Android:
| Button | Action |
|---|---|
| Toggle | Show/hide system keyboard |
| Ctrl | Send Ctrl modifier |
| Alt | Send Alt modifier |
| SFTP | Open current directory |
| Clipboard | Copy/Paste context-aware |
| Snippets | Execute snippet |
String encodeKey(Key key) {
switch (key) {
case Key.enter:
return '\r';
case Key.tab:
return '\t';
case Key.escape:
return '\x1b';
case Key.ctrlC:
return '\x03';
// ... more keys
}
}
class TextSelection {
final BufferRange range;
final String text;
void copyToClipboard() {
Clipboard.setData(ClipboardData(text: text));
}
}
class TerminalDimensions {
static Size calculate(double fontSize, Size screenSize) {
final charWidth = fontSize * 0.6; // Monospace aspect ratio
final charHeight = fontSize * 1.2;
final cols = (screenSize.width / charWidth).floor();
final rows = (screenSize.height / charHeight).floor();
return Size(cols.toDouble(), rows.toDouble());
}
}
GestureDetector(
onScaleStart: () => _baseFontSize = currentFontSize,
onScaleUpdate: (details) {
final newFontSize = _baseFontSize * details.scale;
resize(newFontSize);
},
)
const colorMap = {
0: Color(0x000000), // Black
1: Color(0x800000), // Red
2: Color(0x008000), // Green
3: Color(0x808000), // Yellow
4: Color(0x000080), // Blue
5: Color(0x800080), // Magenta
6: Color(0x008080), // Cyan
7: Color(0xC0C0C0), // White
// ... 256-color palette
};
void copySelection() {
final selected = terminal.getSelection();
Clipboard.setData(ClipboardData(text: selected));
}
Future<void> pasteClipboard() async {
final data = await Clipboard.getData('text/plain');
if (data?.text != null) {
terminal.paste(data!.text!);
}
}
void executeSnippet(Snippet snippet) {
final formatted = formatSnippet(snippet);
terminal.paste(formatted);
terminal.paste('\r'); // Execute
}
void openSftp() async {
final cwd = await terminal.getCurrentWorkingDirectory();
Navigator.push(
context,
SftpPage(initialPath: cwd),
);
}
Timer.periodic(Duration(seconds: 30), (_) {
if (terminal.isActive) {
terminal.send('\x00'); // NUL - no-op keep-alive
}
});