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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ lib/
## Learned Workspace Facts

- Home navigation uses `AppTab` (Client, Agents, Logs) with an `IndexedStack`.
- OpenCode integrates through `opencode_api` and `OpenCodeConnectionService`; sessions are scoped by a `directory` query param; `getConfig`/`updateConfig` exposed for server-side config.
- `SSHProfile` includes `agentPort` (default 5000) and `useHttps`; `agentBaseUrl` builds the OpenCode URL.
- OpenCode V2 is talked to by `OpenCodeConnectionService` over Dio (`/api/info`, `/api/session`, `/api/event`, etc.) with HTTP Basic auth; responses are mapped into `opencode_api` V1 UI models. Do not call legacy V1 routes like `/global/health` (they can return SPA HTML 200).
- Sessions are scoped by a `directory` query/`location.directory`; `getConfig` reads `/api/config` documents; arbitrary config PATCH is not available on V2 (experimental shell patch only). Insecure TLS is opt-in via `allowSelfSignedCerts` (never implied by HTTPS alone) and is limited to the profile host.
- `SSHProfile` includes `agentPort` (default 5000), `useHttps`, `allowSelfSignedCerts`, `agentUsername` (default `opencode`), and `agentPassword` (secure storage); `agentBaseUrl` builds the OpenCode URL. Agent HTTP Basic auth uses `effectiveAgentUsername` / `effectiveAgentPassword` (falls back to SSH password when agent password is unset).
- Client terminal AI command generation lives in the accessory bar wand + top `AiCommandOverlay` (not the shortcut bar).
- `AgentProvider` is a `ChangeNotifierProxyProvider` that routes `onLog` to `SSHProvider.addLog`.
- Agent directory path persists in ConfigService as `agent_last_directory`.
- Android widgets (`SshQuickConnectWidget`, `AgentQuickConnectWidget`) sync profiles via `WidgetProfileService` and deep-link via `sshapp://widget/ssh|agent?profileId=` through `WidgetLaunchHandler`.
Expand All @@ -148,7 +150,7 @@ lib/
- Agents tab uses master-detail navigation on all screen sizes with `PopScope` and Android predictive back.
- `AppLifecycleService` and `ConnectionForegroundService` keep SSH/agent sessions alive when Android is backgrounded.
- Agent chat groups consecutive same-role messages via `groupAgentMessages` in `lib/utils/agent_message_grouping.dart`; rendering uses `AgentMessageBubble` and `AgentMessagePartTile`.
- `OpenCodeRemoteConfigService` imports Windows host config over SSH from `%USERPROFILE%\.config\opencode\`; Android build uses AGP 9.0.1 with vendored `packages/home_widget`.
- `OpenCodeRemoteConfigService` imports Windows host config over SSH from `%USERPROFILE%\.config\opencode\`; Android build uses AGP 9.3.1 / Kotlin 2.4.20 with vendored `packages/home_widget`.

## Cursor Cloud specific instructions

Expand Down
4 changes: 2 additions & 2 deletions android/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ pluginManagement {

plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("com.android.application") version "9.3.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.20" apply false
id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
}

Expand Down
10 changes: 9 additions & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';
import 'package:provider/provider.dart';

import 'constants/app_metadata.dart';
Expand Down Expand Up @@ -106,6 +106,14 @@ class MyApp extends StatelessWidget {
darkTheme: settings.appTheme == AppTheme.hacker
? hackerTheme
: darkTheme,
builder: (context, child) {
// Bridge ThemeData/MaterialLocalizations for packages that still
// import package:flutter/material.dart (xterm, share_plus, etc.).
// ignore: deprecated_member_use
return MaterialUiCompatibilityBridge(
child: child ?? const SizedBox.shrink(),
);
},
home: const SplashScreen(),
),
);
Expand Down
2 changes: 1 addition & 1 deletion lib/models/home_toolbar_action.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';

/// Actions that can appear in the home AppBar or its overflow menu.
enum HomeToolbarAction {
Expand Down
34 changes: 34 additions & 0 deletions lib/models/ssh_profile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class SSHProfile {
final String? startupCommand;
final int agentPort;
final bool useHttps;

/// When true with [useHttps], agent TLS accepts self-signed certificates.
/// Off by default — must be opted in per profile (LAN OpenCode).
final bool allowSelfSignedCerts;
final String agentUsername;
final String? agentPassword;
final SessionManager sessionManager;

SSHProfile({
Expand All @@ -28,12 +34,28 @@ class SSHProfile {
this.startupCommand,
this.agentPort = 5000,
this.useHttps = false,
this.allowSelfSignedCerts = false,
this.agentUsername = 'opencode',
this.agentPassword,
this.sessionManager = SessionManager.none,
}) : id = id ?? const Uuid().v4();

String get agentBaseUrl =>
'${useHttps ? 'https' : 'http'}://$host:$agentPort';

/// HTTP Basic username for OpenCode serve (never empty).
String get effectiveAgentUsername =>
agentUsername.isNotEmpty ? agentUsername : 'opencode';

/// OpenCode serve password, falling back to SSH password when unset.
String get effectiveAgentPassword {
final agent = agentPassword;
if (agent != null && agent.isNotEmpty) {
return agent;
}
return password ?? '';
}

SSHProfile copyWith({
String? id,
String? name,
Expand All @@ -46,6 +68,9 @@ class SSHProfile {
String? startupCommand,
int? agentPort,
bool? useHttps,
bool? allowSelfSignedCerts,
String? agentUsername,
String? agentPassword,
SessionManager? sessionManager,
}) {
return SSHProfile(
Expand All @@ -60,6 +85,9 @@ class SSHProfile {
startupCommand: startupCommand ?? this.startupCommand,
agentPort: agentPort ?? this.agentPort,
useHttps: useHttps ?? this.useHttps,
allowSelfSignedCerts: allowSelfSignedCerts ?? this.allowSelfSignedCerts,
agentUsername: agentUsername ?? this.agentUsername,
agentPassword: agentPassword ?? this.agentPassword,
sessionManager: sessionManager ?? this.sessionManager,
);
}
Expand All @@ -78,6 +106,9 @@ class SSHProfile {
'startupCommand': startupCommand,
'agentPort': agentPort,
'useHttps': useHttps,
'allowSelfSignedCerts': allowSelfSignedCerts,
'agentUsername': agentUsername,
if (includePassword) 'agentPassword': agentPassword,
'sessionManager': sessionManager.name,
};
}
Expand All @@ -95,6 +126,9 @@ class SSHProfile {
startupCommand: json['startupCommand'] as String?,
agentPort: json['agentPort'] as int? ?? 5000,
useHttps: json['useHttps'] as bool? ?? false,
allowSelfSignedCerts: json['allowSelfSignedCerts'] as bool? ?? false,
agentUsername: json['agentUsername'] as String? ?? 'opencode',
agentPassword: json['agentPassword'] as String?,
sessionManager: SessionManagerX.fromStorage(
json['sessionManager'] as String?,
),
Expand Down
56 changes: 40 additions & 16 deletions lib/providers/agent_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,23 +47,29 @@ class AgentProvider extends ChangeNotifier {
}

Future<AgentConnection> connectFromProfile(SSHProfile profile) async {
final username =
profile.username.isNotEmpty ? profile.username : 'opencode';
final service = OpenCodeConnectionService(
baseUrl: profile.agentBaseUrl,
username: username,
password: profile.password ?? '',
username: profile.effectiveAgentUsername,
password: profile.effectiveAgentPassword,
allowInsecureTls: profile.useHttps && profile.allowSelfSignedCerts,
);

try {
await service.connect();
final sessions = sortSessionsByUpdatedDesc(await service.getSessions());
final savedDirectory = await _config.getAgentLastDirectory();
final directory = (savedDirectory != null && savedDirectory.isNotEmpty)
? savedDirectory
: null;
final sessions = sortSessionsByUpdatedDesc(
await service.getSessions(directory: directory),
);
final connection = AgentConnection(
id: const Uuid().v4(),
profile: profile,
service: service,
sessions: sessions,
isConnected: true,
selectedDirectory: directory,
);

_wireConnection(connection);
Expand Down Expand Up @@ -92,8 +98,9 @@ class AgentProvider extends ChangeNotifier {
final profile = SSHProfile(
name: name,
host: Uri.parse(url).host,
username: username,
password: password,
username: 'unused',
agentUsername: username,
agentPassword: password,
agentPort: Uri.parse(url).port == 0 ? 5000 : Uri.parse(url).port,
useHttps: url.startsWith('https'),
);
Expand All @@ -102,21 +109,23 @@ class AgentProvider extends ChangeNotifier {

Future<AgentConnection> connectToLocalDesktop({
required int port,
String username = 'opencode',
String password = '',
}) async {
final profile = SSHProfile(
name: 'Local Desktop',
host: '127.0.0.1',
username: 'opencode',
password: password,
username: 'unused',
agentUsername: username,
agentPassword: password,
agentPort: port,
);

const username = 'opencode';
final service = OpenCodeConnectionService(
baseUrl: profile.agentBaseUrl,
username: username,
password: password,
username: profile.effectiveAgentUsername,
password: profile.effectiveAgentPassword,
allowInsecureTls: profile.useHttps && profile.allowSelfSignedCerts,
);

try {
Expand Down Expand Up @@ -504,15 +513,25 @@ class AgentProvider extends ChangeNotifier {
return;
}

if (type == 'permission.asked') {
if (type == 'permission.asked' ||
(type.contains('permission') &&
!type.contains('reply') &&
!type.contains('list') &&
!type.contains('saved'))) {
final connection = _findConnection(connectionId);
if (connection == null) return;
final permissionId =
props['permissionID'] as String? ?? props['id'] as String? ?? '';
if (permissionId.isEmpty) return;
_pendingPermission = AgentPermissionRequest(
connectionId: connectionId,
sessionId: props['sessionID'] as String? ?? '',
permissionId: props['permissionID'] as String? ?? '',
sessionId: props['sessionID'] as String? ??
props['sessionId'] as String? ??
'',
permissionId: permissionId,
message: props['message'] as String? ??
props['description'] as String? ??
props['action'] as String? ??
'Permission requested',
service: connection.service,
);
Expand All @@ -525,7 +544,12 @@ class AgentProvider extends ChangeNotifier {
type.contains('message')) {
// ignore: unawaited_futures
_reloadMessages(connectionId);
if (type.startsWith('session.')) {
if (type.startsWith('session.') &&
(type.contains('create') ||
type.contains('update') ||
type.contains('remove') ||
type.contains('idle') ||
type.endsWith('.updated'))) {
// ignore: unawaited_futures
refreshSessions(connectionId);
}
Expand Down
71 changes: 61 additions & 10 deletions lib/providers/settings_provider.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:material_ui/material_ui.dart';

import '../models/ai_provider.dart';
import '../models/home_toolbar_action.dart';
Expand Down Expand Up @@ -45,6 +45,7 @@ class SettingsProvider extends ChangeNotifier {
List<KeyboardShortcut> _shortcuts = [];
bool _isLoaded = false;
double _terminalFontSize = 12.0;
bool _terminalPinchToZoom = false;
bool _showMobileShortcutBar = false;
int _defaultAgentPort = 5000;
TerminalFontFamily _terminalFontFamily = TerminalFontFamily.monospace;
Expand All @@ -70,7 +71,11 @@ class SettingsProvider extends ChangeNotifier {
List<KeyboardShortcut> get shortcuts => _shortcuts;
bool get isLoaded => _isLoaded;
double get terminalFontSize => _terminalFontSize;
bool get terminalPinchToZoom => _terminalPinchToZoom;
bool get showMobileShortcutBar => _showMobileShortcutBar;

static const double kTerminalFontSizeMin = 6;
static const double kTerminalFontSizeMax = 24;
int get defaultAgentPort => _defaultAgentPort;
TerminalFontFamily get terminalFontFamily => _terminalFontFamily;
TerminalFontWeight get terminalFontWeight => _terminalFontWeight;
Expand Down Expand Up @@ -136,19 +141,33 @@ class SettingsProvider extends ChangeNotifier {

final shortcutsData = settings['shortcuts'] as List<dynamic>?;
if (shortcutsData != null && shortcutsData.isNotEmpty) {
_shortcuts = shortcutsData
.map((e) =>
KeyboardShortcut.fromJson(Map<String, dynamic>.from(e as Map)))
final loaded = shortcutsData
.map((e) => KeyboardShortcut.fromJson(
Map<String, dynamic>.from(e as Map),
))
.toList();
_shortcuts = _dedupeShortcuts(loaded);
if (_shortcuts.length != loaded.length) {
// Persist cleanup so duplicate chips do not return next launch.
await _saveSetting(
'shortcuts',
_shortcuts.map((s) => s.toJson()).toList(),
);
}
} else {
_shortcuts = KeyboardShortcut.defaults;
}

final fontSizeVal = settings['terminalFontSize'];
if (fontSizeVal != null) {
_terminalFontSize = (fontSizeVal as num).toDouble();
_terminalFontSize = (fontSizeVal as num).toDouble().clamp(
kTerminalFontSizeMin,
kTerminalFontSizeMax,
);
}

_terminalPinchToZoom = settings['terminalPinchToZoom'] as bool? ?? false;

_showMobileShortcutBar =
settings['showMobileShortcutBar'] as bool? ?? false;

Expand Down Expand Up @@ -276,9 +295,27 @@ class SettingsProvider extends ChangeNotifier {
notifyListeners();
}

Future<void> setTerminalFontSize(double size) async {
_terminalFontSize = size;
await _saveSetting('terminalFontSize', size);
Future<void> setTerminalFontSize(
double size, {
bool persist = true,
}) async {
final clamped = size.clamp(kTerminalFontSizeMin, kTerminalFontSizeMax);
if (_terminalFontSize == clamped) {
if (persist) {
await _saveSetting('terminalFontSize', clamped);
}
return;
}
_terminalFontSize = clamped;
notifyListeners();
if (persist) {
await _saveSetting('terminalFontSize', clamped);
}
}

Future<void> setTerminalPinchToZoom(bool value) async {
_terminalPinchToZoom = value;
await _saveSetting('terminalPinchToZoom', value);
notifyListeners();
}

Expand Down Expand Up @@ -420,8 +457,8 @@ class SettingsProvider extends ChangeNotifier {
}

Future<void> updateShortcuts(List<KeyboardShortcut> shortcuts) async {
_shortcuts = shortcuts;
await _saveSetting('shortcuts', shortcuts.map((s) => s.toJson()).toList());
_shortcuts = _dedupeShortcuts(shortcuts);
await _saveSetting('shortcuts', _shortcuts.map((s) => s.toJson()).toList());
notifyListeners();
}

Expand All @@ -431,6 +468,20 @@ class SettingsProvider extends ChangeNotifier {
notifyListeners();
}

/// Keeps the first chip for each action+row pair.
static List<KeyboardShortcut> _dedupeShortcuts(
List<KeyboardShortcut> shortcuts,
) {
final seen = <String>{};
final result = <KeyboardShortcut>[];
for (final shortcut in shortcuts) {
final key = '${shortcut.action.name}:${shortcut.row}';
if (!seen.add(key)) continue;
result.add(shortcut);
}
return result;
}

Future<void> _saveSetting(String key, dynamic value) async {
final settings = await _config.getSettings();
settings[key] = value;
Expand Down
Loading
Loading