Merge main into windows-support
Brings windows-support up to date with main (T-404/405/406, T-413–416, T-421, the T-422 workspace-lifecycle epic, and the 2.4.0 release). Conflict resolutions: - terminal_pane.dart: keep the Windows PowerShell shell selection and main's workspace-cwd fix (T-381) together. - tool_check.dart: accept main's deletion (dead, unreferenced code). - CHANGELOG.md: keep both Unreleased sections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-1151
File diff suppressed because it is too large
Load Diff
@@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
|
||||
// breaks the cluster at every level, including L3, so parallel agents
|
||||
// never merge into one Activity card.
|
||||
if (isAgentTool(name)) return false;
|
||||
// A Workflow run is a first-class orchestration card too (T-416): it owns
|
||||
// the live agent fan-out, so it never folds into a generic Activity card.
|
||||
if (name == 'Workflow') return false;
|
||||
// The Edit/Write call stays first-class with its diff at L1/L2.
|
||||
if (level == FoldLevel.everything) return true;
|
||||
return !isDiffTool(name);
|
||||
|
||||
@@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
|
||||
/// Returns a change stream for [dir] (fires on any file event under it).
|
||||
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
|
||||
|
||||
/// Modest version-agnostic fallback used when the probe is unavailable, so
|
||||
/// the typeahead still offers the common built-ins.
|
||||
const List<String> kFallbackSlashCommands = [
|
||||
'add-dir',
|
||||
'agents',
|
||||
'clear',
|
||||
'compact',
|
||||
'config',
|
||||
'context',
|
||||
'cost',
|
||||
'doctor',
|
||||
'exit',
|
||||
'help',
|
||||
'init',
|
||||
'mcp',
|
||||
'memory',
|
||||
'model',
|
||||
'permissions',
|
||||
'resume',
|
||||
'review',
|
||||
'status',
|
||||
'usage',
|
||||
];
|
||||
/// Fallback used when the probe is unavailable. Mirrors the builtins a real
|
||||
/// CLI advertises in its stream-json `initialize` handshake (probed against
|
||||
/// 2.1.175) — i.e. the ones that genuinely work headless. It deliberately
|
||||
/// does NOT list TUI-only commands (config, permissions, status, doctor, …):
|
||||
/// this list doubles as the router's "advertised" set (T-411), and a TUI-only
|
||||
/// token here would be forwarded to the CLI and error. The composer unions
|
||||
/// [kClideOwnedCommands] on top for the typeahead (T-162).
|
||||
const List<String> kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage'];
|
||||
|
||||
class ClaudeConfig extends ChangeNotifier {
|
||||
ClaudeConfig({
|
||||
@@ -253,6 +238,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
_version = _parseVersion(await _guard(_versionRunner));
|
||||
await _readProbeCache();
|
||||
await _loadDiskConfig();
|
||||
if (_disposed) return; // activation fired-and-forgot; teardown won
|
||||
_startWatchers();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -296,8 +282,14 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set when [dispose] runs. The fire-and-forget [load] from extension
|
||||
/// activation checks this so a teardown racing an in-flight load can't
|
||||
/// notify (or start watchers on) a disposed notifier.
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_stopWatching();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import 'clipboard_paste.dart';
|
||||
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
||||
import 'conversation_controller.dart';
|
||||
import 'conversation_view.dart';
|
||||
import 'model_picker_card.dart';
|
||||
import 'permission_mode_control.dart';
|
||||
import 'prompt_card.dart';
|
||||
import 'session_index.dart';
|
||||
@@ -24,6 +25,7 @@ import 'slash_commands.dart';
|
||||
import 'stream_json_session.dart';
|
||||
import 'task_list.dart';
|
||||
import 'transcript_reader.dart';
|
||||
import 'workflow_run.dart';
|
||||
|
||||
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
||||
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
|
||||
@@ -71,7 +73,11 @@ class ClaudePane extends StatefulWidget {
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
StreamSubscription<SessionStatus>? _statusSub;
|
||||
StreamSubscription<SessionEnd>? _endSub;
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
StreamSubscription<Message>? _commandSub;
|
||||
StreamSubscription<String>? _modelErrorSub;
|
||||
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
|
||||
ConversationController? _conversation;
|
||||
StreamJsonSession? _session;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
@@ -80,6 +86,21 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
String? _error;
|
||||
String _statusLine = 'starting…';
|
||||
|
||||
/// One-shot fork source: seeds the first bind, then cleared so /clear,
|
||||
/// /resume, and respawns operate on this pane's own session (T-375).
|
||||
late String? _forkSource = widget.forkSourceId;
|
||||
|
||||
/// Whether a bare `/model` opened the picker in the interaction zone
|
||||
/// (T-408). An open prompt takes precedence; the picker shows once it
|
||||
/// resolves.
|
||||
bool _modelPickerOpen = false;
|
||||
bool _effortPickerOpen = false;
|
||||
bool _permissionPickerOpen = false;
|
||||
|
||||
/// Effort level this pane's session runs at (`--effort`, T-412). Null =
|
||||
/// the CLI default. Set by /effort; carried by every respawn.
|
||||
String? _effort;
|
||||
|
||||
bool _spawned = false;
|
||||
|
||||
/// Per-session composer draft (text + caret), held here so an unsent
|
||||
@@ -141,6 +162,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Cache the kernel for dispose() — ancestor lookups there are illegal,
|
||||
// and the old lookup-and-swallow leaked the settings listener on every
|
||||
// disposed pane (T-366).
|
||||
_kernel = ClideKernel.of(context);
|
||||
// Spawn once, after the kernel is available.
|
||||
if (!_spawned) {
|
||||
_spawned = true;
|
||||
@@ -155,6 +180,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// GlobalKey and spawns once, so without this it would keep the previous
|
||||
// repo's session after a switch (T-269).
|
||||
_projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
|
||||
// Sidebar controls (and any future surface) drive this pane's session by
|
||||
// publishing slash-command text on builtin.claude/command (T-414) —
|
||||
// executed through the exact _send routing the composer uses, so the
|
||||
// control and the typed command are one code path (D-6). Only the
|
||||
// primary pane listens: the controls target the primary session, and a
|
||||
// second listener would double-execute.
|
||||
if (widget.isPrimary) {
|
||||
_commandSub = ClideKernel.of(context).messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen((msg) {
|
||||
final text = msg.data['text'] as String?;
|
||||
if (text != null && text.isNotEmpty) _send(text);
|
||||
});
|
||||
}
|
||||
// Re-fold the conversation when the activity fold-level setting changes
|
||||
// (claude.activity.fold-level command, T-235).
|
||||
ClideKernel.of(context).settings.addListener(_onSettingsChanged);
|
||||
@@ -168,11 +205,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
void dispose() {
|
||||
activeClaudeConfig?.removeListener(_onConfigChanged);
|
||||
_kernel()?.settings.removeListener(_onSettingsChanged);
|
||||
_kernel?.settings.removeListener(_onSettingsChanged);
|
||||
_projectSub?.cancel();
|
||||
_commandSub?.cancel();
|
||||
_projectSub = null;
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
// The orchestrator owns the session, so disposing this pane does NOT kill
|
||||
// it — that's what lets a hidden/kept-alive pane keep its session (T-169).
|
||||
// A secondary tab being *closed* is a real teardown, so close its session;
|
||||
@@ -224,6 +268,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
Future<void> _rebindToActiveProject() async {
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
_modelPickerOpen = false;
|
||||
_effortPickerOpen = false;
|
||||
_permissionPickerOpen = false;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
|
||||
_conversation = null;
|
||||
_session = null;
|
||||
@@ -263,7 +316,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
|
||||
final ManagedSession managed;
|
||||
final forkSource = widget.forkSourceId;
|
||||
final forkSource = _forkSource;
|
||||
if (forkSource != null) {
|
||||
// Fork pane: branch source session into a new clide-managed session.
|
||||
// The clide-internal id is a fresh UUID; the real claude session id is
|
||||
@@ -271,12 +324,23 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_sessionId ??= freshSessionId();
|
||||
try {
|
||||
managed = await orch.spawn(
|
||||
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource),
|
||||
SpawnSpec(
|
||||
id: _orchId,
|
||||
role: 'fork ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
forkSourceSessionId: forkSource,
|
||||
effort: _effort,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
||||
return;
|
||||
}
|
||||
// One-shot: the fork source seeds only the FIRST bind. Leaving it set
|
||||
// made /clear re-fork the original conversation instead of clearing —
|
||||
// every later respawn must operate on this pane's own session (T-375).
|
||||
_forkSource = null;
|
||||
if (!mounted) return;
|
||||
setState(() => _statusLine = 'fork of $forkSource');
|
||||
} else {
|
||||
@@ -298,6 +362,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
cwd: repoRoot,
|
||||
resume: resume,
|
||||
transcriptPath: resume ? transcriptFile : null,
|
||||
effort: _effort,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -310,11 +375,14 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
_session = managed.session;
|
||||
_conversation = managed.conversation;
|
||||
// The wire never reports effort — record what this session was spawned
|
||||
// with so the status line / sidebar can show it (T-412).
|
||||
if (_effort != null) managed.session.noteEffort(_effort!);
|
||||
// Diagnostic (T-274 follow-up): record how this pane bound its session —
|
||||
// a fresh spawn vs connecting to existing on-disk history (the seed read
|
||||
// from the transcript/sidecar). Surfaces the resume path in `make run`.
|
||||
final seeded = _conversation?.items.length ?? 0;
|
||||
_kernel()?.log.info(
|
||||
_kernel?.log.info(
|
||||
'claude',
|
||||
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot — '
|
||||
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
|
||||
@@ -323,6 +391,36 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = s);
|
||||
});
|
||||
// Workflow runs arrive on out-of-band system events that add no
|
||||
// conversation item, so the view won't rebuild on its own — drive a
|
||||
// rebuild as the run map changes so the workflow card updates live (T-416).
|
||||
_workflowsSub = managed.session.workflowsStream.listen((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
});
|
||||
// A rejected /model change (unknown name) rolls back silently in the
|
||||
// status — say why out loud (T-408).
|
||||
_modelErrorSub = managed.session.modelErrors.listen((msg) {
|
||||
_kernel?.notify.warn(msg, title: 'model');
|
||||
});
|
||||
// Surface a dead process instead of letting it look thoughtful (T-361):
|
||||
// late binders read the replayed end; live sessions stream it.
|
||||
final alreadyEnded = managed.session.end;
|
||||
if (alreadyEnded != null) {
|
||||
_onSessionEnd(alreadyEnded);
|
||||
} else {
|
||||
_endSub = managed.session.endedStream.listen(_onSessionEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/// The claude process exited under this pane's live session. Stop looking
|
||||
/// busy, say so in the status line, and log the drained stderr tail —
|
||||
/// the diagnostics that used to vanish (T-361).
|
||||
void _onSessionEnd(SessionEnd end) {
|
||||
if (!mounted) return;
|
||||
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
|
||||
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
|
||||
setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart');
|
||||
}
|
||||
|
||||
// Send composed text to Claude over the stream-json channel. Commands clide
|
||||
@@ -342,10 +440,152 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
case 'fork':
|
||||
_forkSession();
|
||||
return;
|
||||
case 'model':
|
||||
_modelCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'effort':
|
||||
_effortCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'permissions':
|
||||
_permissionsCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'status':
|
||||
_openMetaTab('activity');
|
||||
return;
|
||||
case 'config':
|
||||
case 'mcp':
|
||||
case 'agents':
|
||||
case 'hooks':
|
||||
_openMetaTab('config');
|
||||
return;
|
||||
case 'memory':
|
||||
_openMemory();
|
||||
return;
|
||||
case 'help':
|
||||
_helpCommand();
|
||||
return;
|
||||
}
|
||||
// Route the rest (T-411): a known TUI-only builtin never reaches the
|
||||
// session — forwarded it would error (or, un-advertised, bracket-paste to
|
||||
// the model as literal text, burning a turn). It becomes a local notice
|
||||
// card pointing at the clide-native way instead.
|
||||
final advertised = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands;
|
||||
if (routeSlashCommand(text, advertised: advertised) == SlashRoute.unavailable) {
|
||||
_session?.addLocalNotice(tuiOnlyNotice(slashCommandToken(text)!));
|
||||
return;
|
||||
}
|
||||
_session?.send(text);
|
||||
}
|
||||
|
||||
/// clide-owned `/model` (T-408): with an argument, set the model directly;
|
||||
/// bare, open the picker in the interaction zone (D-78).
|
||||
void _modelCommand(String arg) {
|
||||
if (_session == null) return;
|
||||
if (arg.isNotEmpty) {
|
||||
_session!.setModel(arg);
|
||||
return;
|
||||
}
|
||||
setState(() => _modelPickerOpen = true);
|
||||
}
|
||||
|
||||
void _pickModel(String value) {
|
||||
_session?.setModel(value);
|
||||
_closeModelPicker();
|
||||
}
|
||||
|
||||
void _closeModelPicker() {
|
||||
setState(() => _modelPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
/// clide-owned `/effort` (T-412): with a level, respawn-with-resume carrying
|
||||
/// `--effort`; bare, open the picker. No set_effort control subtype exists
|
||||
/// (probed 2.1.175), so the respawn IS the mechanism — resume keeps the
|
||||
/// conversation, only the process restarts.
|
||||
void _effortCommand(String arg) {
|
||||
if (_session == null) return;
|
||||
if (arg.isEmpty) {
|
||||
setState(() => _effortPickerOpen = true);
|
||||
return;
|
||||
}
|
||||
if (!kEffortLevels.any((l) => l.value == arg)) {
|
||||
_session!.addLocalNotice('unknown effort "$arg" — levels: ${kEffortLevels.map((l) => l.value).join(', ')}');
|
||||
return;
|
||||
}
|
||||
_setEffort(arg);
|
||||
}
|
||||
|
||||
void _pickEffort(String value) {
|
||||
_closeEffortPicker();
|
||||
_setEffort(value);
|
||||
}
|
||||
|
||||
void _closeEffortPicker() {
|
||||
setState(() => _effortPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
void _setEffort(String level) {
|
||||
final sid = _sessionId;
|
||||
if (sid == null) return;
|
||||
_effort = level;
|
||||
_kernel?.notify.info('effort $level — restarting the session to apply', title: 'effort');
|
||||
unawaited(_respawnWithSession(sid));
|
||||
}
|
||||
|
||||
/// clide-owned `/permissions` (T-413): with a mode, set it directly over
|
||||
/// set_permission_mode; bare, open a picker — the same interaction-zone
|
||||
/// pattern as /model and /effort.
|
||||
void _permissionsCommand(String arg) {
|
||||
final s = _session;
|
||||
if (s == null) return;
|
||||
if (arg.isEmpty) {
|
||||
setState(() => _permissionPickerOpen = true);
|
||||
return;
|
||||
}
|
||||
if (!kPermissionModes.any((m) => m.value == arg)) {
|
||||
s.addLocalNotice('unknown permission mode "$arg" — modes: ${kPermissionModes.map((m) => m.value).join(', ')}');
|
||||
return;
|
||||
}
|
||||
s.setPermissionMode(arg);
|
||||
}
|
||||
|
||||
void _pickPermissionMode(String value) {
|
||||
_closePermissionPicker();
|
||||
_session?.setPermissionMode(value);
|
||||
}
|
||||
|
||||
void _closePermissionPicker() {
|
||||
setState(() => _permissionPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
/// Navigate to the Claude sidebar and select a sub-tab (T-413): the
|
||||
/// /status//config//mcp//agents//hooks commands land here.
|
||||
void _openMetaTab(String tab) {
|
||||
final k = _kernel;
|
||||
if (k == null) return;
|
||||
k.panels.activateTab(Slots.sidebar, 'claude.meta');
|
||||
k.messages.publish('builtin.claude', 'meta.tab', {'tab': tab});
|
||||
}
|
||||
|
||||
/// clide-owned `/memory` (T-413): open the workspace CLAUDE.md in the editor.
|
||||
void _openMemory() {
|
||||
final root = _repoRoot;
|
||||
if (root == null) return;
|
||||
unawaited(_ipc()?.request('editor.open', args: {'path': '$root/CLAUDE.md'}));
|
||||
}
|
||||
|
||||
/// clide-owned `/help` (T-413): a local summary card — never the CLI's TUI
|
||||
/// help, which doesn't exist headless.
|
||||
void _helpCommand() {
|
||||
final advertised = (activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands).where((c) => !kClideOwnedCommands.contains(c)).toList()..sort();
|
||||
_session?.addLocalNotice(
|
||||
'clide commands: ${(kClideOwnedCommands.toList()..sort()).map((c) => '/$c').join(' ')}\n'
|
||||
'claude commands & skills: ${advertised.map((c) => '/$c').join(' ')}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a submitted prompt in the active session's history (T-163),
|
||||
/// de-duping immediate repeats. Empty/whitespace prompts are skipped.
|
||||
void _appendHistory(String text) {
|
||||
@@ -370,7 +610,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
/// background tap must never pull focus from (or resurrect) the composer
|
||||
/// over an open prompt.
|
||||
void _focusComposerOnTap() {
|
||||
if (_session?.pendingPrompt != null) return;
|
||||
if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen || _permissionPickerOpen) return;
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
@@ -422,7 +662,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
/// re-bind the pane to it.
|
||||
Future<void> _resumeFlow() async {
|
||||
final root = _repoRoot;
|
||||
final dialog = _kernel()?.dialog;
|
||||
final dialog = _kernel?.dialog;
|
||||
if (root == null || dialog == null) return;
|
||||
final dir = Directory(claudeProjectDir(root));
|
||||
final sessions = await listSessions(dir);
|
||||
@@ -440,6 +680,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
Future<void> _respawnWithSession(String sessionId, {bool clearTranscript = false}) async {
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
_modelPickerOpen = false;
|
||||
_effortPickerOpen = false;
|
||||
_permissionPickerOpen = false;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old session
|
||||
// Erase only after the process is dead, so claude isn't mid-write.
|
||||
final root = _repoRoot;
|
||||
@@ -455,15 +704,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
// -- helpers --------------------------------------------------------------
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
DaemonClient? _ipc() => _kernel?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
try {
|
||||
return ClideKernel.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/// Cached in didChangeDependencies (T-366); see note there.
|
||||
KernelServices? _kernel;
|
||||
|
||||
// -- build ----------------------------------------------------------------
|
||||
|
||||
@@ -496,10 +740,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
onTap: _focusComposerOnTap,
|
||||
child: ConversationView(
|
||||
controller: _conversation!,
|
||||
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
|
||||
foldLevel: foldLevelFromName(_kernel?.settings.get<String>(kActivityFoldLevelKey)),
|
||||
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
||||
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
||||
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
|
||||
emptyState: ClaudeBanner(
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
workspace: _repoRoot,
|
||||
@@ -516,9 +761,36 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
),
|
||||
// An open prompt takes the composer's space and hides the text
|
||||
// input until it's answered, so interaction stays out of the
|
||||
// conversation stream (D-78).
|
||||
// conversation stream (D-78). The /model picker uses the same
|
||||
// slot; a prompt outranks it (T-408).
|
||||
if (prompt != null && _session != null)
|
||||
ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt)
|
||||
else if (_modelPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
models: _session!.availableModels.isEmpty ? kFallbackModels : _session!.availableModels,
|
||||
currentModel: _status.model,
|
||||
onPick: _pickModel,
|
||||
onCancel: _closeModelPicker,
|
||||
)
|
||||
else if (_effortPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
title: 'effort',
|
||||
models: kEffortLevels,
|
||||
currentModel: _status.effort,
|
||||
// Exact match — containment would mark `high` inside `xhigh`.
|
||||
isCurrent: (o, c) => c != null && o.value == c,
|
||||
onPick: _pickEffort,
|
||||
onCancel: _closeEffortPicker,
|
||||
)
|
||||
else if (_permissionPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
title: 'permissions',
|
||||
models: kPermissionModes,
|
||||
currentModel: _status.permissionMode,
|
||||
isCurrent: (o, c) => c != null && o.value == c,
|
||||
onPick: _pickPermissionMode,
|
||||
onCancel: _closePermissionPicker,
|
||||
)
|
||||
else
|
||||
StreamBuilder<bool>(
|
||||
stream: _session?.busyStream,
|
||||
|
||||
@@ -92,3 +92,41 @@ String formatTokenCount(int n) {
|
||||
if (n >= 1000) return '${(n / 1000).round()}k';
|
||||
return '$n';
|
||||
}
|
||||
|
||||
/// Parsed `/usage` output (T-415). The CLI answers a forwarded `/usage`
|
||||
/// headless and free (probed 2.1.175, num_turns 0) with plain text:
|
||||
///
|
||||
/// Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam)
|
||||
/// Current week (all models): 53% used · resets Jun 15, 6:59pm (…)
|
||||
/// Current week (Sonnet only): 0% used
|
||||
class ClaudeUsage {
|
||||
const ClaudeUsage({this.session, this.week, this.weekSonnet});
|
||||
|
||||
/// The value text per line (e.g. `15% used · resets Jun 12, 3:39pm`),
|
||||
/// timezone parenthetical stripped. Null when the line wasn't present.
|
||||
final String? session;
|
||||
final String? week;
|
||||
final String? weekSonnet;
|
||||
|
||||
bool get isEmpty => session == null && week == null && weekSonnet == null;
|
||||
}
|
||||
|
||||
/// Parse `/usage` response text into a [ClaudeUsage], or null when [text]
|
||||
/// isn't usage output. Tolerant of label drift: any `Current …: …% used`
|
||||
/// line is matched by its key phrase.
|
||||
ClaudeUsage? parseUsageText(String text) {
|
||||
if (!text.contains('% used')) return null;
|
||||
String? valueOf(String keyPhrase) {
|
||||
for (final line in text.split('\n')) {
|
||||
if (!line.contains(keyPhrase)) continue;
|
||||
final colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
// Strip the trailing timezone parenthetical — noise at sidebar width.
|
||||
return line.substring(colon + 1).replaceAll(RegExp(r'\s*\([^)]*\)\s*$'), '').trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final usage = ClaudeUsage(session: valueOf('Current session'), week: valueOf('(all models)'), weekSonnet: valueOf('(Sonnet only)'));
|
||||
return usage.isEmpty ? null : usage;
|
||||
}
|
||||
|
||||
@@ -15,13 +15,17 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
|
||||
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:clide/kernel/src/keymap/pane_key_nav.dart';
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
@@ -38,11 +42,18 @@ class ConversationView extends StatefulWidget {
|
||||
this.hiddenToolUseIds = const <String>{},
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.quietErrorToolUseIds = const <String>{},
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
this.foldLevel = FoldLevel.tools,
|
||||
});
|
||||
|
||||
final ConversationController controller;
|
||||
|
||||
/// Live Workflow runs keyed by their launching `Workflow` tool-use id
|
||||
/// (T-416). A `Workflow` tool-use card with a matching run renders the
|
||||
/// dedicated run card (phases, agent rows, status) instead of the generic
|
||||
/// tool card; absent (pre-progress, or on reload) it falls back to generic.
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
/// How aggressively consecutive meta items (tool calls/results, thinking)
|
||||
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
|
||||
final FoldLevel foldLevel;
|
||||
@@ -268,9 +279,13 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
void _onChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
// Follow the tail — jump to the bottom after the new item lays out.
|
||||
// Follow the tail — but only when already pinned to it. New items arrive
|
||||
// on every streamed token; jumping unconditionally yanks a reader who
|
||||
// scrolled up back to the bottom for the whole reply (T-368, twin of the
|
||||
// T-297 resize gate).
|
||||
if (!_atBottom) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scroll.hasClients) {
|
||||
if (_scroll.hasClients && _atBottom) {
|
||||
_scroll.jumpTo(_scroll.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
@@ -328,6 +343,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
workflows: widget.workflows,
|
||||
),
|
||||
FoldedCluster(:final items) => _ActivityCard(
|
||||
key: ValueKey('cluster.${items.first.uuid}'),
|
||||
@@ -339,6 +355,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
workflows: widget.workflows,
|
||||
),
|
||||
EditRun(:final edits) => _EditRunCard(
|
||||
key: ValueKey('edits.${edits.first.uuid}'),
|
||||
@@ -372,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
return list;
|
||||
},
|
||||
);
|
||||
return ColoredBox(
|
||||
final body = ColoredBox(
|
||||
color: tokens.panelBackground,
|
||||
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized,
|
||||
);
|
||||
// Vim nav scrolls the conversation while this region holds focus under the
|
||||
// vim preset (T-406): j/k by a line, ctrl+d/u by half a viewport, gg/G to
|
||||
// the ends — G also re-arms follow-tail so new output keeps it pinned.
|
||||
return PaneKeyNav(onNav: _onNav, child: body);
|
||||
}
|
||||
|
||||
/// One "line" of scroll for j/k — a few text rows' worth.
|
||||
static const double _lineScroll = 48;
|
||||
|
||||
void _onNav(NavIntent intent, int count) {
|
||||
if (!_scroll.hasClients) return;
|
||||
final p = _scroll.position;
|
||||
final half = p.viewportDimension / 2;
|
||||
switch (intent) {
|
||||
case NavDownIntent():
|
||||
_scrollBy(_lineScroll * count);
|
||||
case NavUpIntent():
|
||||
_scrollBy(-_lineScroll * count);
|
||||
case NavPageDownIntent():
|
||||
_scrollBy(half);
|
||||
case NavPageUpIntent():
|
||||
_scrollBy(-half);
|
||||
case NavTopIntent():
|
||||
_scroll.jumpTo(0);
|
||||
_atBottom = false;
|
||||
case NavBottomIntent():
|
||||
_scroll.jumpTo(p.maxScrollExtent);
|
||||
_atBottom = true; // re-arm follow-tail (T-297)
|
||||
case NavExpandOrRightIntent() || NavCollapseOrLeftIntent() || NavActivateIntent():
|
||||
break; // a reader pane has no expand/activate semantics
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollBy(double delta) {
|
||||
final p = _scroll.position;
|
||||
final target = (p.pixels + delta).clamp(0.0, p.maxScrollExtent);
|
||||
_scroll.jumpTo(target);
|
||||
_atBottom = (p.maxScrollExtent - target) <= _bottomEpsilon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +514,9 @@ class _BashLiveTailState extends State<_BashLiveTail> {
|
||||
if (source == null) return; // no file-backed source → muted note in build
|
||||
final term = Terminal(maxLines: 1000);
|
||||
_terminal = term;
|
||||
_follower = FileTailFollower(source, onData: (bytes) => term.write(utf8.decode(bytes, allowMalformed: true)));
|
||||
// writeBytes: the follower's chunk boundaries are arbitrary (it can even
|
||||
// start mid-rune by construction) — keep decode state across reads (T-373).
|
||||
_follower = FileTailFollower(source, onData: term.writeBytes);
|
||||
unawaited(_follower!.start());
|
||||
}
|
||||
|
||||
@@ -497,6 +554,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||
this.runByToolUseId = const <String, List<ConversationItem>>{},
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final ConversationItem item;
|
||||
@@ -532,6 +590,9 @@ class _ConversationTurn extends StatelessWidget {
|
||||
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
|
||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||
|
||||
/// Live Workflow runs keyed by launching tool-use id (T-416).
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = item;
|
||||
@@ -571,6 +632,17 @@ class _ConversationTurn extends StatelessWidget {
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
// CLI-local output (model "<synthetic>": a forwarded local command's
|
||||
// response or a clide-injected notice, T-411) is not Claude speaking —
|
||||
// framed + muted like the context card (T-306), attributed to clide.
|
||||
AssistantTextMessage() when i.synthetic => ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: 'clide',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
|
||||
// agent with a muted accent, never the coral "claude" brand (T-265). The
|
||||
// coral claudeAccent is reserved for the real main-thread Claude.
|
||||
@@ -680,6 +752,13 @@ class _ConversationTurn extends StatelessWidget {
|
||||
/// and its own per-item mark. An Agent/Task call also nests its visible
|
||||
/// sub-agent run in a second collapser below (T-264).
|
||||
Widget _toolUseCollapser(AssistantToolUse t) {
|
||||
// A Workflow tool-use with a live run (T-416) renders the dedicated run
|
||||
// card — phases, agent rows, status — instead of the generic tool card. No
|
||||
// run yet (pre-progress, or on reload where the system events are gone)
|
||||
// falls through to the generic collapser below.
|
||||
if (t.name == 'Workflow' && workflows[t.toolUseId] != null) {
|
||||
return _workflowCard(t, workflows[t.toolUseId]!);
|
||||
}
|
||||
final outcome = toolUseOutcomes[t.toolUseId];
|
||||
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
|
||||
final collapser = ClideCollapserCard(
|
||||
@@ -723,6 +802,99 @@ class _ConversationTurn extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// A dedicated card for a Workflow run (T-416): the harness's multi-agent
|
||||
/// orchestration. The collapser header carries the run's live status (spinner
|
||||
/// while running, check when done) and a `done/total agents` counter; the body
|
||||
/// lists each fanned-out agent — grouped under phase headers when the workflow
|
||||
/// declared phases — plus the run's usage and the orchestration script.
|
||||
Widget _workflowCard(AssistantToolUse t, WorkflowRun run) {
|
||||
final title = run.name ?? 'workflow';
|
||||
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
|
||||
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
|
||||
final detail = run.done ? (run.summary ?? run.description) : run.description;
|
||||
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
|
||||
return ClideCollapserCard(
|
||||
label: 'workflow',
|
||||
color: color,
|
||||
collapsedSummary: collapsedSummary,
|
||||
counter: counter,
|
||||
status: run.done ? ClideRunStatus.success : ClideRunStatus.running,
|
||||
children: [_workflowBody(t, run)],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _workflowBody(AssistantToolUse t, WorkflowRun run) {
|
||||
final agents = run.orderedAgents;
|
||||
final phases = run.orderedPhases;
|
||||
final rows = <Widget>[];
|
||||
if (phases.isEmpty) {
|
||||
rows.addAll(agents.map(_workflowAgentRow));
|
||||
} else {
|
||||
for (final p in phases) {
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 2),
|
||||
child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow));
|
||||
}
|
||||
// Agents the deltas never tagged with a phase still render, after the
|
||||
// phased groups, so nothing fanned out is silently dropped.
|
||||
rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow));
|
||||
}
|
||||
if (rows.isEmpty) {
|
||||
rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta));
|
||||
}
|
||||
|
||||
final script = t.input['script'];
|
||||
return ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: run.done ? tokens.statusSuccess : tokens.globalFocus,
|
||||
label: run.name ?? 'workflow',
|
||||
copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input),
|
||||
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows),
|
||||
extraSegments: [
|
||||
if (run.totalTokens != null && run.totalTokens! > 0)
|
||||
CardSegment(
|
||||
label: 'usage',
|
||||
child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
if (script is String)
|
||||
CardSegment(
|
||||
label: 'script',
|
||||
child: ClideCodeBlock(source: script, language: 'javascript'),
|
||||
),
|
||||
],
|
||||
margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH),
|
||||
);
|
||||
}
|
||||
|
||||
/// One agent row in a workflow card: a state glyph (spinner while running, a
|
||||
/// muted check once done), the agent's label, and its model (T-416).
|
||||
Widget _workflowAgentRow(WorkflowAgent a) {
|
||||
final done = a.state == WorkflowAgentState.done;
|
||||
final Widget glyph = done
|
||||
? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess)
|
||||
: ClideSpinner(size: 12, color: tokens.globalTextMuted);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 16, child: Center(child: glyph)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (a.model != null && a.model!.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The inner content card for a tool use (T-305): the call body + folded
|
||||
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
|
||||
/// collapse caret — the enclosing collapser owns collapse. Used both as a
|
||||
@@ -890,6 +1062,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
required this.resultByToolUseId,
|
||||
required this.promptsByToolUseId,
|
||||
required this.runByToolUseId,
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final List<ConversationItem> items;
|
||||
@@ -900,6 +1073,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -921,6 +1095,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
runByToolUseId: runByToolUseId,
|
||||
workflows: workflows,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
|
||||
import 'package:clide/builtin/claude/src/conversation_view.dart' show claudeAccent;
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/pane_context_status.dart';
|
||||
@@ -21,6 +22,18 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// D-6 contract (T-391): a failed command returns an ERROR envelope (non-zero
|
||||
/// CLI exit), never `ok` with an `error` field a script can't detect.
|
||||
IpcResponse _userErr(String msg, {String? hint}) => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: msg, hint: hint),
|
||||
);
|
||||
|
||||
IpcResponse _notFound(String msg) => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: msg),
|
||||
);
|
||||
|
||||
class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.claude';
|
||||
@@ -95,7 +108,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: show an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.show(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
|
||||
},
|
||||
@@ -106,7 +119,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: hide an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.hide(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
|
||||
},
|
||||
@@ -117,7 +130,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: close (kill) an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
await _orchestrator?.close(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
|
||||
},
|
||||
@@ -128,7 +141,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: mute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.mute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
|
||||
},
|
||||
@@ -139,7 +152,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: unmute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.unmute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
|
||||
},
|
||||
@@ -151,9 +164,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: inject a text turn into an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
final text = args.skip(1).join(' ');
|
||||
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
|
||||
if (text.isEmpty) return _userErr('missing message text');
|
||||
_orchestrator?.injectMessage(id, text);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
|
||||
},
|
||||
@@ -169,12 +182,12 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: set permission mode for an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
final mode = args.length >= 2 ? args[1] : null;
|
||||
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
|
||||
if (mode == null) return _userErr('missing mode (default|acceptEdits|plan|bypassPermissions)');
|
||||
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
|
||||
if (!valid.contains(mode)) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
|
||||
return _userErr('unknown mode "$mode"; use one of: ${valid.join(', ')}');
|
||||
}
|
||||
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
|
||||
@@ -188,7 +201,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: Cycle permission mode',
|
||||
run: (_) async {
|
||||
final managed = _orchestrator?.byId('primary');
|
||||
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
|
||||
if (managed == null) return _notFound('no primary session');
|
||||
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
|
||||
managed.session.setPermissionMode(next);
|
||||
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
|
||||
@@ -200,11 +213,12 @@ class ClaudeExtension extends ClideExtension {
|
||||
command: 'claude.task.reassign',
|
||||
title: 'Claude: reassign a shared task to an agent',
|
||||
run: (args) async {
|
||||
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
|
||||
if (args.length < 2) return _userErr('usage: <taskId> <sessionId>');
|
||||
final taskId = args[0];
|
||||
final toId = args[1];
|
||||
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
|
||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
|
||||
if (!ok) return _notFound('could not reassign task "$taskId" to "$toId"');
|
||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': true});
|
||||
},
|
||||
),
|
||||
// T-180: full team chat pane opened as a workspace tab.
|
||||
@@ -241,7 +255,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
command: 'claude.team-chat.post',
|
||||
title: 'Claude: post a message into the team channel as the user',
|
||||
run: (args) async {
|
||||
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
|
||||
if (args.isEmpty) return _userErr('usage: [@name] <text>');
|
||||
final raw = args.join(' ');
|
||||
String? recipient;
|
||||
String body = raw;
|
||||
@@ -269,15 +283,18 @@ class ClaudeExtension extends ClideExtension {
|
||||
run: (args) async {
|
||||
final sourceId = args.firstOrNull;
|
||||
if (sourceId == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
|
||||
return _userErr('usage: claude.agent.fork <sourceSessionId> [<cwd>]');
|
||||
}
|
||||
final orch = _orchestrator;
|
||||
if (orch == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'orchestrator unavailable'),
|
||||
);
|
||||
}
|
||||
final source = orch.byId(sourceId);
|
||||
if (source == null) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
|
||||
return _notFound('unknown session "$sourceId"');
|
||||
}
|
||||
final cwd = args.length >= 2 ? args[1] : source.cwd;
|
||||
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
|
||||
@@ -292,6 +309,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
slot: Slots.sidebar,
|
||||
title: 'Activity',
|
||||
icon: PhosphorIcons.byName('robot'),
|
||||
// Claude's accent marks Claude's own panel in the rail (T-418) —
|
||||
// nominative use per the licenses.yaml trademark note.
|
||||
iconColor: claudeAccent,
|
||||
priority: 60,
|
||||
build: (_) => const ClaudeMetaSidebar(),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/// The Activity tab: session controls, usage, stats (stats-cache.json), and
|
||||
/// the primary session's live runtime row. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395); session controls + the usage block are
|
||||
/// the power-panel additions (T-415).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_stats.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ActivityTabView extends StatelessWidget {
|
||||
const ActivityTabView({
|
||||
super.key,
|
||||
required this.stats,
|
||||
required this.primaryStatus,
|
||||
required this.config,
|
||||
this.usage,
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final ClaudeStats stats;
|
||||
final SessionStatus? primaryStatus;
|
||||
final ClaudeConfig? config;
|
||||
|
||||
/// Parsed `/usage` output for the usage block, refreshed via the refresh
|
||||
/// control (T-415). Null until the first refresh.
|
||||
final ClaudeUsage? usage;
|
||||
|
||||
/// Live Workflow runs in the primary session, keyed by launching tool-use id
|
||||
/// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with
|
||||
/// its done/total agent count and running/done state.
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
/// Publish a slash command for the primary pane to execute — the session
|
||||
/// controls are the same code path as typing the command (D-6).
|
||||
void _command(BuildContext context, String text) {
|
||||
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': text});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final latest = stats.latest;
|
||||
final u = usage;
|
||||
final sections = <MetaSection>[
|
||||
..._workflowSection(tokens),
|
||||
if (u != null)
|
||||
MetaSection('USAGE', [
|
||||
if (u.session != null) MetaRow('session', u.session!),
|
||||
if (u.week != null) MetaRow('week (all)', u.week!),
|
||||
if (u.weekSonnet != null) MetaRow('week (sonnet)', u.weekSonnet!),
|
||||
]),
|
||||
if (latest != null)
|
||||
MetaSection('TODAY', [
|
||||
MetaRow('messages', '${latest.messageCount}'),
|
||||
MetaRow('sessions', '${latest.sessionCount}'),
|
||||
MetaRow('tool calls', '${latest.toolCallCount}'),
|
||||
]),
|
||||
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
|
||||
..._runtimeSection(tokens),
|
||||
];
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
// SESSION control strip (T-415): drives the primary session through
|
||||
// the builtin.claude/command bus — identical to typing the command.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText('SESSION', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_control(context, tokens, 'clear', 'trash', '/clear'),
|
||||
_control(context, tokens, 'compact', 'arrows-in-simple', '/compact'),
|
||||
_control(context, tokens, 'fork', 'git-branch', '/fork'),
|
||||
_control(context, tokens, 'resume', 'clock-counter-clockwise', '/resume'),
|
||||
const Spacer(),
|
||||
_control(context, tokens, 'refresh usage', 'arrow-clockwise', '/usage'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (sections.isEmpty) metaPlaceholder('No activity recorded yet.') else ...metaTableChildren(tokens, sections),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _control(BuildContext context, SurfaceTokens tokens, String label, String glyph, String command) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '$label session',
|
||||
excludeSemantics: true,
|
||||
onTap: () => _command(context, command),
|
||||
child: ClideTappable(
|
||||
tooltip: '$label · $command',
|
||||
onTap: () => _command(context, command),
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: ClideIcon(PhosphorIcons.byName(glyph), size: 15, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// An aggregate WORKFLOWS section while one or more workflow runs exist this
|
||||
/// session (T-416): a row per run — its name and `done/total agents`, tinted
|
||||
/// focus while running and success once complete.
|
||||
List<MetaSection> _workflowSection(SurfaceTokens tokens) {
|
||||
final runs = workflows.values.toList();
|
||||
if (runs.isEmpty) return const [];
|
||||
return [
|
||||
MetaSection('WORKFLOWS', [
|
||||
for (final r in runs)
|
||||
MetaRow(
|
||||
r.name ?? r.taskId ?? 'workflow',
|
||||
r.agentCount == 0 ? (r.done ? 'done' : 'starting') : '${r.doneCount}/${r.agentCount} agents${r.done ? ' ✓' : ''}',
|
||||
valueColor: r.done ? tokens.statusSuccess : tokens.globalFocus,
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
|
||||
final st = primaryStatus;
|
||||
final skills = config?.skills.length;
|
||||
final rows = <MetaRow>[
|
||||
if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
|
||||
if (st?.effort != null) MetaRow('effort', st!.effort!),
|
||||
if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
|
||||
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
|
||||
if (skills != null) MetaRow('skills', '$skills'),
|
||||
];
|
||||
return rows.isEmpty ? const [] : [MetaSection('RUNTIME · primary', rows)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/// The Config tab (T-183): the settings table over [ClaudeConfig] plus the
|
||||
/// skills/agents/commands/hooks/permissions/MCP accordion. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in
|
||||
/// the parent (it survives tab switches) and arrives as a prop + toggle
|
||||
/// callback.
|
||||
///
|
||||
/// T-414 makes the settings table a control panel: model / effort /
|
||||
/// permission-mode rows are live popover controls. Picking an option
|
||||
/// publishes the explicit slash command (`/model sonnet`) on the
|
||||
/// `builtin.claude`/`command` channel; the primary Claude pane executes it
|
||||
/// through the same `_send` routing the composer uses — one implementation,
|
||||
/// two surfaces (D-6).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart' show ModelOption, kEffortLevels, kFallbackModels, kPermissionModes;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ConfigTabView extends StatelessWidget {
|
||||
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection, this.status, this.models});
|
||||
|
||||
final ClaudeConfig? config;
|
||||
|
||||
/// The primary session's live status — drives the control rows' current
|
||||
/// values. Null before the session reports (controls fall back to the
|
||||
/// probe/settings values).
|
||||
final SessionStatus? status;
|
||||
|
||||
/// Models selectable for the primary session (from its `initialize`
|
||||
/// response); falls back to [kFallbackModels].
|
||||
final List<ModelOption>? models;
|
||||
|
||||
/// Sections currently expanded — owned by the parent state.
|
||||
final Set<ConfigSection> expanded;
|
||||
final void Function(ConfigSection section) onToggleSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final cfg = config;
|
||||
if (cfg == null) {
|
||||
return metaPlaceholder('Claude environment not loaded.');
|
||||
}
|
||||
final settings = cfg.settings;
|
||||
final model = status?.model ?? cfg.probe?.model ?? settings['model']?.toString() ?? 'default';
|
||||
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
|
||||
final mode = status?.permissionMode ?? cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
|
||||
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
|
||||
|
||||
final children = <Widget>[
|
||||
// Pinned SETTINGS control panel — not collapsible.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
SettingControlRow(
|
||||
label: 'model',
|
||||
value: model,
|
||||
valueColor: tokens.globalFocus,
|
||||
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
|
||||
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
|
||||
command: 'model',
|
||||
),
|
||||
SettingControlRow(label: 'effort', value: effort, options: kEffortLevels, isActive: (o) => o.value == effort, command: 'effort'),
|
||||
SettingControlRow(
|
||||
label: 'permission mode',
|
||||
value: permissionModeLabel(mode),
|
||||
options: kPermissionModes,
|
||||
isActive: (o) => o.value == mode,
|
||||
command: 'permissions',
|
||||
),
|
||||
_configRow(tokens, 'output style', outputStyle),
|
||||
_configRow(tokens, 'source', '~/.claude + .claude'),
|
||||
|
||||
// ---- Accordion sections ----
|
||||
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section),
|
||||
|
||||
// Footer hint.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
];
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
/// One read-only key→value row in the pinned SETTINGS table.
|
||||
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(value, fontSize: kMetaFont, color: valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _sectionLabel(ConfigSection section) => switch (section) {
|
||||
ConfigSection.skills => 'SKILLS',
|
||||
ConfigSection.agents => 'AGENTS',
|
||||
ConfigSection.commands => 'COMMANDS',
|
||||
ConfigSection.hooks => 'HOOKS',
|
||||
ConfigSection.permissions => 'PERMISSIONS',
|
||||
ConfigSection.mcpServers => 'MCP SERVERS',
|
||||
};
|
||||
|
||||
int _sectionCount(ClaudeConfig config, ConfigSection section) => switch (section) {
|
||||
ConfigSection.skills => config.skills.length,
|
||||
ConfigSection.agents => config.agents.length,
|
||||
ConfigSection.commands => config.commands.length,
|
||||
ConfigSection.hooks => config.hooks.length,
|
||||
ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
|
||||
ConfigSection.mcpServers => config.mcpServers.length,
|
||||
};
|
||||
|
||||
Widget _accordion(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||
final isExpanded = expanded.contains(section);
|
||||
final children = isExpanded ? _sectionChildren(context, tokens, config, section) : const <Widget>[];
|
||||
return ClideAccordion(
|
||||
label: _sectionLabel(section),
|
||||
count: _sectionCount(config, section),
|
||||
expanded: isExpanded,
|
||||
onToggle: () => onToggleSection(section),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _sectionChildren(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||
switch (section) {
|
||||
case ConfigSection.skills:
|
||||
return [for (final skill in config.skills) _fileRow(context, tokens, skill.name, skill.path)];
|
||||
case ConfigSection.agents:
|
||||
return [for (final agent in config.agents) _fileRow(context, tokens, agent.name, agent.path)];
|
||||
case ConfigSection.commands:
|
||||
return [for (final cmd in config.commands) _fileRow(context, tokens, cmd.name, cmd.path)];
|
||||
case ConfigSection.hooks:
|
||||
return [
|
||||
for (final hook in config.hooks)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(hook.event, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
for (final cmd in hook.commands)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, top: 1),
|
||||
child: ClideText(cmd, fontSize: clideFontSmall, muted: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
case ConfigSection.permissions:
|
||||
return _permissionRows(tokens, config.permissions);
|
||||
case ConfigSection.mcpServers:
|
||||
return [
|
||||
for (final srv in config.mcpServers)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(srv.name, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row for file-backed items (skills, agents, commands).
|
||||
/// All config items are .md files — opens in the markdown reader panel
|
||||
/// via the kernel MessageBus (D-6, T-183).
|
||||
Widget _fileRow(BuildContext context, SurfaceTokens tokens, String name, String? path) {
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
|
||||
);
|
||||
if (path == null) return row;
|
||||
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: name,
|
||||
excludeSemantics: true,
|
||||
onTap: openMarkdown,
|
||||
child: ClideTappable(
|
||||
tooltip: path,
|
||||
onTap: openMarkdown,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders grouped allow/ask/deny permission rows, colour-coded by kind.
|
||||
List<Widget> _permissionRows(SurfaceTokens tokens, ClaudePermissions perms) {
|
||||
// allow → statusSuccess, ask → statusWarning, deny → statusError
|
||||
Color kindColor(ConfigPermKind k) => switch (k) {
|
||||
ConfigPermKind.allow => tokens.statusSuccess,
|
||||
ConfigPermKind.ask => tokens.statusWarning,
|
||||
ConfigPermKind.deny => tokens.statusError,
|
||||
};
|
||||
|
||||
String kindLabel(ConfigPermKind k) => switch (k) {
|
||||
ConfigPermKind.allow => 'allow',
|
||||
ConfigPermKind.ask => 'ask',
|
||||
ConfigPermKind.deny => 'deny',
|
||||
};
|
||||
|
||||
final groups = [(ConfigPermKind.allow, perms.allow), (ConfigPermKind.ask, perms.ask), (ConfigPermKind.deny, perms.deny)];
|
||||
|
||||
final rows = <Widget>[];
|
||||
for (final (kind, rules) in groups) {
|
||||
if (rules.isEmpty) continue;
|
||||
final color = kindColor(kind);
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText(kindLabel(kind), fontSize: clideFontSmall, color: color),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final rule in rules)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: ClideText(rule, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/// One live setting row (T-414): label + current value as a popover control on
|
||||
/// the owned anchored-menu primitive. Picking an option publishes the explicit
|
||||
/// slash command on `builtin.claude`/`command`; the primary Claude pane
|
||||
/// executes it through its normal `_send` routing — so the sidebar control and
|
||||
/// the typed command are literally the same code path (D-6).
|
||||
class SettingControlRow extends StatefulWidget {
|
||||
const SettingControlRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.isActive,
|
||||
required this.command,
|
||||
this.valueColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
/// Current value, displayed on the trigger.
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
final List<ModelOption> options;
|
||||
final bool Function(ModelOption option) isActive;
|
||||
|
||||
/// The slash-command token this control drives (`model`, `effort`,
|
||||
/// `permissions`); a pick publishes `/<command> <option.value>`.
|
||||
final String command;
|
||||
|
||||
@override
|
||||
State<SettingControlRow> createState() => _SettingControlRowState();
|
||||
}
|
||||
|
||||
class _SettingControlRowState extends State<SettingControlRow> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
|
||||
void _pick(String value) {
|
||||
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': '/${widget.command} $value'});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(widget.label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.start,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
for (final o in widget.options)
|
||||
ClideMenuItem(
|
||||
label: o.description.isEmpty ? o.displayName : '${o.displayName} — ${o.description}',
|
||||
active: widget.isActive(o),
|
||||
semanticLabel: '${widget.label}: ${o.displayName}',
|
||||
onSelect: () => _pick(o.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: '${widget.label}: ${widget.value}. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
tooltip: 'change ${widget.label}',
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => DecoratedBox(
|
||||
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClideText(widget.value, fontSize: kMetaFont, color: widget.valueColor ?? tokens.globalForeground, maxLines: 1),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// A single icon-button used by the roster row controls + task rows.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395). Promote to
|
||||
/// lib/widgets/ only when a second consumer appears.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MetaIconButton extends StatelessWidget {
|
||||
const MetaIconButton({super.key, required this.painter, required this.tooltip, required this.color, required this.onTap});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Icon-only button: expose the tooltip text as the Semantics button label
|
||||
// so AT (and widget tests) can find and activate it by name.
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
excludeSemantics: true,
|
||||
onTap: onTap,
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideIcon(painter, size: 12, color: hovered ? ClideTheme.of(ctx).surface.globalForeground : color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Inline text input for injecting a message into a session (T-171).
|
||||
/// Submits on Enter; Cancel is handled by the parent's icon button.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class InjectTextField extends StatelessWidget {
|
||||
const InjectTextField({super.key, required this.controller, required this.tokens, required this.onSubmit});
|
||||
|
||||
final TextEditingController controller;
|
||||
final SurfaceTokens tokens;
|
||||
final void Function(String text) onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
|
||||
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: onSubmit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/// Shared models + table geometry for the Claude meta sidebar's tabs.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The shared label-column width + row pitch the Activity and Config tables
|
||||
/// both use, so toggling between tabs keeps every value at the same x and y.
|
||||
const double kMetaLabelColumnWidth = 110;
|
||||
const double kMetaRowPitch = 6;
|
||||
|
||||
/// Type scale for the sidebar tables (T-414 styling pass): labels/values read
|
||||
/// at meta size (13) — the old 12px-everything read as bland and cramped.
|
||||
const double kMetaFont = clideFontMeta;
|
||||
|
||||
/// The sidebar's sub-tabs.
|
||||
enum SidebarTab { activity, team, config }
|
||||
|
||||
// T-183: accordion sections for the Config tab.
|
||||
enum ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
|
||||
|
||||
/// Permission kind for colour-coding in the Config tab (T-183).
|
||||
enum ConfigPermKind { allow, ask, deny }
|
||||
|
||||
class MetaSection {
|
||||
const MetaSection(this.header, this.rows);
|
||||
final String header;
|
||||
final List<MetaRow> rows;
|
||||
}
|
||||
|
||||
class MetaRow {
|
||||
const MetaRow(this.label, this.value, {this.valueColor});
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
}
|
||||
|
||||
/// The muted empty-state body shared by every tab.
|
||||
Widget metaPlaceholder(String text) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(text, muted: true, fontSize: kMetaFont),
|
||||
);
|
||||
|
||||
/// Key→value sections on the shared table geometry (Activity + Config).
|
||||
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) =>
|
||||
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections));
|
||||
|
||||
/// The table rows without the enclosing ListView, for tabs that compose extra
|
||||
/// widgets around the sections (the Activity tab's control strip, T-415).
|
||||
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
final s = sections[i];
|
||||
children.add(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
|
||||
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
);
|
||||
for (final r in s.rows) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// Clickable permission-mode badge shown in each roster row (T-181).
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Maps a permission-mode string to a single-letter badge label.
|
||||
String permissionModeBadgeLabel(String mode) => switch (mode) {
|
||||
'acceptEdits' => 'A',
|
||||
'plan' => 'P',
|
||||
'bypassPermissions' => 'B',
|
||||
_ => 'D', // default
|
||||
};
|
||||
|
||||
/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default.
|
||||
/// - Shift-click → shows the bypass confirm inline in the parent row.
|
||||
///
|
||||
/// The badge reflects the LIVE mode from `SessionStatus.permissionMode`
|
||||
/// (T-157). It is a custom painted label (no Material), consistent with the
|
||||
/// rendering stack rules (D-7, CLAUDE.md guardrails).
|
||||
class PermissionModeBadge extends StatelessWidget {
|
||||
const PermissionModeBadge({super.key, required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
|
||||
|
||||
final String mode;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
/// Called on a plain click — the parent cycles to the next safe mode.
|
||||
final VoidCallback onCycle;
|
||||
|
||||
/// Called on a shift-click — the parent shows the bypass confirm.
|
||||
final VoidCallback onBypass;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = permissionModeBadgeLabel(mode);
|
||||
final isBypass = mode == 'bypassPermissions';
|
||||
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
|
||||
|
||||
final tooltip =
|
||||
'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Permission mode: $label',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
width: 16,
|
||||
height: 14,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withAlpha(hovered ? 51 : 26),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
|
||||
),
|
||||
child: ClideText(label, fontSize: 9, color: badgeColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/// A single agent roster row: color dot + name + status sub-text +
|
||||
/// controls (T-171). Split out of claude_meta_sidebar.dart (T-395).
|
||||
///
|
||||
/// Controls (trailing region):
|
||||
/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click
|
||||
/// reaches bypassPermissions behind a confirm
|
||||
/// - eye / eye-slash — show / hide the session pane
|
||||
/// - speaker / speaker-slash — mute / unmute broker delivery
|
||||
/// - inject (chat icon) — expand the inline message input
|
||||
/// - fork (git-branch icon) — open a new pane branching from this session (T-172)
|
||||
/// - close (×) — kill the session
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/inject_field.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/permission_badge.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AgentRosterRow extends StatefulWidget {
|
||||
const AgentRosterRow({
|
||||
super.key,
|
||||
required this.member,
|
||||
required this.status,
|
||||
required this.orchestrator,
|
||||
required this.injectingAgentId,
|
||||
required this.injectController,
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
required this.onFork,
|
||||
});
|
||||
|
||||
final TeamMemberJoined member;
|
||||
final SessionStatus? status;
|
||||
final ClaudeSessionOrchestrator? orchestrator;
|
||||
|
||||
/// The member name currently in inject mode (null = none).
|
||||
final String? injectingAgentId;
|
||||
|
||||
/// Shared text controller for the inject field (cleared on submit/cancel).
|
||||
final TextEditingController injectController;
|
||||
|
||||
final void Function(String memberName) onToggleInject;
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
|
||||
/// Called when the badge cycles to a new [mode] string for this member.
|
||||
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
|
||||
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
|
||||
/// Called when the fork button is tapped (T-172). The session id of the
|
||||
/// member's managed session is passed so the host can open a fork pane.
|
||||
final void Function(String memberName) onFork;
|
||||
|
||||
@override
|
||||
State<AgentRosterRow> createState() => _AgentRosterRowState();
|
||||
}
|
||||
|
||||
class _AgentRosterRowState extends State<AgentRosterRow> {
|
||||
/// Whether the bypass-confirm inline prompt is showing.
|
||||
bool _confirmingBypass = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final managed = widget.orchestrator?.byMemberName(widget.member.name);
|
||||
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
|
||||
final st = widget.status;
|
||||
final model = st?.model ?? widget.member.model;
|
||||
final sub = [
|
||||
widget.member.agentType,
|
||||
if (model != null) shortModelLabel(model),
|
||||
if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!),
|
||||
if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx',
|
||||
].join(' · ');
|
||||
|
||||
final isVisible = managed?.visible ?? true;
|
||||
final isMuted = managed?.muted ?? false;
|
||||
final isInjecting = widget.injectingAgentId == widget.member.name;
|
||||
final currentMode = st?.permissionMode ?? 'default';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Color dot
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Name + status
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
// T-181: permission-mode badge (inline below the status sub-text).
|
||||
if (managed != null)
|
||||
PermissionModeBadge(
|
||||
mode: currentMode,
|
||||
tokens: tokens,
|
||||
onCycle: () {
|
||||
final next = _nextSafeMode(currentMode);
|
||||
widget.onSetPermissionMode(widget.member.name, next);
|
||||
},
|
||||
onBypass: () => setState(() => _confirmingBypass = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Trailing controls (T-171).
|
||||
// T-172 seam: append a fork icon button to this row.
|
||||
if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting),
|
||||
],
|
||||
),
|
||||
// Bypass confirm: replaces inject field area when active.
|
||||
if (_confirmingBypass) _buildBypassConfirm(tokens),
|
||||
// Inline inject-message field — visible only when toggled.
|
||||
if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Safe-mode cycle: default → acceptEdits → plan → default (T-181).
|
||||
static String _nextSafeMode(String current) {
|
||||
const cycle = ['default', 'acceptEdits', 'plan'];
|
||||
final idx = cycle.indexOf(current);
|
||||
return cycle[(idx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
Widget _buildBypassConfirm(SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Confirm
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Confirm bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: 'Confirm',
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Cancel
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Cancel bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
child: ClideTappable(
|
||||
tooltip: 'Cancel',
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Show / hide
|
||||
MetaIconButton(
|
||||
painter: isVisible ? PhosphorIcons.byName('eye') : PhosphorIcons.byName('eye-slash'),
|
||||
tooltip: isVisible ? 'Hide pane' : 'Show pane',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
|
||||
),
|
||||
// Mute / unmute
|
||||
MetaIconButton(
|
||||
painter: isMuted ? PhosphorIcons.byName('eye-slash') : PhosphorIcons.byName('eye'),
|
||||
// NOTE: We use eye/eyeSlash as stand-ins until a dedicated speaker
|
||||
// icon is added to PhosphorIcons (no speaker codepoint yet).
|
||||
// The semantic tooltip still says mute/unmute so AT users are clear.
|
||||
tooltip: isMuted ? 'Unmute messages' : 'Mute messages',
|
||||
color: isMuted ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id),
|
||||
),
|
||||
// Inject message
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('chat-circle'),
|
||||
tooltip: 'Inject message',
|
||||
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
// Fork session (T-172): branch into a new pane without touching the original.
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('git-branch'),
|
||||
tooltip: 'Fork session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onFork(widget.member.name),
|
||||
),
|
||||
// Close session
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('x'),
|
||||
tooltip: 'Close session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onClose(widget.member.name),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInjectField(BuildContext context, SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InjectTextField(
|
||||
controller: widget.injectController,
|
||||
tokens: tokens,
|
||||
onSubmit: (text) {
|
||||
if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('x'),
|
||||
tooltip: 'Cancel',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/// The Activity / Team / Config sub-tab strip — same interaction as the pql
|
||||
/// panel's view tabs, with an underline under the active tab. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SidebarTabStrip extends StatelessWidget {
|
||||
const SidebarTabStrip({super.key, required this.current, required this.memberCount, required this.onPick});
|
||||
final SidebarTab current;
|
||||
final int memberCount;
|
||||
final ValueChanged<SidebarTab> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final t in SidebarTab.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: t == current,
|
||||
label: _label(t),
|
||||
excludeSemantics: true,
|
||||
onTap: () => onPick(t),
|
||||
child: ClideTappable(
|
||||
onTap: () => onPick(t),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: ClideText(_label(t), fontSize: clideFontSmall, color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(SidebarTab t) => switch (t) {
|
||||
SidebarTab.activity => 'Activity',
|
||||
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
|
||||
SidebarTab.config => 'Config',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/// One row in the Team tab's TASKS section: status marker + title +
|
||||
/// owner + reassign control (T-171). Split out of
|
||||
/// claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TaskRow extends StatelessWidget {
|
||||
const TaskRow({super.key, required this.task, required this.members, required this.broker});
|
||||
|
||||
final TeamTask task;
|
||||
final List<TeamMemberJoined> members;
|
||||
final TeamBroker? broker;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final marker = switch (task.status) {
|
||||
'done' => '✓',
|
||||
'claimed' => '◈',
|
||||
_ => '○',
|
||||
};
|
||||
final markerColor = switch (task.status) {
|
||||
'done' => tokens.globalTextMuted,
|
||||
'claimed' => tokens.globalFocus,
|
||||
_ => tokens.globalForeground,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(marker, fontSize: clideFontSmall, color: markerColor),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
task.title,
|
||||
fontSize: clideFontSmall,
|
||||
color: task.status == 'done' ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (task.owner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: ClideText(task.owner!, fontSize: clideFontSmall, color: tokens.globalFocus),
|
||||
),
|
||||
// Reassign: cycle to the next roster member.
|
||||
if (broker != null && broker!.members.length > 1)
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('arrow-clockwise'),
|
||||
tooltip: 'Reassign task',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => _reassign(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reassign(BuildContext context) {
|
||||
final b = broker;
|
||||
if (b == null || members.isEmpty) return;
|
||||
final brokerMembers = b.members;
|
||||
if (brokerMembers.isEmpty) return;
|
||||
// Cycle to the next member after the current owner.
|
||||
final currentIndex = brokerMembers.indexWhere((m) => m.name == task.owner);
|
||||
final nextIndex = (currentIndex + 1) % brokerMembers.length;
|
||||
b.reassignTask(task.id, brokerMembers[nextIndex].id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/// The Team tab: the roster cockpit (T-171) — per-member rows with
|
||||
/// controls, the TASKS section, and the MESSAGES chat feed (T-180).
|
||||
/// Stateless and props-driven; the parent owns the member list, inject
|
||||
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
|
||||
/// (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/task_row.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamTask;
|
||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TeamTabView extends StatelessWidget {
|
||||
const TeamTabView({
|
||||
super.key,
|
||||
required this.members,
|
||||
required this.memberStatus,
|
||||
required this.orchestrator,
|
||||
required this.tasks,
|
||||
required this.injectingAgentId,
|
||||
required this.injectController,
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
required this.onFork,
|
||||
required this.onOpenChatPane,
|
||||
});
|
||||
|
||||
final List<TeamMemberJoined> members;
|
||||
final Map<String, SessionStatus> memberStatus;
|
||||
final ClaudeSessionOrchestrator? orchestrator;
|
||||
final List<TeamTask> tasks;
|
||||
final String? injectingAgentId;
|
||||
final TextEditingController injectController;
|
||||
final void Function(String memberName) onToggleInject;
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
final void Function(String memberName) onFork;
|
||||
final VoidCallback onOpenChatPane;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (members.isEmpty) {
|
||||
return metaPlaceholder('No team active.');
|
||||
}
|
||||
final children = <Widget>[
|
||||
for (final m in members)
|
||||
AgentRosterRow(
|
||||
key: ValueKey(m.agentId),
|
||||
member: m,
|
||||
status: memberStatus[m.agentId],
|
||||
orchestrator: orchestrator,
|
||||
injectingAgentId: injectingAgentId,
|
||||
injectController: injectController,
|
||||
onToggleInject: onToggleInject,
|
||||
onInjectSubmit: onInjectSubmit,
|
||||
onClose: onClose,
|
||||
onSetPermissionMode: onSetPermissionMode,
|
||||
onFork: onFork,
|
||||
),
|
||||
];
|
||||
|
||||
if (tasks.isNotEmpty) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(_taskSection(tokens));
|
||||
}
|
||||
|
||||
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
|
||||
final chatModel = orchestrator?.chatModel;
|
||||
final broker = orchestrator?.broker;
|
||||
if (chatModel != null && broker != null) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: onOpenChatPane));
|
||||
}
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
Widget _taskSection(SurfaceTokens tokens) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('TASKS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
const SizedBox(height: 4),
|
||||
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/// The `/model` picker for the interaction zone (T-408, D-78): a bare
|
||||
/// `/model` swaps this card in for the composer; picking an entry sends
|
||||
/// `set_model` over the control channel and the composer returns. Esc
|
||||
/// cancels. Like [ToolPromptCard], it lives in the composer zone — never
|
||||
/// inline in the conversation.
|
||||
///
|
||||
/// Keyboard: number keys pick directly (CLI muscle memory, T-240), Up/Down
|
||||
/// move the highlight, Enter picks the highlighted entry, Esc cancels.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Whether [option] is the session's current model. Options carry aliases
|
||||
/// (`sonnet`) or full ids while the status holds the full id
|
||||
/// (`claude-sonnet-4-6`), so match on equality or alias containment.
|
||||
bool modelOptionIsCurrent(ModelOption option, String? currentModel) {
|
||||
if (currentModel == null || option.value == 'default') return false;
|
||||
if (option.value == currentModel) return true;
|
||||
return currentModel.toLowerCase().contains(option.value.toLowerCase());
|
||||
}
|
||||
|
||||
class ModelPickerCard extends StatefulWidget {
|
||||
const ModelPickerCard({
|
||||
super.key,
|
||||
required this.models,
|
||||
this.currentModel,
|
||||
required this.onPick,
|
||||
required this.onCancel,
|
||||
this.title = 'model',
|
||||
this.isCurrent = modelOptionIsCurrent,
|
||||
});
|
||||
|
||||
/// Selectable entries, in display order. Callers pass [kFallbackModels]
|
||||
/// when the session hasn't reported its list yet.
|
||||
final List<ModelOption> models;
|
||||
|
||||
/// The session's current model (full id), to mark the active entry.
|
||||
final String? currentModel;
|
||||
|
||||
/// Called once with the picked [ModelOption.value].
|
||||
final void Function(String value) onPick;
|
||||
|
||||
/// Called when the user dismisses the picker without choosing.
|
||||
final VoidCallback onCancel;
|
||||
|
||||
/// Header label. The /effort picker reuses this card with its own title
|
||||
/// and an exact-match [isCurrent] (T-412).
|
||||
final String title;
|
||||
|
||||
/// Marks the active entry. The model default ([modelOptionIsCurrent]) also
|
||||
/// alias-matches (`sonnet` ⊂ `claude-sonnet-4-6`); effort needs exact match
|
||||
/// (`high` would falsely match inside `xhigh`).
|
||||
final bool Function(ModelOption option, String? current) isCurrent;
|
||||
|
||||
@override
|
||||
State<ModelPickerCard> createState() => _ModelPickerCardState();
|
||||
}
|
||||
|
||||
class _ModelPickerCardState extends State<ModelPickerCard> {
|
||||
late int _highlight = _initialHighlight();
|
||||
|
||||
int _initialHighlight() {
|
||||
for (var i = 0; i < widget.models.length; i++) {
|
||||
if (widget.isCurrent(widget.models[i], widget.currentModel)) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent || !node.hasPrimaryFocus) return KeyEventResult.ignored;
|
||||
final hw = HardwareKeyboard.instance;
|
||||
if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored;
|
||||
final key = e.logicalKey;
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
widget.onCancel();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
setState(() => _highlight = (_highlight + 1) % widget.models.length);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
setState(() => _highlight = (_highlight - 1 + widget.models.length) % widget.models.length);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
|
||||
widget.onPick(widget.models[_highlight].value);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final digit = _digitOf(key);
|
||||
if (digit != null && digit >= 1 && digit <= widget.models.length) {
|
||||
widget.onPick(widget.models[digit - 1].value);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
static int? _digitOf(LogicalKeyboardKey key) {
|
||||
const digits = [
|
||||
LogicalKeyboardKey.digit1,
|
||||
LogicalKeyboardKey.digit2,
|
||||
LogicalKeyboardKey.digit3,
|
||||
LogicalKeyboardKey.digit4,
|
||||
LogicalKeyboardKey.digit5,
|
||||
LogicalKeyboardKey.digit6,
|
||||
LogicalKeyboardKey.digit7,
|
||||
LogicalKeyboardKey.digit8,
|
||||
LogicalKeyboardKey.digit9,
|
||||
];
|
||||
const numpad = [
|
||||
LogicalKeyboardKey.numpad1,
|
||||
LogicalKeyboardKey.numpad2,
|
||||
LogicalKeyboardKey.numpad3,
|
||||
LogicalKeyboardKey.numpad4,
|
||||
LogicalKeyboardKey.numpad5,
|
||||
LogicalKeyboardKey.numpad6,
|
||||
LogicalKeyboardKey.numpad7,
|
||||
LogicalKeyboardKey.numpad8,
|
||||
LogicalKeyboardKey.numpad9,
|
||||
];
|
||||
var i = digits.indexOf(key);
|
||||
if (i < 0) i = numpad.indexOf(key);
|
||||
return i < 0 ? null : i + 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border(top: BorderSide(color: tokens.statusInfo, width: 2)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusInfo),
|
||||
const Spacer(),
|
||||
ClideText('↑↓ · 1-${widget.models.length} · Enter · Esc', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (var i = 0; i < widget.models.length; i++) _row(tokens, i),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
ClideButton(label: 'cancel', variant: ClideButtonVariant.subtle, onPressed: widget.onCancel),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(SurfaceTokens tokens, int i) {
|
||||
final m = widget.models[i];
|
||||
final current = widget.isCurrent(m, widget.currentModel);
|
||||
final highlighted = i == _highlight;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ClideButton(
|
||||
label: '${i + 1}. ${current ? '●' : '○'} ${m.displayName}${m.description.isEmpty ? '' : ' — ${m.description}'}',
|
||||
variant: highlighted ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
|
||||
onPressed: () => widget.onPick(m.value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ class SpawnSpec {
|
||||
this.team = false,
|
||||
this.memberName,
|
||||
this.forkSourceSessionId,
|
||||
this.effort,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -81,6 +82,12 @@ class SpawnSpec {
|
||||
/// Takes precedence over [resume]/[sessionId] for arg selection.
|
||||
final String? forkSourceSessionId;
|
||||
|
||||
/// Effort level passed to `claude --effort` (low/medium/high/xhigh/max,
|
||||
/// T-412). Null spawns without the flag — the CLI uses its configured
|
||||
/// default (settings.json `effortLevel`). No set_effort control subtype
|
||||
/// exists, so changing effort means respawn-with-resume carrying this.
|
||||
final String? effort;
|
||||
|
||||
/// Whether this spec spawns a forked session.
|
||||
bool get isFork => forkSourceSessionId != null;
|
||||
}
|
||||
@@ -188,7 +195,28 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
/// (T-269): the existing session belongs to the old repo, so it is torn down
|
||||
/// and a fresh one spawned for the new repo — a pane must never inherit
|
||||
/// another workspace's conversation.
|
||||
Future<ManagedSession> spawn(SpawnSpec spec) async {
|
||||
Future<ManagedSession> spawn(SpawnSpec spec) {
|
||||
// Serialize concurrent spawns per id (T-374): the body check-then-acts
|
||||
// on _sessions across two awaits, so two racing callers would both
|
||||
// pass the check and the loser's live claude process would be orphaned.
|
||||
// The first caller installs the future synchronously; the rest await
|
||||
// it. (A racing different-cwd spawn for the same id also coalesces —
|
||||
// the workspace-switch flow is sequential, so that pair never races.)
|
||||
final inFlight = _spawning[spec.id];
|
||||
if (inFlight != null) return inFlight;
|
||||
final f = _spawn(spec);
|
||||
_spawning[spec.id] = f;
|
||||
unawaited(
|
||||
f.then<void>((_) {}, onError: (Object _) {}).whenComplete(() {
|
||||
if (identical(_spawning[spec.id], f)) _spawning.remove(spec.id);
|
||||
}),
|
||||
);
|
||||
return f;
|
||||
}
|
||||
|
||||
final Map<String, Future<ManagedSession>> _spawning = {};
|
||||
|
||||
Future<ManagedSession> _spawn(SpawnSpec spec) async {
|
||||
final existing = _sessions[spec.id];
|
||||
if (existing != null) {
|
||||
if (existing.cwd == spec.cwd) return existing;
|
||||
@@ -217,7 +245,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
|
||||
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs];
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
...bootstrap.extraArgs,
|
||||
if (spec.effort != null) ...['--effort', spec.effort!],
|
||||
...sessionArgs,
|
||||
];
|
||||
|
||||
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
|
||||
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
|
||||
|
||||
@@ -30,8 +30,29 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
|
||||
/// Slash commands clide handles itself instead of forwarding to Claude:
|
||||
/// Claude Code's own handling forks the session to a new id that clide's
|
||||
/// transcript reader can't follow, so clide owns the semantics (T-156).
|
||||
/// `/fork` branches the current session into a new pane (T-172).
|
||||
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'};
|
||||
/// `/fork` branches the current session into a new pane (T-172). `/model`
|
||||
/// is interactive in the CLI's TUI only — forwarded it does nothing — so
|
||||
/// clide owns it as a set_model control request / picker (T-408). `/effort`
|
||||
/// has no control subtype, so clide owns it as a respawn-with-resume
|
||||
/// carrying `--effort` (T-412). `/permissions` is a picker over
|
||||
/// set_permission_mode; the rest navigate to clide surfaces (T-413):
|
||||
/// /status//config//mcp//agents//hooks → the Claude sidebar tabs,
|
||||
/// /memory → CLAUDE.md in the editor, /help → a local command summary.
|
||||
const Set<String> kClideOwnedCommands = {
|
||||
'clear',
|
||||
'resume',
|
||||
'fork',
|
||||
'model',
|
||||
'effort',
|
||||
'permissions',
|
||||
'status',
|
||||
'config',
|
||||
'mcp',
|
||||
'agents',
|
||||
'hooks',
|
||||
'memory',
|
||||
'help',
|
||||
};
|
||||
|
||||
/// The clide-owned command in [text] (a single-line leading-slash token in
|
||||
/// [kClideOwnedCommands]), or null.
|
||||
@@ -40,6 +61,93 @@ String? clideOwnedCommand(String text) {
|
||||
return token != null && kClideOwnedCommands.contains(token) ? token : null;
|
||||
}
|
||||
|
||||
/// Where slash input goes (T-411). One source of truth so a TUI-only command
|
||||
/// neither errors raw from the CLI nor bracket-pastes to the model as text
|
||||
/// (burning a real turn — observed with /effort on claude 2.1.175).
|
||||
enum SlashRoute {
|
||||
/// clide implements it natively ([kClideOwnedCommands]).
|
||||
owned,
|
||||
|
||||
/// The CLI handles it headless — advertised in the `initialize` handshake's
|
||||
/// `slash_commands` (skills + the headless builtins: compact, context, …).
|
||||
forward,
|
||||
|
||||
/// A known TUI-only builtin: never forwarded; clide shows a local notice
|
||||
/// with the clide-native way ([kTuiOnlyCommands]).
|
||||
unavailable,
|
||||
}
|
||||
|
||||
/// Claude Code TUI-only builtins (probed against 2.1.175: not advertised in
|
||||
/// stream-json, and forwarding would either error "isn't available in this
|
||||
/// environment" or — worse, for un-advertised tokens — bracket-paste to the
|
||||
/// model as literal text). Value = the clide-native pointer shown in the
|
||||
/// notice card. Commands clide later implements move to [kClideOwnedCommands].
|
||||
const Map<String, String> kTuiOnlyCommands = {
|
||||
'effort': '', // owned (T-412) — only routes here if ever removed from owned
|
||||
'status': '', // owned (T-413)
|
||||
'cost': 'cost and context usage live in the Claude sidebar (Activity tab)',
|
||||
'context': '', // advertised on current CLIs — only routes here on older ones
|
||||
'help': '', // owned (T-413)
|
||||
'config': '', // owned (T-413)
|
||||
'permissions': '', // owned (T-413)
|
||||
'memory': '', // owned (T-413)
|
||||
'mcp': '', // owned (T-413)
|
||||
'agents': '', // owned (T-413)
|
||||
'hooks': '', // owned (T-413)
|
||||
'todos': "Claude's task list docks above the composer",
|
||||
'model': '', // owned (T-408) — only routes here if ever removed from owned
|
||||
'doctor': 'run `claude doctor` in a terminal',
|
||||
'login': 'run `claude` in a terminal and use /login there',
|
||||
'logout': 'run `claude` in a terminal and use /logout there',
|
||||
'exit': 'close the pane or switch sessions instead',
|
||||
'vim': 'clide ships its own editor vim mode',
|
||||
'add-dir': '',
|
||||
'bashes': '',
|
||||
'bug': '',
|
||||
'export': '',
|
||||
'fast': '',
|
||||
'ide': "you're already in one",
|
||||
'install-github-app': '',
|
||||
'migrate-installer': '',
|
||||
'output-style': '',
|
||||
'pr-comments': '',
|
||||
'privacy-settings': '',
|
||||
'release-notes': '',
|
||||
'rewind': '',
|
||||
'statusline': '',
|
||||
'terminal-setup': '',
|
||||
'upgrade': '',
|
||||
};
|
||||
|
||||
/// Route [text] (composer input). Null when it isn't slash-command input —
|
||||
/// send it as a normal message. Precedence: owned > advertised > TUI-only
|
||||
/// catalog > forward (unknown tokens stay literal text via bracketed paste).
|
||||
SlashRoute? routeSlashCommand(String text, {required Iterable<String> advertised}) {
|
||||
final token = slashCommandToken(text);
|
||||
if (token == null) return null;
|
||||
if (kClideOwnedCommands.contains(token)) return SlashRoute.owned;
|
||||
if (advertised.contains(token)) return SlashRoute.forward;
|
||||
if (kTuiOnlyCommands.containsKey(token)) return SlashRoute.unavailable;
|
||||
return SlashRoute.forward;
|
||||
}
|
||||
|
||||
/// The notice text for a TUI-only [token] — the CLI's own phrasing plus the
|
||||
/// clide-native pointer when the catalog has one.
|
||||
String tuiOnlyNotice(String token) {
|
||||
final hint = kTuiOnlyCommands[token] ?? '';
|
||||
final base = "/$token is a Claude Code TUI command — it isn't available in clide's conversation pane.";
|
||||
return hint.isEmpty ? base : '$base\n→ $hint';
|
||||
}
|
||||
|
||||
/// The argument text after the command token — `"/model sonnet"` → `"sonnet"`
|
||||
/// — trimmed; empty when there is none (`"/model"`). Null when [text] isn't
|
||||
/// single-line leading-slash input.
|
||||
String? slashCommandArg(String text) {
|
||||
if (slashCommandToken(text) == null) return null;
|
||||
final ws = text.indexOf(RegExp(r'\s'));
|
||||
return ws < 0 ? '' : text.substring(ws + 1).trim();
|
||||
}
|
||||
|
||||
bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n';
|
||||
|
||||
/// An in-progress slash query at the cursor — the `/` position and the word
|
||||
|
||||
@@ -19,8 +19,12 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/src/util/value_stream.dart';
|
||||
|
||||
/// The claude subprocess, abstracted so tests drive it without spawning.
|
||||
/// Fakes `extend` this and override what they drive; the defaults below
|
||||
/// describe a process with no real child behind it.
|
||||
abstract class StreamJsonProcess {
|
||||
/// stdout, one JSON event per line.
|
||||
Stream<String> get lines;
|
||||
@@ -30,13 +34,43 @@ abstract class StreamJsonProcess {
|
||||
|
||||
/// Terminate the process.
|
||||
Future<void> kill();
|
||||
|
||||
/// The last lines of the child's stderr, drained continuously so the pipe
|
||||
/// can never fill and block the child mid-turn (T-361). Default: none.
|
||||
List<String> get stderrTail => const [];
|
||||
|
||||
/// Completes with the child's exit code, or null when there is no real
|
||||
/// process to watch (fakes that never "exit").
|
||||
Future<int>? get exitCode => null;
|
||||
}
|
||||
|
||||
/// A bounded FIFO of the most recent lines — the stderr tail kept for
|
||||
/// post-mortem diagnostics while the stream itself is drained and dropped.
|
||||
class BoundedLineBuffer {
|
||||
BoundedLineBuffer({this.cap = 100});
|
||||
|
||||
final int cap;
|
||||
final List<String> _lines = [];
|
||||
|
||||
void add(String line) {
|
||||
_lines.add(line);
|
||||
if (_lines.length > cap) _lines.removeAt(0);
|
||||
}
|
||||
|
||||
List<String> get lines => List.unmodifiable(_lines);
|
||||
}
|
||||
|
||||
/// Production [StreamJsonProcess] backed by a real `claude` process.
|
||||
class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc);
|
||||
class ClaudeStreamJsonProcess extends StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc) {
|
||||
// Drain stderr from the moment the process exists — with --verbose the
|
||||
// CLI chats on stderr, and an undrained 64KB pipe blocks the child
|
||||
// mid-turn with zero diagnostics (T-361). Keep a tail for post-mortems.
|
||||
_proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(_stderr.add, onError: (Object _) {});
|
||||
}
|
||||
|
||||
final Process _proc;
|
||||
final BoundedLineBuffer _stderr = BoundedLineBuffer();
|
||||
|
||||
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
|
||||
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
|
||||
@@ -75,6 +109,12 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
Future<void> kill() async {
|
||||
_proc.kill();
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get stderrTail => _stderr.lines;
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _proc.exitCode;
|
||||
}
|
||||
|
||||
/// An in-process MCP server clide hosts for a session, entirely over the
|
||||
@@ -108,6 +148,53 @@ abstract class McpServer {
|
||||
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments);
|
||||
}
|
||||
|
||||
/// A model selectable for a session, from the `initialize` control_response's
|
||||
/// `models[]` (T-408). Pure data, Flutter-free.
|
||||
class ModelOption {
|
||||
const ModelOption({required this.value, required this.displayName, this.description = ''});
|
||||
|
||||
/// The id/alias sent in `set_model` — e.g. `default`, `sonnet`, `opus`.
|
||||
final String value;
|
||||
|
||||
/// Human label, e.g. `Sonnet`.
|
||||
final String displayName;
|
||||
|
||||
/// One-line blurb shown muted next to the label.
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// Effort levels `claude --effort` accepts (probed against 2.1.175). There is
|
||||
/// NO set_effort control subtype (probed: rejected), so changing effort
|
||||
/// respawns the session with the flag — resume keeps the conversation (T-412).
|
||||
/// Expressed as [ModelOption]s so the /effort picker reuses the /model card.
|
||||
const List<ModelOption> kEffortLevels = [
|
||||
ModelOption(value: 'low', displayName: 'low', description: 'fastest, minimal thinking'),
|
||||
ModelOption(value: 'medium', displayName: 'medium', description: 'balanced'),
|
||||
ModelOption(value: 'high', displayName: 'high', description: 'thorough'),
|
||||
ModelOption(value: 'xhigh', displayName: 'xhigh', description: 'deeper reasoning'),
|
||||
ModelOption(value: 'max', displayName: 'max', description: 'maximum thinking budget'),
|
||||
];
|
||||
|
||||
/// Permission modes for the /permissions picker (T-413), set over the
|
||||
/// set_permission_mode control request. Bypass is last and explicit — the
|
||||
/// footgun stays visible but never the default reach (T-181).
|
||||
const List<ModelOption> kPermissionModes = [
|
||||
ModelOption(value: 'default', displayName: 'default', description: 'ask before sensitive tools'),
|
||||
ModelOption(value: 'acceptEdits', displayName: 'acceptEdits', description: 'auto-approve file edits'),
|
||||
ModelOption(value: 'plan', displayName: 'plan', description: 'read-only planning mode'),
|
||||
ModelOption(value: 'bypassPermissions', displayName: 'bypassPermissions', description: 'no prompts at all — careful'),
|
||||
];
|
||||
|
||||
/// Fallback picker entries for when the `initialize` response hasn't arrived
|
||||
/// (or carried no models): the stable aliases every claude build accepts
|
||||
/// (T-408). `default` resets to the CLI's configured model.
|
||||
const List<ModelOption> kFallbackModels = [
|
||||
ModelOption(value: 'default', displayName: 'Default', description: 'recommended — the CLI\'s configured model'),
|
||||
ModelOption(value: 'sonnet', displayName: 'Sonnet', description: 'fast, great for everyday tasks'),
|
||||
ModelOption(value: 'opus', displayName: 'Opus', description: 'most capable'),
|
||||
ModelOption(value: 'haiku', displayName: 'Haiku', description: 'fastest, lightweight'),
|
||||
];
|
||||
|
||||
/// An interactive prompt Claude is blocked on, from the stream-json control
|
||||
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
|
||||
/// an `AskUserQuestion`. Pure data; the decision goes back via
|
||||
@@ -194,6 +281,15 @@ final class DenyTool extends ToolDecision {
|
||||
|
||||
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
|
||||
/// answers control-channel prompts, and sends user messages.
|
||||
/// Terminal session end: the claude process exited (crash or otherwise).
|
||||
/// Carries the exit code and the drained stderr tail for diagnostics.
|
||||
class SessionEnd {
|
||||
const SessionEnd({required this.exitCode, required this.stderrTail});
|
||||
|
||||
final int exitCode;
|
||||
final List<String> stderrTail;
|
||||
}
|
||||
|
||||
class StreamJsonSession {
|
||||
StreamJsonSession(this._proc, {List<McpServer> mcpServers = const []}) : _mcpServers = mcpServers;
|
||||
|
||||
@@ -204,13 +300,36 @@ class StreamJsonSession {
|
||||
/// round-trips are answered by [_handleMcpMessage].
|
||||
final List<McpServer> _mcpServers;
|
||||
final _items = StreamController<ConversationItem>.broadcast();
|
||||
final _statusCtl = StreamController<SessionStatus>.broadcast();
|
||||
// State, not events — replay-latest so a subscriber that binds after the
|
||||
// init event still sees the current status (T-386; root cause of T-274).
|
||||
final _statusCtl = ValueStream<SessionStatus>();
|
||||
final _sessionIdCtl = StreamController<String>.broadcast();
|
||||
StreamSubscription<String>? _sub;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
String? _claudeSessionId;
|
||||
int _localSeq = 0;
|
||||
|
||||
/// The `initialize` handshake's request id — its control_response carries
|
||||
/// the selectable `models[]` (T-408).
|
||||
String? _initRequestId;
|
||||
|
||||
/// In-flight `set_model` request ids → the model the status held before the
|
||||
/// optimistic merge, so an error response can roll it back (T-408).
|
||||
final _pendingSetModel = <String, String?>{};
|
||||
|
||||
List<ModelOption> _availableModels = const [];
|
||||
|
||||
/// Models selectable for this session, from the `initialize` response.
|
||||
/// Empty until that response arrives (callers fall back to
|
||||
/// [kFallbackModels]).
|
||||
List<ModelOption> get availableModels => _availableModels;
|
||||
|
||||
final _modelErrorCtl = StreamController<String>.broadcast();
|
||||
|
||||
/// Errors from rejected `set_model` requests (e.g. an unknown model name),
|
||||
/// for the pane to surface (T-408).
|
||||
Stream<String> get modelErrors => _modelErrorCtl.stream;
|
||||
|
||||
/// Token-by-token streaming state (T-168, wire shape verified by T-184).
|
||||
///
|
||||
/// With `--include-partial-messages`, claude emits the in-progress reply as
|
||||
@@ -237,7 +356,7 @@ class StreamJsonSession {
|
||||
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
|
||||
/// is the one currently shown in the composer zone.
|
||||
final _queue = <ToolPrompt>[];
|
||||
final _pendingCtl = StreamController<ToolPrompt?>.broadcast();
|
||||
final _pendingCtl = ValueStream<ToolPrompt?>.seeded(null);
|
||||
|
||||
/// tool_use_ids that surfaced as a prompt — the view hides their raw
|
||||
/// tool-use card while pending (it shows as a prompt) but keeps the result.
|
||||
@@ -259,10 +378,24 @@ class StreamJsonSession {
|
||||
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
||||
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
|
||||
|
||||
/// Live Workflow runs, keyed by their launching `Workflow` tool-use id
|
||||
/// (T-416). Accumulated from the out-of-band `system` task_* events the
|
||||
/// harness emits while a workflow runs in the background; the conversation
|
||||
/// card and the sidebar indicator both read this snapshot. Ephemeral — the
|
||||
/// events aren't in the resumed transcript, so this is empty on reload.
|
||||
final _workflows = <String, WorkflowRun>{};
|
||||
final _workflowsCtl = ValueStream<Map<String, WorkflowRun>>.seeded(const {});
|
||||
|
||||
/// The current workflow runs, keyed by launching tool-use id.
|
||||
Map<String, WorkflowRun> get workflows => Map.unmodifiable(_workflows);
|
||||
|
||||
/// Emits the workflow-run map whenever a `system` task event updates it.
|
||||
Stream<Map<String, WorkflowRun>> get workflowsStream => _workflowsCtl.stream;
|
||||
|
||||
/// Whether a turn is in flight (between a send and claude's `result`). Drives
|
||||
/// the composer's Stop affordance.
|
||||
bool _busy = false;
|
||||
final _busyCtl = StreamController<bool>.broadcast();
|
||||
final _busyCtl = ValueStream<bool>.seeded(false);
|
||||
bool get busy => _busy;
|
||||
Stream<bool> get busyStream => _busyCtl.stream;
|
||||
|
||||
@@ -299,25 +432,41 @@ class StreamJsonSession {
|
||||
/// The latest known status — the current value [statusStream] last emitted.
|
||||
SessionStatus get status => _status;
|
||||
|
||||
/// Non-null once the claude process has exited (T-361). Late binders read
|
||||
/// this; live listeners get [endedStream]. Never set by a deliberate
|
||||
/// [dispose] — only by the process dying underneath a live session.
|
||||
SessionEnd? get end => _end;
|
||||
SessionEnd? _end;
|
||||
final _endCtl = StreamController<SessionEnd>.broadcast();
|
||||
bool _disposed = false;
|
||||
|
||||
/// Fires once when the process exits while the session is still live —
|
||||
/// a crashed/dead session must not just look thoughtful (T-361).
|
||||
Stream<SessionEnd> get endedStream => _endCtl.stream;
|
||||
|
||||
/// Begin consuming the process's event stream.
|
||||
void start() {
|
||||
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
|
||||
// Declaring our in-process MCP servers in the `initialize` handshake is what
|
||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
|
||||
// when we actually host a server, so a plain session is unchanged.
|
||||
if (_mcpServers.isNotEmpty) {
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'init-${_localSeq++}',
|
||||
'request': {
|
||||
'subtype': 'initialize',
|
||||
'hooks': <String, dynamic>{},
|
||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Watch the process itself: stdout EOF alone is ambiguous, the exit
|
||||
// code is not (T-361).
|
||||
final exit = _proc.exitCode;
|
||||
if (exit != null) unawaited(exit.then(_onExit));
|
||||
// The `initialize` handshake is side-effect-free (verified in the protocol
|
||||
// spike) and does double duty: declaring our in-process MCP servers is what
|
||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
|
||||
// response's `models[]` feeds the /model picker (T-408).
|
||||
_initRequestId = 'init-${_localSeq++}';
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': _initRequestId,
|
||||
'request': {
|
||||
'subtype': 'initialize',
|
||||
'hooks': <String, dynamic>{},
|
||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _onLine(String line) {
|
||||
@@ -345,6 +494,12 @@ class StreamJsonSession {
|
||||
_onControlRequest(ev);
|
||||
return;
|
||||
}
|
||||
// Responses to OUR control requests: the initialize result (models) and
|
||||
// set_model acks/errors (T-408).
|
||||
if (ev['type'] == 'control_response') {
|
||||
_onControlResponse(ev);
|
||||
return;
|
||||
}
|
||||
// A `result` ends the turn — clear the busy/interruptible state and reset
|
||||
// streaming state so the next turn is fresh.
|
||||
if (ev['type'] == 'result') {
|
||||
@@ -361,6 +516,15 @@ class StreamJsonSession {
|
||||
return;
|
||||
}
|
||||
|
||||
// Workflow run progress (T-416): the harness reports a backgrounded Workflow
|
||||
// tool's fan-out on out-of-band `system` task_* events keyed by the
|
||||
// launching tool-use id. Fold them into the run snapshot and notify; they
|
||||
// carry no conversation item, so don't fall through to the parser.
|
||||
if (isWorkflowSystemEvent(ev)) {
|
||||
_onWorkflowEvent(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalise a streamed reply: when the real text `assistant` event for a
|
||||
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
|
||||
// so the controller replaces the placeholder in place rather than appending
|
||||
@@ -434,6 +598,15 @@ class StreamJsonSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one workflow `system` task event into its run snapshot, keyed by the
|
||||
/// launching tool-use id, and publish the updated map (T-416).
|
||||
void _onWorkflowEvent(Map<String, dynamic> ev) {
|
||||
final id = ev['tool_use_id'] as String;
|
||||
final prior = _workflows[id] ?? WorkflowRun(toolUseId: id);
|
||||
_workflows[id] = prior.foldEvent(ev);
|
||||
_workflowsCtl.add(Map.unmodifiable(_workflows));
|
||||
}
|
||||
|
||||
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
|
||||
/// item the UI resolves; every other subtype is answered with an error so
|
||||
/// the turn never hangs waiting on us (D-78).
|
||||
@@ -676,6 +849,18 @@ class StreamJsonSession {
|
||||
_setBusy(true);
|
||||
}
|
||||
|
||||
/// Inject a clide-local notice card into the conversation — nothing is sent
|
||||
/// to claude. Used by the slash-command router for TUI-only commands
|
||||
/// (T-411); renders as the muted synthetic "clide" card.
|
||||
void addLocalNotice(String text) {
|
||||
_items.add(AssistantTextMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text, synthetic: true));
|
||||
}
|
||||
|
||||
/// Record the effort level this session was spawned with (`--effort`,
|
||||
/// T-412). The wire never reports effort, so the spawner tells the status
|
||||
/// what it set; the status line / sidebar read it from [SessionStatus].
|
||||
void noteEffort(String level) => _mergeStatus(SessionStatus(effort: level));
|
||||
|
||||
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
|
||||
/// the `interrupt` control_request; claude cancels the current turn and ends
|
||||
/// it with a `result`, which clears [busy]. Safe to call when idle.
|
||||
@@ -712,13 +897,85 @@ class StreamJsonSession {
|
||||
_mergeStatus(SessionStatus(permissionMode: mode));
|
||||
}
|
||||
|
||||
/// Set the model for subsequent turns (T-408). Sends a `set_model`
|
||||
/// control_request; [model] is an alias (`sonnet`, `opus`) or full id, and
|
||||
/// `default` resets to the CLI's configured model. The status merges
|
||||
/// optimistically (mirroring [setPermissionMode]); an error response rolls
|
||||
/// it back and surfaces on [modelErrors].
|
||||
void setModel(String model) {
|
||||
final rid = 'set-model-${_localSeq++}';
|
||||
_pendingSetModel[rid] = _status.model;
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': rid,
|
||||
'request': {'subtype': 'set_model', 'model': model},
|
||||
}),
|
||||
);
|
||||
// `default` resolves to a model only the CLI knows — leave the status to
|
||||
// the next assistant event in that case.
|
||||
if (model != 'default') _mergeStatus(SessionStatus(model: model));
|
||||
}
|
||||
|
||||
/// A `control_response` to one of our requests: capture the initialize
|
||||
/// result's `models[]`, and roll back + surface a rejected set_model (T-408).
|
||||
void _onControlResponse(Map<String, dynamic> ev) {
|
||||
final resp = ev['response'];
|
||||
if (resp is! Map) return;
|
||||
final rid = resp['request_id'] as String?;
|
||||
if (rid == null) return;
|
||||
final isError = resp['subtype'] == 'error';
|
||||
if (rid == _initRequestId && !isError) {
|
||||
final result = resp['response'];
|
||||
final models = result is Map ? result['models'] : null;
|
||||
if (models is List) {
|
||||
_availableModels = List.unmodifiable([
|
||||
for (final m in models)
|
||||
if (m is Map && m['value'] is String)
|
||||
ModelOption(
|
||||
value: m['value'] as String,
|
||||
displayName: m['displayName'] as String? ?? m['value'] as String,
|
||||
description: m['description'] as String? ?? '',
|
||||
),
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_pendingSetModel.containsKey(rid)) {
|
||||
final previous = _pendingSetModel.remove(rid);
|
||||
if (isError) {
|
||||
if (previous != null) _mergeStatus(SessionStatus(model: previous));
|
||||
_modelErrorCtl.add(resp['error'] as String? ?? 'model change rejected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process exited under a live session. Flip every "in flight"
|
||||
/// surface off so the pane reflects reality instead of spinning forever.
|
||||
void _onExit(int code) {
|
||||
if (_disposed || _end != null) return;
|
||||
_end = SessionEnd(exitCode: code, stderrTail: _proc.stderrTail);
|
||||
_setBusy(false);
|
||||
// A prompt pending against a dead process can never be answered —
|
||||
// clear it so the composer comes back.
|
||||
if (_queue.isNotEmpty) {
|
||||
_queue.clear();
|
||||
_pendingCtl.add(null);
|
||||
}
|
||||
_endCtl.add(_end!);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true; // deliberate teardown — suppress the exit-watch path
|
||||
await _sub?.cancel();
|
||||
await _proc.kill();
|
||||
await _items.close();
|
||||
await _statusCtl.close();
|
||||
await _workflowsCtl.close();
|
||||
await _sessionIdCtl.close();
|
||||
await _pendingCtl.close();
|
||||
await _busyCtl.close();
|
||||
await _endCtl.close();
|
||||
await _modelErrorCtl.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
/// Bridges a [TranscriptReader] onto the kernel [MessageBus] (epic T-132,
|
||||
/// D-75).
|
||||
/// Bus addressing for Claude conversation content (epic T-132, D-75).
|
||||
///
|
||||
/// One reader tails a workspace transcript; this publisher republishes
|
||||
/// every [ConversationItem] as a bus [Message]. Any number of Claude
|
||||
/// panels can then subscribe to the same conversation via the bus instead
|
||||
/// of each owning its own reader — the decoupling the team panels
|
||||
/// (T-139/T-140) need, where a single observer feeds the lead tile plus a
|
||||
/// tile per teammate.
|
||||
/// The tmux-era `TranscriptPublisher` that used to live here (one reader
|
||||
/// tailing a transcript, republished onto the bus) had no production
|
||||
/// constructor calls after the stream-json pivot (D-77) and was removed
|
||||
/// in the T-385 dead-code sweep. The [ClaudeConversation] channel/key
|
||||
/// constants remain — the meta sidebar and team panel host still consume
|
||||
/// them for member-status messages.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
|
||||
/// Bus addressing for Claude conversation content.
|
||||
abstract final class ClaudeConversation {
|
||||
@@ -29,7 +25,7 @@ abstract final class ClaudeConversation {
|
||||
/// Channel for a teammate's conversation (team work, T-139/T-140).
|
||||
static String teammateChannel(String agentId) => 'conversation/$agentId';
|
||||
|
||||
/// Key under which the [ConversationItem] travels in a [Message]'s data.
|
||||
/// Key under which the [ConversationItem] travels in a bus message's data.
|
||||
static const itemKey = 'item';
|
||||
|
||||
/// Shared channel carrying each team member's live status (T-157). Every
|
||||
@@ -44,32 +40,3 @@ abstract final class ClaudeConversation {
|
||||
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
class TranscriptPublisher {
|
||||
/// Starts republishing [reader]'s items onto [messages] under
|
||||
/// [ClaudeConversation.publisher] / [channel]. The subscription is
|
||||
/// attached synchronously, so a controller that subscribes before the
|
||||
/// reader's first poll never misses the initial tail.
|
||||
TranscriptPublisher({required MessageBus messages, required TranscriptReader reader, this.channel = ClaudeConversation.leadChannel})
|
||||
: _messages = messages,
|
||||
_reader = reader {
|
||||
_sub = _reader.stream.listen((item) {
|
||||
_messages.publish(ClaudeConversation.publisher, channel, {ClaudeConversation.itemKey: item});
|
||||
});
|
||||
}
|
||||
|
||||
final MessageBus _messages;
|
||||
final TranscriptReader _reader;
|
||||
final String channel;
|
||||
late final StreamSubscription<ConversationItem> _sub;
|
||||
|
||||
/// Live session status (model / permission-mode / context) from the
|
||||
/// underlying reader — passed through for the status strip (T-145).
|
||||
Stream<SessionStatus> get statusStream => _reader.statusStream;
|
||||
|
||||
/// Stops publishing and tears down the underlying reader.
|
||||
Future<void> dispose() async {
|
||||
await _sub.cancel();
|
||||
await _reader.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +114,19 @@ final class AssistantTextMessage extends ConversationItem {
|
||||
super.parentUuid,
|
||||
super.parentToolUseId,
|
||||
required this.text,
|
||||
this.synthetic = false,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
/// CLI-local output, not the model: the wire marks it `model: "<synthetic>"`
|
||||
/// (a forwarded local command's response — /usage output, "/x isn't
|
||||
/// available in this environment", …). clide-injected notices use it too.
|
||||
/// Rendered as a muted "clide" card, never coral Claude prose (T-411).
|
||||
final bool synthetic;
|
||||
|
||||
@override
|
||||
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars)';
|
||||
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars${synthetic ? ', synthetic' : ''})';
|
||||
}
|
||||
|
||||
/// Extended thinking block from an assistant turn.
|
||||
@@ -426,7 +433,7 @@ class TranscriptReader {
|
||||
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
|
||||
/// and the reader [merge]s deltas into a running status.
|
||||
class SessionStatus {
|
||||
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo});
|
||||
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo, this.effort});
|
||||
|
||||
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
|
||||
final String? model;
|
||||
@@ -451,7 +458,13 @@ class SessionStatus {
|
||||
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
|
||||
final String? rateLimitInfo;
|
||||
|
||||
bool get isEmpty => model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null;
|
||||
/// The session's effort level (`--effort`, T-412). The wire never reports
|
||||
/// it — clide records what it spawned with via [StreamJsonSession.noteEffort];
|
||||
/// null means the CLI default (settings.json `effortLevel`).
|
||||
final String? effort;
|
||||
|
||||
bool get isEmpty =>
|
||||
model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null && effort == null;
|
||||
|
||||
/// Overlay [other]'s non-null fields onto this one.
|
||||
SessionStatus merge(SessionStatus other) => SessionStatus(
|
||||
@@ -461,6 +474,7 @@ class SessionStatus {
|
||||
cost: other.cost ?? cost,
|
||||
contextWindow: other.contextWindow ?? contextWindow,
|
||||
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
|
||||
effort: other.effort ?? effort,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -471,10 +485,11 @@ class SessionStatus {
|
||||
other.contextTokens == contextTokens &&
|
||||
other.cost == cost &&
|
||||
other.contextWindow == contextWindow &&
|
||||
other.rateLimitInfo == rateLimitInfo;
|
||||
other.rateLimitInfo == rateLimitInfo &&
|
||||
other.effort == effort;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo);
|
||||
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo, effort);
|
||||
}
|
||||
|
||||
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and
|
||||
@@ -582,7 +597,9 @@ void _extractAssistantStatus(Map<String, dynamic> envelope, _StatusAcc status) {
|
||||
final message = envelope['message'] as Map?;
|
||||
if (message == null) return;
|
||||
final model = message['model'] as String?;
|
||||
if (model != null && model.isNotEmpty) status.model = model;
|
||||
// "<synthetic>" marks CLI-local output (a forwarded local command's
|
||||
// response) — not a model switch; it must not clobber the tracked model.
|
||||
if (model != null && model.isNotEmpty && model != kSyntheticModel) status.model = model;
|
||||
final usage = message['usage'] as Map?;
|
||||
if (usage != null) {
|
||||
int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
|
||||
@@ -656,6 +673,9 @@ void _parseUserInto(
|
||||
}
|
||||
}
|
||||
|
||||
/// The model marker on CLI-local output (forwarded local-command responses).
|
||||
const String kSyntheticModel = '<synthetic>';
|
||||
|
||||
void _parseAssistantInto(
|
||||
Map<String, dynamic> envelope,
|
||||
String uuid,
|
||||
@@ -669,6 +689,7 @@ void _parseAssistantInto(
|
||||
if (message == null) return;
|
||||
final content = message['content'];
|
||||
if (content is! List) return;
|
||||
final synthetic = (message['model'] as String?) == kSyntheticModel;
|
||||
|
||||
for (final item in content) {
|
||||
if (item is! Map) continue;
|
||||
@@ -684,6 +705,7 @@ void _parseAssistantInto(
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
text: text,
|
||||
synthetic: synthetic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/// Live state of a Claude Code Workflow run (T-416).
|
||||
///
|
||||
/// A Workflow is the harness's multi-agent orchestration tool. The model calls
|
||||
/// it as an ordinary `tool_use` (`name: "Workflow"`, `input: {script}`); the
|
||||
/// tool returns immediately ("launched in background") and the run's real
|
||||
/// progress arrives out-of-band on stream-json `type: "system"` events keyed by
|
||||
/// the launching tool-use id. This file is the pure, Flutter-free model that
|
||||
/// folds those events into a snapshot the conversation/sidebar surfaces render.
|
||||
///
|
||||
/// Wire shape (captured by the T-416 spike, claude 2.1.175):
|
||||
/// - `task_started` — task_id, tool_use_id, description, workflow_name,
|
||||
/// prompt (script source)
|
||||
/// - `task_progress` — usage{total_tokens,tool_uses,duration_ms}, summary,
|
||||
/// and `workflow_progress[]`, a DELTA list mixing
|
||||
/// `{type:"workflow_phase", index, title}` and
|
||||
/// `{type:"workflow_agent", index, label, phaseIndex?,
|
||||
/// phaseTitle?, model, state(start|progress|done),
|
||||
/// agentId?}` — partial, merged by index.
|
||||
/// - `task_updated` — patch{status, end_time}
|
||||
/// - `task_notification` — terminal status:"completed", summary, usage
|
||||
///
|
||||
/// Limit: these events are ephemeral (not persisted to the resumed transcript
|
||||
/// JSONL), so live progress shows during the session; on reload only the tool
|
||||
/// card + its "launched in background" result survive.
|
||||
library;
|
||||
|
||||
/// Lifecycle of a single workflow agent, from its `state` field.
|
||||
enum WorkflowAgentState { start, progress, done, unknown }
|
||||
|
||||
WorkflowAgentState parseWorkflowAgentState(Object? raw) => switch (raw) {
|
||||
'start' || 'queued' || 'running' => WorkflowAgentState.start,
|
||||
'progress' => WorkflowAgentState.progress,
|
||||
'done' || 'complete' || 'completed' => WorkflowAgentState.done,
|
||||
_ => WorkflowAgentState.unknown,
|
||||
};
|
||||
|
||||
/// One phase declared by `meta.phases` / a `phase()` call.
|
||||
class WorkflowPhase {
|
||||
const WorkflowPhase({required this.index, required this.title});
|
||||
|
||||
final int index;
|
||||
final String title;
|
||||
}
|
||||
|
||||
/// One agent fanned out by the workflow. Fields accrete across `task_progress`
|
||||
/// deltas — a later delta fills in `agentId` / upgrades `model` / advances
|
||||
/// `state`, so [mergeDelta] overlays non-null fields onto the prior snapshot.
|
||||
class WorkflowAgent {
|
||||
const WorkflowAgent({
|
||||
required this.index,
|
||||
required this.label,
|
||||
this.model,
|
||||
this.state = WorkflowAgentState.start,
|
||||
this.agentId,
|
||||
this.phaseIndex,
|
||||
this.phaseTitle,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final String label;
|
||||
final String? model;
|
||||
final WorkflowAgentState state;
|
||||
final String? agentId;
|
||||
final int? phaseIndex;
|
||||
final String? phaseTitle;
|
||||
|
||||
/// Fold a raw `workflow_agent` delta entry onto this snapshot, keeping prior
|
||||
/// values where the delta omits a field.
|
||||
WorkflowAgent mergeDelta(Map<String, dynamic> e) => WorkflowAgent(
|
||||
index: index,
|
||||
label: (e['label'] as String?)?.isNotEmpty == true ? e['label'] as String : label,
|
||||
model: (e['model'] as String?) ?? model,
|
||||
state: e.containsKey('state') ? parseWorkflowAgentState(e['state']) : state,
|
||||
agentId: (e['agentId'] as String?) ?? agentId,
|
||||
phaseIndex: (e['phaseIndex'] as num?)?.toInt() ?? phaseIndex,
|
||||
phaseTitle: (e['phaseTitle'] as String?) ?? phaseTitle,
|
||||
);
|
||||
|
||||
static WorkflowAgent fromDelta(Map<String, dynamic> e) => WorkflowAgent(
|
||||
index: (e['index'] as num).toInt(),
|
||||
label: (e['label'] as String?) ?? '',
|
||||
model: e['model'] as String?,
|
||||
state: parseWorkflowAgentState(e['state']),
|
||||
agentId: e['agentId'] as String?,
|
||||
phaseIndex: (e['phaseIndex'] as num?)?.toInt(),
|
||||
phaseTitle: e['phaseTitle'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// An immutable snapshot of one workflow run. [foldEvent] returns a new snapshot
|
||||
/// with a single `system` task event applied (the session keeps one per
|
||||
/// launching tool-use id and replaces it as events arrive).
|
||||
class WorkflowRun {
|
||||
const WorkflowRun({
|
||||
required this.toolUseId,
|
||||
this.taskId,
|
||||
this.name,
|
||||
this.description,
|
||||
this.summary,
|
||||
this.done = false,
|
||||
this.totalTokens,
|
||||
this.toolUses,
|
||||
this.durationMs,
|
||||
this.phases = const {},
|
||||
this.agents = const {},
|
||||
});
|
||||
|
||||
/// The launching `Workflow` tool-use id — the join key to the conversation
|
||||
/// card and across all of this run's system events.
|
||||
final String toolUseId;
|
||||
|
||||
/// The harness task id (e.g. `wy01fihjt`), assigned at `task_started`.
|
||||
final String? taskId;
|
||||
|
||||
/// `workflow_name` from `meta.name`.
|
||||
final String? name;
|
||||
final String? description;
|
||||
final String? summary;
|
||||
|
||||
/// True once a `task_updated{status:completed}` or `task_notification`
|
||||
/// terminal event lands.
|
||||
final bool done;
|
||||
|
||||
final int? totalTokens;
|
||||
final int? toolUses;
|
||||
final int? durationMs;
|
||||
|
||||
/// Phase index → phase. Empty for a phase-less workflow.
|
||||
final Map<int, WorkflowPhase> phases;
|
||||
|
||||
/// Agent index → agent snapshot.
|
||||
final Map<int, WorkflowAgent> agents;
|
||||
|
||||
bool get running => !done;
|
||||
int get agentCount => agents.length;
|
||||
int get doneCount => agents.values.where((a) => a.state == WorkflowAgentState.done).length;
|
||||
|
||||
/// Agents in index order — the order the script fanned them out.
|
||||
List<WorkflowAgent> get orderedAgents {
|
||||
final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Phases in index order.
|
||||
List<WorkflowPhase> get orderedPhases {
|
||||
final list = phases.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
return list;
|
||||
}
|
||||
|
||||
WorkflowRun _copyWith({
|
||||
String? taskId,
|
||||
String? name,
|
||||
String? description,
|
||||
String? summary,
|
||||
bool? done,
|
||||
int? totalTokens,
|
||||
int? toolUses,
|
||||
int? durationMs,
|
||||
Map<int, WorkflowPhase>? phases,
|
||||
Map<int, WorkflowAgent>? agents,
|
||||
}) => WorkflowRun(
|
||||
toolUseId: toolUseId,
|
||||
taskId: taskId ?? this.taskId,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
summary: summary ?? this.summary,
|
||||
done: done ?? this.done,
|
||||
totalTokens: totalTokens ?? this.totalTokens,
|
||||
toolUses: toolUses ?? this.toolUses,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
phases: phases ?? this.phases,
|
||||
agents: agents ?? this.agents,
|
||||
);
|
||||
|
||||
/// Apply one `system` task event ([ev]) and return the updated snapshot.
|
||||
/// [ev] must already be the decoded envelope; unknown subtypes return `this`.
|
||||
WorkflowRun foldEvent(Map<String, dynamic> ev) {
|
||||
switch (ev['subtype']) {
|
||||
case 'task_started':
|
||||
return _copyWith(taskId: ev['task_id'] as String?, name: ev['workflow_name'] as String?, description: ev['description'] as String?);
|
||||
case 'task_progress':
|
||||
return _foldProgress(ev);
|
||||
case 'task_updated':
|
||||
final patch = ev['patch'];
|
||||
final status = patch is Map ? patch['status'] as String? : null;
|
||||
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null);
|
||||
case 'task_notification':
|
||||
final status = ev['status'] as String?;
|
||||
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null, summary: ev['summary'] as String?)._foldUsage(ev['usage']);
|
||||
default:
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
WorkflowRun _foldProgress(Map<String, dynamic> ev) {
|
||||
final phases = Map<int, WorkflowPhase>.from(this.phases);
|
||||
final agents = Map<int, WorkflowAgent>.from(this.agents);
|
||||
final progress = ev['workflow_progress'];
|
||||
if (progress is List) {
|
||||
for (final raw in progress) {
|
||||
if (raw is! Map) continue;
|
||||
final e = raw.cast<String, dynamic>();
|
||||
final idx = (e['index'] as num?)?.toInt();
|
||||
if (idx == null) continue;
|
||||
switch (e['type']) {
|
||||
case 'workflow_phase':
|
||||
phases[idx] = WorkflowPhase(index: idx, title: (e['title'] as String?) ?? 'phase $idx');
|
||||
case 'workflow_agent':
|
||||
final prior = agents[idx];
|
||||
agents[idx] = prior != null ? prior.mergeDelta(e) : WorkflowAgent.fromDelta(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return _copyWith(summary: ev['summary'] as String?, phases: phases, agents: agents)._foldUsage(ev['usage']);
|
||||
}
|
||||
|
||||
WorkflowRun _foldUsage(Object? usage) {
|
||||
if (usage is! Map) return this;
|
||||
return _copyWith(
|
||||
totalTokens: (usage['total_tokens'] as num?)?.toInt(),
|
||||
toolUses: (usage['tool_uses'] as num?)?.toInt(),
|
||||
durationMs: (usage['duration_ms'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `system` subtypes that carry workflow run progress (T-416). Other system
|
||||
/// subtypes (`init`, `hook_*`, `thinking_tokens`) are unrelated and left alone.
|
||||
const Set<String> kWorkflowSystemSubtypes = {'task_started', 'task_progress', 'task_updated', 'task_notification'};
|
||||
|
||||
/// True when [ev] is a `system` event carrying workflow run progress that names
|
||||
/// a launching tool-use id we can key on.
|
||||
bool isWorkflowSystemEvent(Map<String, dynamic> ev) =>
|
||||
ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true;
|
||||
@@ -53,6 +53,23 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
// Editor split (D-049, D-054)
|
||||
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
|
||||
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
|
||||
// Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every
|
||||
// preset; the vim preset additionally binds gt/gT to these (T-405 part 2,
|
||||
// once a global multi-chord matcher lands — see T-404).
|
||||
CommandContribution(
|
||||
id: 'workspace.tab.next',
|
||||
command: 'workspace.tab.next',
|
||||
title: 'Next Workspace Tab',
|
||||
defaultBinding: 'ctrl+pagedown',
|
||||
run: _nextWorkspaceTab,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'workspace.tab.previous',
|
||||
command: 'workspace.tab.previous',
|
||||
title: 'Previous Workspace Tab',
|
||||
defaultBinding: 'ctrl+pageup',
|
||||
run: _prevWorkspaceTab,
|
||||
),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
@@ -195,6 +212,27 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _nextWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: true);
|
||||
Future<IpcResponse> _prevWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: false);
|
||||
|
||||
/// Cycle the workspace tab strip with wraparound (T-405). A no-op when there
|
||||
/// are fewer than two tabs. Activating a tab also focuses the workspace slot
|
||||
/// so the newly-shown pane takes keyboard focus.
|
||||
Future<IpcResponse> _cycleWorkspaceTab({required bool forward}) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
final tabs = ctx.panels.tabsFor(Slots.workspace);
|
||||
if (tabs.length < 2) return IpcResponse.ok(id: '', data: const {'cycled': false});
|
||||
final active = ctx.panels.activeTabIn(Slots.workspace);
|
||||
final cur = tabs.indexWhere((t) => t.id == active);
|
||||
final start = cur < 0 ? 0 : cur;
|
||||
final next = (start + (forward ? 1 : -1) + tabs.length) % tabs.length;
|
||||
final nextId = tabs[next].id;
|
||||
ctx.panels.activateTab(Slots.workspace, nextId);
|
||||
ctx.focus.setActive(slot: Slots.workspace, contributionId: nextId);
|
||||
return IpcResponse.ok(id: '', data: {'active': nextId});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _focusRight(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
|
||||
@@ -54,10 +54,16 @@ class _EditorViewState extends State<EditorView> {
|
||||
super.initState();
|
||||
_text = SyntaxTextController(syntax: _syntax);
|
||||
_focus = FocusNode();
|
||||
_focus.addListener(_onFocusChanged);
|
||||
_text.addListener(_onTextChanged);
|
||||
_tabs.addListener(_onTabsChanged);
|
||||
}
|
||||
|
||||
/// Publish `editor.focused` so non-editor panes can guard their vim nav
|
||||
/// bindings (`!editor.focused`) — when the editor holds focus, j/k/h/l/gg/G
|
||||
/// stay buffer motions; when a pane holds focus they become nav (T-406).
|
||||
void _onFocusChanged() => _keymap?.setScopeFlag('editor.focused', _focus.hasFocus);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -75,12 +81,14 @@ class _EditorViewState extends State<EditorView> {
|
||||
void dispose() {
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.dispose();
|
||||
_focus.removeListener(_onFocusChanged);
|
||||
_focus.dispose();
|
||||
_tabs.removeListener(_onTabsChanged);
|
||||
_tabs.dispose();
|
||||
_controller?.removeListener(_onControllerChanged);
|
||||
_controller?.dispose();
|
||||
_keymap?.removeListener(_onModeChanged);
|
||||
_keymap?.clearScopeFlag('editor.focused');
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,26 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// One row in the flattened, currently-visible tree (T-406). The visible set is
|
||||
/// a pre-order walk of the root plus the children of every expanded directory —
|
||||
/// the same order the tree renders — so a selection cursor can move over it with
|
||||
/// j/k.
|
||||
@immutable
|
||||
class TreeNode {
|
||||
const TreeNode({required this.path, required this.name, required this.isDirectory, required this.depth});
|
||||
|
||||
final String path;
|
||||
final String name;
|
||||
final bool isDirectory;
|
||||
final int depth;
|
||||
}
|
||||
|
||||
class FileTreeController extends ChangeNotifier {
|
||||
FileTreeController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
@@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier {
|
||||
final Map<String, List<FileEntry>> _entries = {};
|
||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
||||
|
||||
/// Display name of the workspace root row ('' path).
|
||||
String get rootName => _rootPath?.split(Platform.pathSeparator).last ?? '';
|
||||
|
||||
// -- Keyboard selection cursor (T-406) -------------------------------------
|
||||
|
||||
/// The path of the currently selected row, or null when nothing is selected.
|
||||
/// '' is the workspace-root row.
|
||||
String? _selectedPath;
|
||||
String? get selectedPath => _selectedPath;
|
||||
|
||||
/// The flattened, currently-visible rows in render order: the root, then the
|
||||
/// children of every expanded directory, depth-first.
|
||||
List<TreeNode> visibleNodes() {
|
||||
final out = <TreeNode>[];
|
||||
if (_rootPath == null) return out;
|
||||
out.add(TreeNode(path: '', name: rootName, isDirectory: true, depth: 0));
|
||||
if (isExpanded('')) _appendChildren('', 1, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
void _appendChildren(String path, int depth, List<TreeNode> out) {
|
||||
final entries = _entries[path];
|
||||
if (entries == null) return;
|
||||
for (final e in entries) {
|
||||
out.add(TreeNode(path: e.path, name: e.name, isDirectory: e.isDirectory, depth: depth));
|
||||
if (e.isDirectory && _expanded.contains(e.path)) _appendChildren(e.path, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
TreeNode? _selectedNode([List<TreeNode>? nodes]) {
|
||||
final list = nodes ?? visibleNodes();
|
||||
for (final n in list) {
|
||||
if (n.path == _selectedPath) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Move the selection cursor [delta] rows (negative = up), clamped to the
|
||||
/// visible list. A first move with nothing selected lands on the first row
|
||||
/// (down) or last row (up).
|
||||
void moveSelection(int delta) {
|
||||
final nodes = visibleNodes();
|
||||
if (nodes.isEmpty) return;
|
||||
final cur = nodes.indexWhere((n) => n.path == _selectedPath);
|
||||
final next = cur < 0 ? (delta > 0 ? 0 : nodes.length - 1) : (cur + delta).clamp(0, nodes.length - 1);
|
||||
if (nodes[next].path == _selectedPath) return;
|
||||
_selectedPath = nodes[next].path;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Select the first ([top]) or last visible row — vim gg / G.
|
||||
void selectEdge({required bool top}) {
|
||||
final nodes = visibleNodes();
|
||||
if (nodes.isEmpty) return;
|
||||
final path = (top ? nodes.first : nodes.last).path;
|
||||
if (path == _selectedPath) return;
|
||||
_selectedPath = path;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Collapse the selected directory, or — if it's already collapsed (or a
|
||||
/// file) — step the selection out to its parent row (vim `h`).
|
||||
Future<void> collapseOrOut() async {
|
||||
final node = _selectedNode();
|
||||
if (node == null) return;
|
||||
if (node.isDirectory && node.path != '' && _expanded.contains(node.path)) {
|
||||
await toggle(node.path); // collapse in place; selection stays on the dir
|
||||
return;
|
||||
}
|
||||
if (node.path == '') return; // already at root
|
||||
_selectedPath = _parentOf(node.path);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Expand the selected directory, or — if it's already expanded — step the
|
||||
/// selection into its first child (vim `l`). A file is a no-op.
|
||||
Future<void> expandOrInto() async {
|
||||
final node = _selectedNode();
|
||||
if (node == null || !node.isDirectory) return;
|
||||
if (!_expanded.contains(node.path)) {
|
||||
await toggle(node.path); // expand
|
||||
return;
|
||||
}
|
||||
final children = _entries[node.path];
|
||||
if (children != null && children.isNotEmpty) {
|
||||
_selectedPath = children.first.path;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the selected row to an action target for the view: a directory to
|
||||
/// toggle, or a file path to open (vim `o` / `enter`). Returns null when
|
||||
/// nothing is selected.
|
||||
({bool isDirectory, String path})? activateTarget() {
|
||||
final node = _selectedNode();
|
||||
if (node == null) return null;
|
||||
return (isDirectory: node.isDirectory, path: node.path);
|
||||
}
|
||||
|
||||
List<FileEntry> allLoadedEntries() {
|
||||
final out = <FileEntry>[];
|
||||
for (final list in _entries.values) {
|
||||
|
||||
@@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget {
|
||||
class _FileTreeViewState extends State<FileTreeView> {
|
||||
FileTreeController? _controller;
|
||||
String _filter = '';
|
||||
final ScrollController _scroll = ScrollController();
|
||||
|
||||
/// Key on the currently-selected row, so a keyboard move can scroll it into
|
||||
/// view (T-406).
|
||||
final GlobalKey _selectedKey = GlobalKey();
|
||||
|
||||
/// Half-page step for ctrl+d / ctrl+u over the flattened tree.
|
||||
static const int _pageStep = 10;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -39,9 +47,52 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onNav(NavIntent intent, int count, FileTreeController c) {
|
||||
switch (intent) {
|
||||
case NavDownIntent():
|
||||
c.moveSelection(count);
|
||||
case NavUpIntent():
|
||||
c.moveSelection(-count);
|
||||
case NavPageDownIntent():
|
||||
c.moveSelection(_pageStep);
|
||||
case NavPageUpIntent():
|
||||
c.moveSelection(-_pageStep);
|
||||
case NavTopIntent():
|
||||
c.selectEdge(top: true);
|
||||
case NavBottomIntent():
|
||||
c.selectEdge(top: false);
|
||||
case NavExpandOrRightIntent():
|
||||
unawaited(c.expandOrInto());
|
||||
case NavCollapseOrLeftIntent():
|
||||
unawaited(c.collapseOrOut());
|
||||
case NavActivateIntent():
|
||||
_activateSelected(c);
|
||||
}
|
||||
}
|
||||
|
||||
void _activateSelected(FileTreeController c) {
|
||||
final t = c.activateTarget();
|
||||
if (t == null) return;
|
||||
if (t.isDirectory) {
|
||||
unawaited(c.toggle(t.path));
|
||||
} else {
|
||||
openWorkspaceFile(ClideKernel.of(context), t.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the selected row into view after the frame it's laid out in.
|
||||
void _ensureSelectedVisible() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _selectedKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
Scrollable.ensureVisible(ctx, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: const Duration(milliseconds: 80));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
@@ -57,6 +108,23 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
final selected = c.selectedPath;
|
||||
if (_filter.isEmpty && selected != null) _ensureSelectedVisible();
|
||||
final scroller = SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0, selectedPath: selected, selectedKey: _selectedKey),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1, selectedPath: selected, selectedKey: _selectedKey),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
||||
@@ -65,20 +133,10 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
label: 'file tree — $rootName',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
|
||||
// region holds focus under the vim preset (T-406). The filter
|
||||
// box sits outside it, so typing a filter is never intercepted.
|
||||
child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -97,11 +155,13 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
}
|
||||
|
||||
class _Children extends StatelessWidget {
|
||||
const _Children({required this.path, required this.controller, required this.depth});
|
||||
const _Children({required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -117,58 +177,67 @@ class _Children extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
|
||||
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
if (controller.isExpanded(e.path))
|
||||
_Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
],
|
||||
)
|
||||
else
|
||||
_FileRow(name: e.name, path: e.path, depth: depth),
|
||||
_FileRow(name: e.name, path: e.path, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirRow extends StatelessWidget {
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = controller.isExpanded(path);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '${expanded ? 'Collapse' : 'Expand'} $name',
|
||||
onTap: () => controller.toggle(path),
|
||||
child: _Row(
|
||||
key: selected ? selectedKey : null,
|
||||
depth: depth,
|
||||
onTap: () => controller.toggle(path),
|
||||
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
|
||||
label: name,
|
||||
rotateLeading: expanded,
|
||||
selected: selected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({required this.name, required this.path, required this.depth});
|
||||
const _FileRow({required this.name, required this.path, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
onTap: () => _openFile(context, path),
|
||||
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
|
||||
child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +249,7 @@ class _FileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
|
||||
const _Row({super.key, required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false, this.selected = false});
|
||||
|
||||
final int depth;
|
||||
final VoidCallback onTap;
|
||||
@@ -188,6 +257,10 @@ class _Row extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final bool rotateLeading;
|
||||
|
||||
/// True when the keyboard selection cursor is on this row (T-406) — draws a
|
||||
/// persistent highlight + accent ring, distinct from transient hover.
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
@@ -195,7 +268,12 @@ class _Row extends StatelessWidget {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
decoration: selected
|
||||
? BoxDecoration(
|
||||
color: tokens.sidebarItemHover,
|
||||
border: Border.all(color: tokens.globalFocus, width: 1),
|
||||
)
|
||||
: (hovered ? BoxDecoration(color: tokens.sidebarItemHover) : null),
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class GraphView extends StatefulWidget {
|
||||
const GraphView({super.key});
|
||||
|
||||
@override
|
||||
State<GraphView> createState() => _GraphViewState();
|
||||
}
|
||||
|
||||
class _GraphViewState extends State<GraphView> {
|
||||
List<_GraphNode> _nodes = [];
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_loading || _nodes.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request(
|
||||
'pql.exec',
|
||||
args: {
|
||||
'argv': ['search', '--connections', '--limit', '50'],
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'failed to load graph';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
_nodes = list.map(_GraphNode.fromJson).toList();
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'parse error: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) {
|
||||
return const Center(child: ClideText('Loading graph...', muted: true));
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
}
|
||||
if (_nodes.isEmpty) {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _nodes.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final n = _nodes[i];
|
||||
return _NodeRow(node: n, tokens: tokens);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GraphNode {
|
||||
const _GraphNode({required this.path, this.inbound = 0, this.outbound = 0});
|
||||
final String path;
|
||||
final int inbound;
|
||||
final int outbound;
|
||||
|
||||
factory _GraphNode.fromJson(Map<String, dynamic> json) => _GraphNode(
|
||||
path: json['path'] as String? ?? json['relative_path'] as String? ?? '',
|
||||
inbound: (json['inbound_count'] as num?)?.toInt() ?? 0,
|
||||
outbound: (json['outbound_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _NodeRow extends StatelessWidget {
|
||||
const _NodeRow({required this.node, required this.tokens});
|
||||
final _GraphNode node;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(node.path, fontSize: clideFontCaption)),
|
||||
ClideText('${node.inbound}in ${node.outbound}out', color: tokens.globalTextMuted, fontSize: clideFontSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
String? _error;
|
||||
int _pid = 0;
|
||||
|
||||
/// Cached in didChangeDependencies — ancestor lookups are illegal in
|
||||
/// dispose(), and the old lookup-and-swallow there meant pane.close
|
||||
/// was never sent, leaking the backend PTY + daemon pane (T-366).
|
||||
KernelServices? _kernel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -45,6 +50,12 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _spawn());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_kernel = ClideKernel.of(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
@@ -53,14 +64,14 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
_paneId = null;
|
||||
if (id != null) {
|
||||
// Fire-and-forget. Daemon-side pane.close is idempotent.
|
||||
unawaited(_kernelIpc()?.request('pane.close', args: {'id': id}));
|
||||
unawaited(_kernel?.ipc.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
final ipc = _kernelIpc();
|
||||
final ipc = _kernel?.ipc;
|
||||
if (ipc == null || !ipc.isConnected) {
|
||||
setState(() => _error = 'Backend not connected.');
|
||||
return;
|
||||
@@ -71,7 +82,9 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
// fallback.
|
||||
final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash');
|
||||
final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo'];
|
||||
final cwd = Directory.current.path;
|
||||
// The open workspace, not Directory.current — a desktop launch starts
|
||||
// in $HOME and a project switch doesn't move the process CWD (T-381).
|
||||
final cwd = _kernel?.project.current?.path ?? Directory.current.path;
|
||||
|
||||
final response = await ipc.request(
|
||||
'pane.spawn',
|
||||
@@ -90,7 +103,7 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
}
|
||||
|
||||
void _subscribeToPaneEvents() {
|
||||
final kernel = _kernel();
|
||||
final kernel = _kernel;
|
||||
if (kernel == null) return;
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((event) {
|
||||
if (event.subsystem != 'pane') return;
|
||||
@@ -99,8 +112,9 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
case 'pane.output':
|
||||
final b64 = event.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
final bytes = base64Decode(b64);
|
||||
_terminal.write(utf8.decode(bytes, allowMalformed: true));
|
||||
// writeBytes keeps UTF-8 decode state across chunks — a rune
|
||||
// split across PTY reads must not become U+FFFD (T-373).
|
||||
_terminal.writeBytes(base64Decode(b64));
|
||||
}
|
||||
case 'pane.exit':
|
||||
setState(() => _error = 'Shell exited.');
|
||||
@@ -115,23 +129,13 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
void _onTerminalOutput(String text) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
_kernel?.ipc.request('pane.write', args: {'id': id, 'text': text});
|
||||
}
|
||||
|
||||
void _onTerminalResize(int cols, int rows, int pixelWidth, int pixelHeight) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
}
|
||||
|
||||
DaemonClient? _kernelIpc() => _kernel()?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
try {
|
||||
return ClideKernel.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
_kernel?.ipc.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -74,13 +74,16 @@ class _TipsCard extends StatelessWidget {
|
||||
const _TipsCard({required this.tokens});
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
// Every tip mirrors a binding that actually exists in the default
|
||||
// preset / contributed commands (T-383) — ctrl-based on the shipped
|
||||
// default keymap, hence ⌃ glyphs. If a binding moves, move the tip.
|
||||
static const _tips = <(String, String)>[
|
||||
('Quick open', '⌘P'),
|
||||
('Command palette', '⌘⇧P'),
|
||||
('Toggle sidebar', '⌘B'),
|
||||
('Toggle context', '⌘J'),
|
||||
('Switch theme', '⌘K ⌘T'),
|
||||
('New Claude session', '⌘⇧C'),
|
||||
('Quick open', '⌃P'),
|
||||
('Command palette', '⌃⇧P'),
|
||||
('Toggle sidebar', '⌃⇧1'),
|
||||
('Toggle context', '⌃⇧3'),
|
||||
('Find in files', '⌃⇧F'),
|
||||
('Focus mode', '⌃.'),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -169,9 +172,10 @@ class _StartColumn extends StatelessWidget {
|
||||
children: [
|
||||
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 20),
|
||||
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌘O', tokens: tokens, onTap: () => _openFolder(context)),
|
||||
_ActionRow(icon: PhosphorIcons.byName('git-branch'), label: 'Clone from git…', shortcut: '⌘G', tokens: tokens, onTap: () {}),
|
||||
_ActionRow(icon: PhosphorIcons.byName('chat-circle'), label: 'Start a Claude session', shortcut: '⌘C', tokens: tokens, onTap: () {}),
|
||||
// Only flows that exist get a tile — the old Clone-from-git and
|
||||
// Start-a-Claude-session rows were inert and advertised shortcuts
|
||||
// that were never registered (T-383). Re-add each WITH its flow.
|
||||
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌃O', tokens: tokens, onTap: () => _openFolder(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,10 @@ export 'src/pql/client.dart' show PqlClient, PqlException;
|
||||
export 'src/ipc/envelope.dart';
|
||||
export 'src/ipc/paths.dart';
|
||||
export 'src/ipc/schema_v1.dart';
|
||||
export 'src/ipc/transport.dart' show DaemonTransport, DaemonConnection, LocalSocketTransport;
|
||||
export 'src/panes/event_sink.dart';
|
||||
export 'src/panes/pane.dart' show Pane, PaneKind;
|
||||
export 'src/util/value_stream.dart' show ValueStream;
|
||||
|
||||
// clideName, clideTagline, clideVersion, clideRepository, clideCommit,
|
||||
// clideDate live in lib/src/build_info.g.dart, regenerated by every
|
||||
|
||||
@@ -27,6 +27,7 @@ class TabContribution extends ContributionPoint {
|
||||
required this.title,
|
||||
required this.build,
|
||||
this.icon,
|
||||
this.iconColor,
|
||||
this.priority = 0,
|
||||
this.fileGlobs = const [],
|
||||
this.listenable,
|
||||
@@ -39,6 +40,9 @@ class TabContribution extends ContributionPoint {
|
||||
final String title;
|
||||
final WidgetBuilder build;
|
||||
final Object? icon;
|
||||
|
||||
/// Optional identity tint for the icon-rail glyph (T-418).
|
||||
final Color? iconColor;
|
||||
final int priority;
|
||||
final List<String> fileGlobs;
|
||||
final Listenable? listenable;
|
||||
|
||||
@@ -28,6 +28,7 @@ export 'src/keymap/key_chord.dart';
|
||||
export 'src/keymap/keymap.dart';
|
||||
export 'src/keymap/keymap_service.dart';
|
||||
export 'src/keymap/modifier_tap.dart';
|
||||
export 'src/keymap/pane_key_nav.dart';
|
||||
export 'src/keymap/sequence_matcher.dart';
|
||||
export 'src/keymap/when_clause.dart';
|
||||
export 'src/dialog.dart';
|
||||
@@ -65,3 +66,4 @@ export 'src/theme/semantic.dart';
|
||||
export 'src/theme/tokens.dart';
|
||||
export 'src/toolchain.dart';
|
||||
export 'src/window_controls.dart';
|
||||
export 'src/workspace_ref.dart';
|
||||
|
||||
@@ -144,10 +144,17 @@ class ExtensionManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
final ctx = _ExtensionContext(manager: this, id: ext.id);
|
||||
// Transactional: a throw mid-activation must leave NOTHING mounted —
|
||||
// the old path left earlier contributions live while the extension
|
||||
// recorded as failed, and a retry double-applied them (T-377).
|
||||
final applied = <ContributionPoint>[];
|
||||
var extActivated = false;
|
||||
try {
|
||||
await ext.activate(ctx);
|
||||
extActivated = true;
|
||||
for (final c in ext.contributions) {
|
||||
_applyContribution(c);
|
||||
applied.add(c);
|
||||
}
|
||||
// Eagerly load the i18n catalog for any localized tab this extension
|
||||
// contributes, so its title resolves without a "namespace not
|
||||
@@ -165,6 +172,22 @@ class ExtensionManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
log.info('extensions', 'activated $id');
|
||||
} catch (e, st) {
|
||||
for (final c in applied.reversed) {
|
||||
try {
|
||||
_removeContribution(c);
|
||||
} catch (e2) {
|
||||
log.warn('extensions', 'unwind of ${c.id} failed during $id rollback: $e2');
|
||||
}
|
||||
}
|
||||
if (extActivated) {
|
||||
// The extension's own activate() succeeded — give it the matching
|
||||
// teardown so it doesn't hold resources for a failed activation.
|
||||
try {
|
||||
await ext.deactivate();
|
||||
} catch (e2) {
|
||||
log.warn('extensions', 'deactivate during $id rollback failed: $e2');
|
||||
}
|
||||
}
|
||||
_failed[id] = e;
|
||||
log.error('extensions', 'activate failed for $id', error: e, stackTrace: st);
|
||||
notifyListeners();
|
||||
@@ -175,6 +198,17 @@ class ExtensionManager extends ChangeNotifier {
|
||||
if (!_activated.contains(id)) return;
|
||||
final ext = _known[id];
|
||||
if (ext == null) return;
|
||||
// Refuse while active extensions depend on this one — deactivating
|
||||
// underneath them leaves them running against missing services (T-377).
|
||||
// Disable the dependents first.
|
||||
final dependents = [
|
||||
for (final e in _known.values)
|
||||
if (_activated.contains(e.id) && e.dependsOn.contains(id)) e.id,
|
||||
];
|
||||
if (dependents.isNotEmpty) {
|
||||
log.warn('extensions', 'refusing to deactivate $id: active dependents: ${dependents.join(', ')}');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ext.deactivate();
|
||||
for (final c in ext.contributions) {
|
||||
@@ -196,8 +230,17 @@ class ExtensionManager extends ChangeNotifier {
|
||||
case TabContribution _:
|
||||
case StatusItemContribution _:
|
||||
case ToolbarButtonContribution _:
|
||||
// Reject duplicates instead of silently mounting a second copy —
|
||||
// benign among curated builtins, hazardous once third-party
|
||||
// extensions land (T-377). The throw rolls the activation back.
|
||||
if (panels.hasContribution(c.id)) {
|
||||
throw StateError('duplicate contribution id: ${c.id}');
|
||||
}
|
||||
panels.contribute(c);
|
||||
case CommandContribution cmd:
|
||||
if (commands.get(cmd.command) != null) {
|
||||
throw StateError('duplicate command id: ${cmd.command}');
|
||||
}
|
||||
commands.register(cmd);
|
||||
final binding = cmd.defaultBinding;
|
||||
if (binding != null) {
|
||||
|
||||
@@ -144,7 +144,7 @@ class KernelServices {
|
||||
final messages = MessageBus();
|
||||
final filterStates = FilterStateCache(messages: messages);
|
||||
|
||||
final settings = SettingsStore(appDir: appDir);
|
||||
final settings = SettingsStore(appDir: appDir, onError: (m) => log.warn('settings', m));
|
||||
await settings.load();
|
||||
|
||||
final i18n = I18n(loader: i18nLoader, log: log, defaultLocale: defaultLocale, initialLocale: initialLocale, availableLocales: availableLocales);
|
||||
@@ -166,7 +166,7 @@ class KernelServices {
|
||||
final readerNav = ReaderNavRegistry(messages);
|
||||
final clipboard = ClideClipboard();
|
||||
final files = FileServices(events);
|
||||
final notify = Notifications();
|
||||
final notify = Notifications(messages: messages);
|
||||
final dialog = DialogRouter();
|
||||
final tray = TrayRegistry();
|
||||
final secrets = SecretsVault();
|
||||
@@ -191,7 +191,7 @@ class KernelServices {
|
||||
isolateClient ??
|
||||
(daemonClientFactory != null
|
||||
? daemonClientFactory(log, events, arrangement, panels)
|
||||
: DaemonClient(
|
||||
: DaemonClient.unixSocket(
|
||||
// Legacy socket-client fallback — kept until T-127
|
||||
// replaces it with the in-process socket loopback.
|
||||
// Today nothing in production hits this branch
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
@@ -10,14 +8,24 @@ import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class DaemonClient extends ChangeNotifier {
|
||||
DaemonClient({required String socketPath, required Logger log, required DaemonBus events}) : _socketPath = socketPath, _log = log, _events = events;
|
||||
/// Connects through [transport] (T-331). The local app passes a
|
||||
/// [LocalSocketTransport]; a remote workspace will pass an SSH-backed
|
||||
/// transport without this class changing.
|
||||
DaemonClient({required DaemonTransport transport, required Logger log, required DaemonBus events}) : _transport = transport, _log = log, _events = events;
|
||||
|
||||
String _socketPath;
|
||||
String get socketPath => _socketPath;
|
||||
/// Convenience for the local unix-socket path — today's only
|
||||
/// production shape.
|
||||
DaemonClient.unixSocket({required String socketPath, required Logger log, required DaemonBus events})
|
||||
: this(transport: LocalSocketTransport(socketPath), log: log, events: events);
|
||||
|
||||
DaemonTransport _transport;
|
||||
|
||||
/// The backend endpoint description — the unix socket path locally.
|
||||
String get socketPath => _transport.endpoint;
|
||||
final Logger _log;
|
||||
final DaemonBus _events;
|
||||
|
||||
Socket? _socket;
|
||||
DaemonConnection? _conn;
|
||||
bool _connected = false;
|
||||
bool _disposed = false;
|
||||
bool _started = false;
|
||||
@@ -48,30 +56,33 @@ class DaemonClient extends ChangeNotifier {
|
||||
_started = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
final s = _socket;
|
||||
_socket = null;
|
||||
await s?.close();
|
||||
final c = _conn;
|
||||
_conn = null;
|
||||
await c?.close();
|
||||
_failPending('client stopped');
|
||||
_wakeConnectWaiters();
|
||||
_setConnected(false);
|
||||
}
|
||||
|
||||
/// Point the client at a different socket path and reconnect.
|
||||
/// Point the client at a different local socket path and reconnect.
|
||||
/// Used on project switch — the workspace-derived socket path
|
||||
/// (D-70) changes when the user opens a different project, so the
|
||||
/// client follows. Cancels the reconnect timer, closes the live
|
||||
/// socket (failing in-flight requests with `disconnect`), updates
|
||||
/// the path, and re-arms the connect loop. Idempotent if the new
|
||||
/// path equals the current one.
|
||||
Future<void> reconnectAt(String newPath) async {
|
||||
if (newPath == _socketPath && _connected) return;
|
||||
_socketPath = newPath;
|
||||
/// client follows. Sugar over [reconnectWith].
|
||||
Future<void> reconnectAt(String newPath) => reconnectWith(LocalSocketTransport(newPath));
|
||||
|
||||
/// Swap the backend transport and reconnect. Cancels the reconnect
|
||||
/// timer, closes the live connection (failing in-flight requests with
|
||||
/// `disconnect`), swaps the transport, and re-arms the connect loop.
|
||||
/// Idempotent if the new endpoint equals the current connected one.
|
||||
Future<void> reconnectWith(DaemonTransport transport) async {
|
||||
if (transport.endpoint == _transport.endpoint && _connected) return;
|
||||
_transport = transport;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
final s = _socket;
|
||||
_socket = null;
|
||||
await s?.close();
|
||||
_failPending('socket path changed');
|
||||
final c = _conn;
|
||||
_conn = null;
|
||||
await c?.close();
|
||||
_failPending('backend endpoint changed');
|
||||
_setConnected(false);
|
||||
_disposed = false;
|
||||
_started = true;
|
||||
@@ -80,7 +91,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<IpcResponse> request(String cmd, {Map<String, Object?> args = const {}}) async {
|
||||
if (!_connected || _socket == null) {
|
||||
if (!_connected || _conn == null) {
|
||||
// A connection attempt is in flight (startup or reconnect) — wait
|
||||
// for it rather than failing instantly, so queries issued during
|
||||
// the startup window don't get a spurious not-connected error.
|
||||
@@ -88,7 +99,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
if (_started && !_disposed) {
|
||||
await _awaitConnected(_connectWait);
|
||||
}
|
||||
if (!_connected || _socket == null) {
|
||||
if (!_connected || _conn == null) {
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'daemon not connected'),
|
||||
@@ -99,7 +110,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
final completer = Completer<IpcResponse>();
|
||||
_pending[id] = completer;
|
||||
final req = IpcRequest(id: id, cmd: cmd, args: args);
|
||||
_socket!.writeln(req.encode());
|
||||
_conn!.writeLine(req.encode());
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
@@ -126,30 +137,25 @@ class DaemonClient extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
// Already connected? Don't open a second socket. Guards against
|
||||
// Already connected? Don't open a second connection. Guards against
|
||||
// racing connect attempts (e.g. start() arming the reconnect loop
|
||||
// while swapIpcServer's reconnectAt connects on first boot).
|
||||
// while swapBackend's reconnectAt connects on first boot).
|
||||
if (_disposed || _connected) return;
|
||||
try {
|
||||
final addr = InternetAddress(_socketPath, type: InternetAddressType.unix);
|
||||
final socket = await Socket.connect(addr, 0);
|
||||
_socket = socket;
|
||||
final conn = await _transport.open();
|
||||
_conn = conn;
|
||||
_backoff = const Duration(milliseconds: 200);
|
||||
_setConnected(true);
|
||||
_log.info('ipc', 'connected to $_socketPath');
|
||||
socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
_handleLine,
|
||||
onDone: _handleDisconnect,
|
||||
onError: (Object e) {
|
||||
_log.warn('ipc', 'socket error', error: e);
|
||||
_handleDisconnect();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
_log.info('ipc', 'connected to ${_transport.endpoint}');
|
||||
conn.lines.listen(
|
||||
_handleLine,
|
||||
onDone: _handleDisconnect,
|
||||
onError: (Object e) {
|
||||
_log.warn('ipc', 'socket error', error: e);
|
||||
_handleDisconnect();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.debug('ipc', 'connect failed ($e); retry in ${_backoff.inMilliseconds}ms');
|
||||
_scheduleReconnect();
|
||||
@@ -175,7 +181,7 @@ class DaemonClient extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleDisconnect() {
|
||||
_socket = null;
|
||||
_conn = null;
|
||||
_failPending('daemon disconnected');
|
||||
_setConnected(false);
|
||||
_scheduleReconnect();
|
||||
@@ -218,8 +224,9 @@ class DaemonClient extends ChangeNotifier {
|
||||
_disposed = true;
|
||||
_started = false;
|
||||
_reconnectTimer?.cancel();
|
||||
unawaited(_socket?.close());
|
||||
_socket = null;
|
||||
final c = _conn;
|
||||
if (c != null) unawaited(c.close());
|
||||
_conn = null;
|
||||
_failPending('client disposed');
|
||||
_wakeConnectWaiters();
|
||||
super.dispose();
|
||||
|
||||
@@ -98,6 +98,63 @@ class TextScaleResetIntent extends Intent {
|
||||
const TextScaleResetIntent();
|
||||
}
|
||||
|
||||
// -- Pane navigation (vim normal-mode motions outside the editor) ------------
|
||||
|
||||
/// Base for the preset-neutral navigation intents (T-406). A focused non-editor
|
||||
/// pane (file tree, conversation, lists) runs its own [SequenceMatcher] and
|
||||
/// dispatches the resolved [NavIntent] to its own handler — the vim preset binds
|
||||
/// j/k/etc. to these; default/vscode/jetbrains can later bind arrows/page keys
|
||||
/// to the same ids. Marker base so a pane's key handler can tell a nav motion
|
||||
/// apart from any other fired intent.
|
||||
sealed class NavIntent extends Intent {
|
||||
const NavIntent();
|
||||
}
|
||||
|
||||
/// Move the selection / scroll down one step (vim `j`).
|
||||
class NavDownIntent extends NavIntent {
|
||||
const NavDownIntent();
|
||||
}
|
||||
|
||||
/// Move the selection / scroll up one step (vim `k`).
|
||||
class NavUpIntent extends NavIntent {
|
||||
const NavUpIntent();
|
||||
}
|
||||
|
||||
/// Scroll down half a viewport (vim `ctrl+d`).
|
||||
class NavPageDownIntent extends NavIntent {
|
||||
const NavPageDownIntent();
|
||||
}
|
||||
|
||||
/// Scroll up half a viewport (vim `ctrl+u`).
|
||||
class NavPageUpIntent extends NavIntent {
|
||||
const NavPageUpIntent();
|
||||
}
|
||||
|
||||
/// Jump to the first item / top (vim `gg`).
|
||||
class NavTopIntent extends NavIntent {
|
||||
const NavTopIntent();
|
||||
}
|
||||
|
||||
/// Jump to the last item / bottom (vim `G`).
|
||||
class NavBottomIntent extends NavIntent {
|
||||
const NavBottomIntent();
|
||||
}
|
||||
|
||||
/// Expand the focused node, or step into it / move right (vim `l`).
|
||||
class NavExpandOrRightIntent extends NavIntent {
|
||||
const NavExpandOrRightIntent();
|
||||
}
|
||||
|
||||
/// Collapse the focused node, or step out of it / move left (vim `h`).
|
||||
class NavCollapseOrLeftIntent extends NavIntent {
|
||||
const NavCollapseOrLeftIntent();
|
||||
}
|
||||
|
||||
/// Activate the focused item — open the file, run the row (vim `o` / `enter`).
|
||||
class NavActivateIntent extends NavIntent {
|
||||
const NavActivateIntent();
|
||||
}
|
||||
|
||||
// -- Command bridge ---------------------------------------------------------
|
||||
|
||||
/// Generic "invoke this CommandRegistry command id" intent. Used for
|
||||
@@ -136,6 +193,16 @@ final Map<String, Intent Function()> builtinIntents = {
|
||||
'quickOpen.selectPrevious': () => const QuickOpenSelectPreviousIntent(),
|
||||
'quickOpen.accept': () => const QuickOpenAcceptIntent(),
|
||||
'findInFiles.open': () => const FindInFilesIntent(),
|
||||
// Pane navigation (T-406) — preset-neutral; the vim preset binds j/k/etc.
|
||||
'nav.down': () => const NavDownIntent(),
|
||||
'nav.up': () => const NavUpIntent(),
|
||||
'nav.pageDown': () => const NavPageDownIntent(),
|
||||
'nav.pageUp': () => const NavPageUpIntent(),
|
||||
'nav.top': () => const NavTopIntent(),
|
||||
'nav.bottom': () => const NavBottomIntent(),
|
||||
'nav.expandOrRight': () => const NavExpandOrRightIntent(),
|
||||
'nav.collapseOrLeft': () => const NavCollapseOrLeftIntent(),
|
||||
'nav.activate': () => const NavActivateIntent(),
|
||||
'text.scaleIncrease': () => const TextScaleIncreaseIntent(),
|
||||
'text.scaleDecrease': () => const TextScaleDecreaseIntent(),
|
||||
'text.scaleReset': () => const TextScaleResetIntent(),
|
||||
|
||||
@@ -167,20 +167,37 @@ class KeymapService extends ChangeNotifier {
|
||||
return km.match(sequence, _scope).exact;
|
||||
}
|
||||
|
||||
/// Scope-flag producers clear their flags from widget dispose() — which
|
||||
/// during app teardown runs AFTER KernelServices.dispose() has disposed
|
||||
/// this notifier. Tolerate that ordering instead of asserting (the same
|
||||
/// fire-and-forget pattern SettingsStore uses).
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _safeNotify() {
|
||||
if (_disposed) return;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set a named scope flag. Producers should call this when their
|
||||
/// state changes so when-clauses re-evaluate correctly. Notifies
|
||||
/// listeners when the value actually changes.
|
||||
void setScopeFlag(String name, bool value) {
|
||||
if (_scope[name] == value) return;
|
||||
_scope[name] = value;
|
||||
notifyListeners();
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Clear a named scope flag.
|
||||
void clearScopeFlag(String name) {
|
||||
if (!_scope.containsKey(name)) return;
|
||||
_scope.remove(name);
|
||||
notifyListeners();
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Switch presets. Persists the new preset name to settings and
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/// Detects a double-tapped bare modifier (e.g. JetBrains "Search
|
||||
/// Everywhere" = double-Shift). (T-341)
|
||||
///
|
||||
/// Headless and clock-injected: the caller (the global key handler) passes
|
||||
/// the event time so it neither reads a clock nor consumes events. Feed it
|
||||
/// every [KeyDownEvent]: a bare modifier press via [tap], any other key via
|
||||
/// [reset] (an intervening key breaks the gesture, e.g. `Shift a Shift`).
|
||||
/// A "tap" is a clean press-and-release: no other key may go down while the
|
||||
/// modifier is held, otherwise the press was a chord (`Shift+;` typing a
|
||||
/// colon) and must not count (T-409). The gesture therefore completes on the
|
||||
/// second clean *release*, never on a key-down — at down time it's unknowable
|
||||
/// whether the press will stay bare.
|
||||
///
|
||||
/// Headless and clock-injected: the caller (the root shell's raw-keyboard
|
||||
/// handler) passes the event time so it neither reads a clock nor consumes
|
||||
/// events. Feed every [KeyDownEvent] to [down] and every [KeyUpEvent] to
|
||||
/// [up], passing the event's [KeyModifier] (null for non-modifier keys).
|
||||
library;
|
||||
|
||||
import 'key_chord.dart';
|
||||
@@ -12,33 +18,50 @@ import 'key_chord.dart';
|
||||
class ModifierTapTracker {
|
||||
ModifierTapTracker({this.window = const Duration(milliseconds: 350)});
|
||||
|
||||
/// Max gap between the two taps to count as a double-tap.
|
||||
/// Max gap between the two tap releases to count as a double-tap.
|
||||
final Duration window;
|
||||
|
||||
KeyModifier? _last;
|
||||
DateTime? _lastAt;
|
||||
/// Modifier currently held whose press is still bare (no chorded key yet).
|
||||
KeyModifier? _pressing;
|
||||
|
||||
/// Record a bare-modifier press at [now]. Returns the modifier when this
|
||||
/// press completes a double-tap of the *same* modifier within [window];
|
||||
/// otherwise records it as the first tap and returns null.
|
||||
KeyModifier? tap(KeyModifier m, DateTime now) {
|
||||
final last = _last;
|
||||
final lastAt = _lastAt;
|
||||
if (last == m && lastAt != null) {
|
||||
final gap = now.difference(lastAt);
|
||||
/// Modifier of the last completed clean tap, arming the double-tap.
|
||||
KeyModifier? _armed;
|
||||
DateTime? _armedAt;
|
||||
|
||||
/// Record a key press. A non-modifier key ([mod] == null) — or any key
|
||||
/// landing while a modifier is already held — is a chord: it dirties the
|
||||
/// held press and breaks the armed gesture.
|
||||
void down(KeyModifier? mod) {
|
||||
if (mod == null || _pressing != null) {
|
||||
_pressing = null;
|
||||
_disarm();
|
||||
return;
|
||||
}
|
||||
_pressing = mod;
|
||||
}
|
||||
|
||||
/// Record a key release at [now]. Returns the modifier when this release
|
||||
/// completes a double-tap: the second clean tap of the *same* modifier
|
||||
/// within [window] of the first tap's release.
|
||||
KeyModifier? up(KeyModifier? mod, DateTime now) {
|
||||
if (mod == null) return null;
|
||||
final pressing = _pressing;
|
||||
_pressing = null;
|
||||
if (pressing != mod) return null; // press went dirty (chorded) or stale
|
||||
if (_armed == mod && _armedAt != null) {
|
||||
final gap = now.difference(_armedAt!);
|
||||
if (gap >= Duration.zero && gap <= window) {
|
||||
reset();
|
||||
return m;
|
||||
_disarm();
|
||||
return mod;
|
||||
}
|
||||
}
|
||||
_last = m;
|
||||
_lastAt = now;
|
||||
_armed = mod;
|
||||
_armedAt = now;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Break the gesture — any non-modifier key press resets the tracker.
|
||||
void reset() {
|
||||
_last = null;
|
||||
_lastAt = null;
|
||||
void _disarm() {
|
||||
_armed = null;
|
||||
_armedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/// A reusable vim normal-mode navigation key handler for non-editor panes
|
||||
/// (T-406).
|
||||
///
|
||||
/// The passive global key path is single-chord only and can't run sequences or
|
||||
/// consume events (D-82), so — exactly like the editor's command-mode handler —
|
||||
/// each pane that wants vim motions hosts its OWN [SequenceMatcher] inside a
|
||||
/// `Focus.onKeyEvent`. [PaneKeyNav] is that handler, factored out so the file
|
||||
/// tree, conversation, and lists share one implementation.
|
||||
///
|
||||
/// While a `vim.normal` scope flag is set and this region holds focus, bare and
|
||||
/// shift-only chords (plus the two half-page chords `ctrl+d` / `ctrl+u`) feed
|
||||
/// the matcher against the live keymap; a fired [NavIntent] is handed to
|
||||
/// [onNav] with its repeat count. Everything else under `vim.normal` is
|
||||
/// swallowed (vim normal mode is inert for unbound keys), except other-modifier
|
||||
/// chords (palette, quick-open, …) which bubble to the global handler. Under a
|
||||
/// non-vim preset or in insert mode the region is transparent — keys pass
|
||||
/// straight through.
|
||||
///
|
||||
/// The vim preset binds nav.* `when: vim.normal && !editor.focused`, so a key
|
||||
/// that also has an `editor.vim.*` motion (j/k/h/l/gg/G) resolves to the nav
|
||||
/// intent here and to the editor motion in the editor — see vim.yaml.
|
||||
library;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../facade.dart';
|
||||
import 'intents.dart';
|
||||
import 'key_chord.dart';
|
||||
import 'keymap.dart';
|
||||
import 'sequence_matcher.dart';
|
||||
|
||||
/// Signature for a fired navigation motion: the [intent] and its repeat
|
||||
/// [count] (>= 1, from a leading digit prefix like `5j`).
|
||||
typedef NavHandler = void Function(NavIntent intent, int count);
|
||||
|
||||
class PaneKeyNav extends StatefulWidget {
|
||||
const PaneKeyNav({super.key, required this.child, required this.onNav, this.focusNode, this.autofocus = false, this.canRequestFocus = true});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Called when a `nav.*` motion resolves while this region has focus.
|
||||
final NavHandler onNav;
|
||||
|
||||
/// Focus node for the region. When null, [PaneKeyNav] owns one. Panes that
|
||||
/// want to move focus here programmatically (a row tap, F6) pass their own.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
final bool autofocus;
|
||||
|
||||
/// Whether the region can take focus at all. False makes it a pure pass-through
|
||||
/// (used when a pane temporarily routes keys elsewhere, e.g. a filter box).
|
||||
final bool canRequestFocus;
|
||||
|
||||
@override
|
||||
State<PaneKeyNav> createState() => _PaneKeyNavState();
|
||||
}
|
||||
|
||||
class _PaneKeyNavState extends State<PaneKeyNav> {
|
||||
FocusNode? _ownNode;
|
||||
SequenceMatcher? _matcher;
|
||||
|
||||
FocusNode get _node => widget.focusNode ?? (_ownNode ??= FocusNode(debugLabel: 'PaneKeyNav'));
|
||||
|
||||
/// The half-page scroll chords are the only modified chords this handler
|
||||
/// claims; every other modified chord bubbles to the global shortcut path.
|
||||
static final KeyChord _ctrlD = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyD);
|
||||
static final KeyChord _ctrlU = KeyChord(modifiers: const {KeyModifier.ctrl}, key: LogicalKeyboardKey.keyU);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_matcher != null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
_matcher = SequenceMatcher(keymap: () => kernel.keymap.keymap ?? Keymap(const []), context: () => kernel.keymap.scope);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ownNode?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent && event is! KeyRepeatEvent) return KeyEventResult.ignored;
|
||||
final kernel = ClideKernel.of(context);
|
||||
// Only vim normal mode drives pane navigation. Insert/visual or a non-vim
|
||||
// preset → transparent, keys pass through to whatever's below.
|
||||
if (kernel.keymap.scope['vim.normal'] != true) return KeyEventResult.ignored;
|
||||
|
||||
final hw = HardwareKeyboard.instance;
|
||||
final chord = KeyChord.fromKeyEvent(event, hw);
|
||||
if (chord == null) return KeyEventResult.ignored;
|
||||
|
||||
// Bare + shift-only chords drive the matcher; ctrl+d/ctrl+u are the only
|
||||
// modified chords we claim (half-page scroll). Any other modified chord is
|
||||
// an app shortcut (palette, quick-open) — let it bubble to the global path.
|
||||
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
|
||||
if (modified && chord != _ctrlD && chord != _ctrlU) return KeyEventResult.ignored;
|
||||
|
||||
final r = _matcher!.feed(chord);
|
||||
switch (r.outcome) {
|
||||
case SeqOutcome.fired:
|
||||
// The vim preset also binds these keys to editor.vim.* motions; in a
|
||||
// pane only nav.* applies. A non-nav fired intent (e.g. a stray
|
||||
// editor.vim.* with no focus guard) is swallowed, never executed here.
|
||||
if (r.intent is NavIntent) widget.onNav(r.intent! as NavIntent, r.count);
|
||||
return KeyEventResult.handled;
|
||||
case SeqOutcome.pending:
|
||||
return KeyEventResult.handled;
|
||||
case SeqOutcome.unmatched:
|
||||
// Vim normal mode beeps on unbound keys — swallow so a bare key never
|
||||
// leaks to text input or the global handler.
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(focusNode: _node, autofocus: widget.autofocus, canRequestFocus: widget.canRequestFocus, onKeyEvent: _onKey, child: widget.child);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
import 'package:clide/kernel/src/toast.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
enum NotificationLevel { info, warning, error, success }
|
||||
@@ -18,6 +20,14 @@ class ClideNotification {
|
||||
}
|
||||
|
||||
class Notifications extends ChangeNotifier {
|
||||
Notifications({MessageBus? messages}) : _messages = messages;
|
||||
|
||||
/// When wired (the facade passes the kernel bus), every notification is
|
||||
/// also published to the toast channel so it actually renders — the
|
||||
/// in-memory list had zero widget consumers and messages vanished
|
||||
/// silently (T-382).
|
||||
final MessageBus? _messages;
|
||||
|
||||
final List<ClideNotification> _active = [];
|
||||
final Map<String, Timer> _timers = {};
|
||||
int _seq = 0;
|
||||
@@ -41,6 +51,21 @@ class Notifications extends ChangeNotifier {
|
||||
final n = ClideNotification(id: id, level: level, message: message, title: title, duration: duration ?? const Duration(seconds: 4));
|
||||
_active.add(n);
|
||||
_timers[id] = Timer(n.duration, () => dismiss(id));
|
||||
final bus = _messages;
|
||||
if (bus != null) {
|
||||
publishToast(
|
||||
bus,
|
||||
'kernel.notify',
|
||||
title == null ? message : '$title — $message',
|
||||
severity: switch (level) {
|
||||
NotificationLevel.info => ToastSeverity.info,
|
||||
NotificationLevel.warning => ToastSeverity.warning,
|
||||
NotificationLevel.error => ToastSeverity.error,
|
||||
NotificationLevel.success => ToastSeverity.success,
|
||||
},
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ class PanelRegistry extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Whether any slot already mounts a contribution with [id]. Used by the
|
||||
/// extension manager to reject duplicate ids instead of silently mounting
|
||||
/// a second copy (T-377).
|
||||
bool hasContribution(String id) => _mounts.values.any((list) => list.any((c) => c.id == id));
|
||||
|
||||
void contribute(ContributionPoint point) {
|
||||
final slot = point.slot;
|
||||
if (slot == null) return;
|
||||
|
||||
@@ -6,10 +6,20 @@ import 'package:clide/kernel/src/events/types.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/kernel/src/settings.dart';
|
||||
import 'package:clide/kernel/src/toolchain.dart';
|
||||
import 'package:clide/kernel/src/workspace_ref.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class RecentProject {
|
||||
const RecentProject({required this.path, required this.name, this.branch, required this.lastOpened, this.startupSticky = false});
|
||||
const RecentProject({
|
||||
required this.path,
|
||||
required this.name,
|
||||
this.branch,
|
||||
required this.lastOpened,
|
||||
this.startupSticky = false,
|
||||
this.host,
|
||||
this.port,
|
||||
this.user,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String name;
|
||||
@@ -21,12 +31,27 @@ class RecentProject {
|
||||
/// opens it directly; otherwise the welcome screen takes over (T-115).
|
||||
final bool startupSticky;
|
||||
|
||||
/// Remote workspace identity (T-332/T-329): the SSH host (or
|
||||
/// `~/.ssh/config` alias) the repo lives on. Absent = local — older
|
||||
/// persisted recents deserialize as local automatically.
|
||||
final String? host;
|
||||
final int? port;
|
||||
final String? user;
|
||||
|
||||
bool get isRemote => host != null;
|
||||
|
||||
/// This recent's location as a [WorkspaceRef].
|
||||
WorkspaceRef get ref => host == null ? WorkspaceRef.local(path) : WorkspaceRef.remote(host: host!, path: path, port: port, user: user);
|
||||
|
||||
RecentProject copyWith({bool? startupSticky, DateTime? lastOpened, String? branch}) => RecentProject(
|
||||
path: path,
|
||||
name: name,
|
||||
branch: branch ?? this.branch,
|
||||
lastOpened: lastOpened ?? this.lastOpened,
|
||||
startupSticky: startupSticky ?? this.startupSticky,
|
||||
host: host,
|
||||
port: port,
|
||||
user: user,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -35,6 +60,9 @@ class RecentProject {
|
||||
'branch': branch,
|
||||
'lastOpened': lastOpened.toIso8601String(),
|
||||
if (startupSticky) 'startupSticky': true,
|
||||
if (host != null) 'host': host,
|
||||
if (port != null) 'port': port,
|
||||
if (user != null) 'user': user,
|
||||
};
|
||||
|
||||
factory RecentProject.fromJson(Map<String, dynamic> json) => RecentProject(
|
||||
@@ -43,9 +71,13 @@ class RecentProject {
|
||||
branch: json['branch'] as String?,
|
||||
lastOpened: DateTime.tryParse(json['lastOpened'] as String? ?? '') ?? DateTime.now(),
|
||||
startupSticky: json['startupSticky'] as bool? ?? false,
|
||||
host: json['host'] as String?,
|
||||
port: json['port'] as int?,
|
||||
user: json['user'] as String?,
|
||||
);
|
||||
|
||||
String get relativePath {
|
||||
if (isRemote) return '$host:$path';
|
||||
final home = Platform.environment['HOME'] ?? '';
|
||||
if (home.isNotEmpty && path.startsWith(home)) return '~${path.substring(home.length)}';
|
||||
return path;
|
||||
|
||||
@@ -6,11 +6,16 @@ import 'package:yaml/yaml.dart';
|
||||
enum SettingsScope { app, project, ext }
|
||||
|
||||
class SettingsStore extends ChangeNotifier {
|
||||
SettingsStore({required this.appDir, this.projectDir});
|
||||
SettingsStore({required this.appDir, this.projectDir, this.onError});
|
||||
|
||||
final Directory appDir;
|
||||
Directory? projectDir;
|
||||
|
||||
/// Surfaces load/parse problems (wired to the kernel Logger by the
|
||||
/// facade). A parse failure must not pass silently — it used to reset
|
||||
/// every setting on the next write (T-376).
|
||||
final void Function(String message)? onError;
|
||||
|
||||
final Map<String, Object?> _appValues = <String, Object?>{};
|
||||
final Map<String, Object?> _projectValues = <String, Object?>{};
|
||||
|
||||
@@ -93,17 +98,29 @@ class SettingsStore extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readFile(File f) async {
|
||||
String txt;
|
||||
try {
|
||||
if (!await f.exists()) return <String, Object?>{};
|
||||
final txt = await f.readAsString();
|
||||
if (txt.trim().isEmpty) return <String, Object?>{};
|
||||
txt = await f.readAsString();
|
||||
} catch (_) {
|
||||
// On web (or in sandboxes where the path isn't readable) silently
|
||||
// degrade to an empty in-memory catalog. `set` will no-op too.
|
||||
return <String, Object?>{};
|
||||
}
|
||||
if (txt.trim().isEmpty) return <String, Object?>{};
|
||||
try {
|
||||
final yaml = loadYaml(txt);
|
||||
final out = <String, Object?>{};
|
||||
if (yaml is Map) _flatten(yaml, '', out);
|
||||
return out;
|
||||
} catch (_) {
|
||||
// On web (or in sandboxes where the path isn't writable) silently
|
||||
// degrade to an empty in-memory catalog. `set` will no-op too.
|
||||
} catch (e) {
|
||||
// A parse failure must not silently reset the user's settings — the
|
||||
// next `set` overwrites the file with the (now empty) in-memory map.
|
||||
// Preserve the original for recovery and say so (T-376).
|
||||
try {
|
||||
await File('${f.path}.broken').writeAsString(txt);
|
||||
} catch (_) {}
|
||||
onError?.call('failed to parse ${f.path}: $e — original preserved at ${f.path}.broken');
|
||||
return <String, Object?>{};
|
||||
}
|
||||
}
|
||||
@@ -111,7 +128,11 @@ class SettingsStore extends ChangeNotifier {
|
||||
Future<void> _writeFile(File f, Map<String, Object?> flat) async {
|
||||
try {
|
||||
await f.parent.create(recursive: true);
|
||||
await f.writeAsString(_emitYaml(_unflatten(flat)));
|
||||
// Temp-file + rename: a crash mid-write must not truncate the live
|
||||
// settings file (T-376).
|
||||
final tmp = File('${f.path}.tmp');
|
||||
await tmp.writeAsString(_emitYaml(_unflatten(flat)));
|
||||
await tmp.rename(f.path);
|
||||
} catch (_) {
|
||||
// Web / read-only sandbox: in-memory update remains valid, we
|
||||
// just can't persist. Callers already called notifyListeners.
|
||||
@@ -214,6 +235,20 @@ void _emitScalar(StringBuffer buf, Object? v) {
|
||||
_emitScalar(buf, v[i]);
|
||||
}
|
||||
buf.write(']');
|
||||
} else if (v is Map) {
|
||||
// YAML flow mapping — maps nested inside lists (e.g. keymap overlay
|
||||
// entries) used to fall through to toString() and corrupt on the
|
||||
// next read (T-376).
|
||||
buf.write('{');
|
||||
var first = true;
|
||||
v.forEach((k, vv) {
|
||||
if (!first) buf.write(', ');
|
||||
first = false;
|
||||
_emitScalar(buf, '$k');
|
||||
buf.write(': ');
|
||||
_emitScalar(buf, vv);
|
||||
});
|
||||
buf.write('}');
|
||||
} else {
|
||||
buf.write('"${v.toString()}"');
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../src/pty/env.dart';
|
||||
|
||||
class ToolCheck extends ChangeNotifier {
|
||||
bool pqlOk = false;
|
||||
bool tmuxOk = false;
|
||||
bool gitOk = false;
|
||||
bool checked = false;
|
||||
|
||||
bool get allOk => pqlOk && tmuxOk && gitOk;
|
||||
|
||||
List<String> get errors => [if (!pqlOk) 'pql not found', if (!tmuxOk) 'tmux not found', if (!gitOk) 'git not found'];
|
||||
|
||||
/// Workspace root, set by the app at boot. Falls back to cwd.
|
||||
static String? workspaceRoot;
|
||||
|
||||
Future<void> check() async {
|
||||
pqlOk = _existsOnPath('pql');
|
||||
// tmux has no Windows build; absence there is the documented
|
||||
// no-tmux mode, not a failed check.
|
||||
tmuxOk = Platform.isWindows || _existsOnPath('tmux');
|
||||
gitOk = _existsOnPath('git');
|
||||
checked = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if [name] exists as an executable in any PATH directory.
|
||||
/// Uses direct file-existence checks — works inside a macOS sandbox
|
||||
/// without needing to exec `which`.
|
||||
static bool _existsOnPath(String name) {
|
||||
final sep = Platform.isWindows ? ';' : ':';
|
||||
for (final dir in expandedPath.split(sep)) {
|
||||
if (dir.isEmpty) continue;
|
||||
if (Platform.isWindows) {
|
||||
for (final ext in const ['.exe', '.bat', '.cmd', '.com', '']) {
|
||||
if (File('$dir\\$name$ext').existsSync()) return true;
|
||||
}
|
||||
} else {
|
||||
if (File('$dir/$name').existsSync()) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/// WorkspaceRef (T-332): where a workspace lives — a local repo root or
|
||||
/// a repo on a remote host reached over SSH (T-329).
|
||||
///
|
||||
/// The remote form is written `ssh://[user@]host[:port]/abs/remote/path`
|
||||
/// (host may be a `~/.ssh/config` alias — resolution happens at connect
|
||||
/// time, not here). A bare string with no scheme is a local path.
|
||||
library;
|
||||
|
||||
/// A reference to a workspace root. Immutable value type.
|
||||
class WorkspaceRef {
|
||||
const WorkspaceRef.local(this.path) : host = null, port = null, user = null;
|
||||
|
||||
const WorkspaceRef.remote({required String this.host, required this.path, this.port, this.user});
|
||||
|
||||
/// Remote host (or `~/.ssh/config` alias). Null means local.
|
||||
final String? host;
|
||||
|
||||
/// SSH port; null means the ssh default / config-resolved port.
|
||||
final int? port;
|
||||
|
||||
/// SSH user; null means the local username / config-resolved user.
|
||||
final String? user;
|
||||
|
||||
/// Absolute workspace path — on [host] when remote, locally otherwise.
|
||||
final String path;
|
||||
|
||||
bool get isRemote => host != null;
|
||||
|
||||
/// Parse either a plain local path or an `ssh://` URI. Returns null
|
||||
/// for a malformed `ssh://` form (no host, or no absolute path).
|
||||
static WorkspaceRef? parse(String input) {
|
||||
if (!input.startsWith('ssh://')) return WorkspaceRef.local(input);
|
||||
final Uri uri;
|
||||
try {
|
||||
uri = Uri.parse(input);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
if (uri.host.isEmpty || uri.path.isEmpty || uri.path == '/') return null;
|
||||
return WorkspaceRef.remote(host: uri.host, path: uri.path, port: uri.hasPort ? uri.port : null, user: uri.userInfo.isEmpty ? null : uri.userInfo);
|
||||
}
|
||||
|
||||
/// The canonical string form: the bare path locally, the full
|
||||
/// `ssh://` URI remotely. `parse(uri) == ref` round-trips.
|
||||
String get uri {
|
||||
if (!isRemote) return path;
|
||||
final auth = user == null ? host! : '$user@$host';
|
||||
final p = port == null ? '' : ':$port';
|
||||
return 'ssh://$auth$p$path';
|
||||
}
|
||||
|
||||
/// Compact human form for recents/switcher rows: `host:path` remotely
|
||||
/// (e.g. `buildbox:/srv/repo`), the bare path locally.
|
||||
String get display => isRemote ? '$host:$path' : path;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is WorkspaceRef && other.host == host && other.port == port && other.user == user && other.path == path;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(host, port, user, path);
|
||||
|
||||
@override
|
||||
String toString() => 'WorkspaceRef($uri)';
|
||||
}
|
||||
+48
-14
@@ -132,11 +132,17 @@ Future<void> main() async {
|
||||
McpServer? mcpServer;
|
||||
final ipcLog = Logger();
|
||||
|
||||
// IPC-server swaps must run one-at-a-time — see the swapIpcServer wrapper
|
||||
// below doSwapIpcServer for why. (T-352)
|
||||
// Backend swaps must run one-at-a-time — see the swapBackend wrapper
|
||||
// below doSwapBackend for why. (T-352)
|
||||
Future<void> swapChain = Future<void>.value();
|
||||
|
||||
Future<void> doSwapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) async {
|
||||
// Teardown of the service set behind the currently-served dispatcher
|
||||
// (pane PTYs, file watcher, in-flight searches, editor buffers). Swapped
|
||||
// alongside the IPC server so a project switch can't leak the previous
|
||||
// workspace's watchers into the new one's bus (T-367).
|
||||
Future<void> Function()? activeSubsystemTeardown;
|
||||
|
||||
Future<void> doSwapBackend(DaemonDispatcher dispatcher, Future<void> Function() teardown, Directory workRoot) async {
|
||||
if (kIsWeb) return;
|
||||
// Already serving this exact workspace? Reuse the live server.
|
||||
// The startup factory binds the launch CWD, then the project-open
|
||||
@@ -148,6 +154,9 @@ Future<void> main() async {
|
||||
final live = ipcServer;
|
||||
if (live != null && live.isRunning && live.workspaceRoot == workRoot.path) {
|
||||
ipcLog.info('ipc', 'already serving ${workRoot.path}; reusing the live server');
|
||||
// The freshly built dispatcher is dropped unused — its services are
|
||||
// inert (watchers/PTYs only start via dispatched commands), so there
|
||||
// is nothing to tear down. The live server keeps its own set.
|
||||
// Idempotent — a no-op when the client is already connected here.
|
||||
await ipcClient?.reconnectAt(live.socketPath);
|
||||
return;
|
||||
@@ -163,6 +172,16 @@ Future<void> main() async {
|
||||
} catch (e) {
|
||||
ipcLog.warn('mcp', 'stop failed during swap: $e');
|
||||
}
|
||||
// The old server is down — release the previous workspace's services
|
||||
// before the new set takes over (T-367). The shutdown() methods are
|
||||
// idempotent, so a failed swap retried later is safe.
|
||||
try {
|
||||
await activeSubsystemTeardown?.call();
|
||||
} catch (e, st) {
|
||||
ipcLog.warn('ipc', 'subsystem teardown failed during swap: $e');
|
||||
ipcLog.debug('ipc', '$st');
|
||||
}
|
||||
activeSubsystemTeardown = teardown;
|
||||
final server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot.path, log: ipcLog, events: daemonBus);
|
||||
ipcServer = server;
|
||||
try {
|
||||
@@ -197,14 +216,20 @@ Future<void> main() async {
|
||||
// load (stale/global pql.db) yet working after a manual refresh. Chaining
|
||||
// every swap makes them apply in call order; the repo swap is issued last
|
||||
// and therefore wins. (T-352)
|
||||
Future<void> swapIpcServer(DaemonDispatcher dispatcher, Directory workRoot) {
|
||||
final next = swapChain.then((_) => doSwapIpcServer(dispatcher, workRoot));
|
||||
Future<void> swapBackend(DaemonDispatcher dispatcher, Future<void> Function() teardown, Directory workRoot) {
|
||||
final next = swapChain.then((_) => doSwapBackend(dispatcher, teardown, workRoot));
|
||||
// A failed swap must not break the chain for the next one.
|
||||
swapChain = next.catchError((Object _) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
DaemonDispatcher buildDispatcher(DaemonBus events, Toolchain tc, Directory workRoot, LayoutArrangement arrangement, PanelRegistry panels) {
|
||||
(DaemonDispatcher, Future<void> Function()) buildDispatcher(
|
||||
DaemonBus events,
|
||||
Toolchain tc,
|
||||
Directory workRoot,
|
||||
LayoutArrangement arrangement,
|
||||
PanelRegistry panels,
|
||||
) {
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final eventSink = _BusEventSink(events);
|
||||
final paneRegistry = PaneRegistry(events: eventSink);
|
||||
@@ -297,7 +322,16 @@ Future<void> main() async {
|
||||
};
|
||||
});
|
||||
registerArgvUnwrap(dispatcher);
|
||||
return dispatcher;
|
||||
// Paired teardown for this workspace's stateful services — the swap
|
||||
// calls it when this dispatcher stops being served (T-367).
|
||||
Future<void> teardown() async {
|
||||
await paneRegistry.shutdown();
|
||||
await filesService.shutdown();
|
||||
await searchService.shutdown();
|
||||
await editorRegistry.shutdown();
|
||||
}
|
||||
|
||||
return (dispatcher, teardown);
|
||||
}
|
||||
|
||||
final services = await KernelServices.boot(
|
||||
@@ -314,22 +348,22 @@ Future<void> main() async {
|
||||
kernelArrangement = arrangement;
|
||||
kernelPanels = panels;
|
||||
final workRoot = startupWorkRoot;
|
||||
final dispatcher = buildDispatcher(events, toolchain, workRoot, arrangement, panels);
|
||||
final (dispatcher, teardown) = buildDispatcher(events, toolchain, workRoot, arrangement, panels);
|
||||
// Build the client at the workspace's socket path. The
|
||||
// server is started below (swapIpcServer) which the
|
||||
// server is started below (swapBackend) which the
|
||||
// client will then auto-connect to via its reconnect
|
||||
// loop. autoStartDaemonClient:false means we own the
|
||||
// lifecycle here.
|
||||
final client = DaemonClient(socketPath: workspaceSocketPath(workRoot.path), log: log, events: events);
|
||||
final client = DaemonClient.unixSocket(socketPath: workspaceSocketPath(workRoot.path), log: log, events: events);
|
||||
ipcClient = client;
|
||||
// start() synchronously marks the client "connecting" (so
|
||||
// requests issued during the startup window park for the
|
||||
// socket instead of failing) and arms the reconnect loop.
|
||||
// swapIpcServer then binds the server and reconnectAt makes
|
||||
// swapBackend then binds the server and reconnectAt makes
|
||||
// the connect immediate. _connect's already-connected guard
|
||||
// keeps these two paths from opening a second socket.
|
||||
unawaited(client.start());
|
||||
unawaited(swapIpcServer(dispatcher, workRoot));
|
||||
unawaited(swapBackend(dispatcher, teardown, workRoot));
|
||||
return client;
|
||||
},
|
||||
onProjectOpen: kIsWeb
|
||||
@@ -339,8 +373,8 @@ Future<void> main() async {
|
||||
final arrangement = kernelArrangement;
|
||||
final panels = kernelPanels;
|
||||
if (bus == null || arrangement == null || panels == null) return;
|
||||
final dispatcher = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels);
|
||||
await swapIpcServer(dispatcher, Directory(path));
|
||||
final (dispatcher, teardown) = buildDispatcher(bus, toolchain, Directory(path), arrangement, panels);
|
||||
await swapBackend(dispatcher, teardown, Directory(path));
|
||||
},
|
||||
);
|
||||
// Expose the reader nav to the `clide status` snapshot (T-221). Boot
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'dart:io' show FileSystemException;
|
||||
|
||||
import '../editor/buffer.dart' show Selection;
|
||||
import '../editor/registry.dart';
|
||||
import '../files/path_safety.dart' show PathOutsideRoot;
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/errno_mapping.dart';
|
||||
@@ -84,6 +85,13 @@ Future<IpcResponse> _open(IpcRequest req, EditorRegistry r) async {
|
||||
r.setSelection(buf.id, Selection.collapsed(_offsetForLine(buf.content, line)));
|
||||
}
|
||||
return IpcResponse.ok(id: req.id, data: buf.toJson());
|
||||
} on PathOutsideRoot {
|
||||
// Same containment contract as files.read (T-363); a buffer is a
|
||||
// write surface, so no D-80 extra-root widening here.
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace: $path'),
|
||||
);
|
||||
} on FileSystemException catch (e) {
|
||||
final errno = e.osError?.errorCode;
|
||||
if (errno != null) {
|
||||
@@ -190,7 +198,17 @@ Future<IpcResponse> _setContent(IpcRequest req, EditorRegistry r) async {
|
||||
Future<IpcResponse> _save(IpcRequest req, EditorRegistry r) async {
|
||||
final id = _resolveId(req, r);
|
||||
if (id == null) return _notFound(req.id, 'no active buffer');
|
||||
final ok = await r.save(id);
|
||||
final bool ok;
|
||||
try {
|
||||
ok = await r.save(id);
|
||||
} on PathOutsideRoot {
|
||||
// Defense in depth — open already validates, but a symlink can be
|
||||
// swapped in under the buffer's path between open and save (T-363).
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'path outside workspace'),
|
||||
);
|
||||
}
|
||||
if (!ok) return _notFound(req.id, 'no such buffer: $id');
|
||||
return IpcResponse.ok(id: req.id, data: {'id': id, 'saved': true});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,15 @@ class SearchService {
|
||||
_active.remove(id)?.cancel();
|
||||
}
|
||||
|
||||
/// Cancel every in-flight search. Called when the workspace service
|
||||
/// set is torn down on project switch (T-367).
|
||||
Future<void> shutdown() async {
|
||||
for (final c in _active.values) {
|
||||
c.cancel();
|
||||
}
|
||||
_active.clear();
|
||||
}
|
||||
|
||||
/// Compute (preview) or perform (apply) a search-and-replace.
|
||||
///
|
||||
/// Preview returns per-file before/after edits without touching disk.
|
||||
|
||||
@@ -9,6 +9,7 @@ library;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../files/path_safety.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../panes/event_sink.dart';
|
||||
import 'buffer.dart';
|
||||
@@ -212,10 +213,13 @@ class EditorRegistry {
|
||||
events.emit(IpcEvent(subsystem: 'editor', kind: kind, timestamp: DateTime.now().toUtc(), data: data));
|
||||
}
|
||||
|
||||
/// Resolve a buffer path to disk under the workspace root, with the
|
||||
/// same traversal/symlink containment as files.* (T-363). A buffer is
|
||||
/// a WRITE surface (save), so the D-80 extra read roots do not apply —
|
||||
/// strictly workspace-confined. Throws [PathOutsideRoot] on escape.
|
||||
String _absolutePathOf(String repoRelative) {
|
||||
if (repoRelative.startsWith('/')) return repoRelative;
|
||||
final sep = Platform.pathSeparator;
|
||||
return '${workspaceRoot.absolute.path}$sep${repoRelative.replaceAll('/', sep)}';
|
||||
return resolveUnderRootFollowingSymlinks(workspaceRoot, repoRelative.replaceAll('/', sep));
|
||||
}
|
||||
|
||||
// Support JSON decode of Selection from IPC args.
|
||||
|
||||
@@ -43,6 +43,11 @@ Future<List<FileEntry>> listDir({required Directory root, required String dir, r
|
||||
await for (final e in resolved.list(followLinks: false)) {
|
||||
final name = e.uri.pathSegments.isNotEmpty ? e.uri.pathSegments.where((s) => s.isNotEmpty).last : '';
|
||||
final rel = dir.isEmpty ? name : '$dir/$name';
|
||||
// With followLinks: false the lister yields Link entities for symlinks —
|
||||
// that's the symlink signal. stat() follows the link (target type/size,
|
||||
// notFound for broken links), so its type can never be `link` and must
|
||||
// not be used for detection (T-365).
|
||||
final isLink = e is Link;
|
||||
final stat = await e.stat();
|
||||
final isDir = stat.type == FileSystemEntityType.directory;
|
||||
if (ignore.isIgnored(rel, isDirectory: isDir)) continue;
|
||||
@@ -51,7 +56,7 @@ Future<List<FileEntry>> listDir({required Directory root, required String dir, r
|
||||
name: name,
|
||||
path: rel,
|
||||
isDirectory: isDir,
|
||||
isSymlink: stat.type == FileSystemEntityType.link,
|
||||
isSymlink: isLink,
|
||||
sizeBytes: isDir ? null : stat.size,
|
||||
modifiedMs: stat.modified.millisecondsSinceEpoch,
|
||||
),
|
||||
@@ -79,9 +84,10 @@ class WalkResult {
|
||||
|
||||
/// Recursively walk [root], returning every non-ignored *file*
|
||||
/// (directories are descended into but not emitted), pruned by
|
||||
/// [ignore]. Reuses [listDir] per directory, so ignore filtering,
|
||||
/// symlink-escape safety (`followLinks: false`), and per-directory
|
||||
/// sorting are inherited.
|
||||
/// [ignore]. Reuses [listDir] per directory, so ignore filtering and
|
||||
/// per-directory sorting are inherited. Symlinks are never descended —
|
||||
/// a symlinked directory would be an escape hatch out of the workspace
|
||||
/// and a cycle risk (T-365); symlinks to files are emitted as entries.
|
||||
///
|
||||
/// Capped at [maxFiles] to bound work on pathological trees; when the
|
||||
/// cap is hit the walk stops early and [WalkResult.truncated] is set so
|
||||
@@ -97,7 +103,7 @@ Future<WalkResult> walkFiles({required Directory root, required IgnoreSet ignore
|
||||
final entries = await listDir(root: root, dir: dir, ignore: ignore);
|
||||
for (final e in entries) {
|
||||
if (e.isDirectory) {
|
||||
stack.add(e.path);
|
||||
if (!e.isSymlink) stack.add(e.path);
|
||||
} else {
|
||||
out.add(e);
|
||||
if (out.length >= maxFiles) {
|
||||
|
||||
+6
-201
@@ -1,8 +1,9 @@
|
||||
/// Git operations — staging, committing, stashing, log, pull, push.
|
||||
///
|
||||
/// Each function shells out to `git` and returns either a typed result
|
||||
/// or throws [GitException] on failure. All operations are workspace-
|
||||
/// rooted (take a [Directory] argument).
|
||||
/// Shared git plumbing: the resolved `git` binary path, the typed
|
||||
/// failure ([GitException]), the ref-shaped-argument validator, and the
|
||||
/// log entry model. The legacy free-function operation API that used to
|
||||
/// live here duplicated [GitClient] verb-for-verb, had no non-test
|
||||
/// callers, and carried a latent pipe deadlock in its hunk-apply path —
|
||||
/// removed in the T-385 dead-code sweep; use [GitClient].
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
@@ -71,199 +72,3 @@ class GitLogEntry {
|
||||
if (body.isNotEmpty) 'body': body,
|
||||
};
|
||||
}
|
||||
|
||||
/// Stage files. Empty [paths] means stage all (`git add -A`).
|
||||
Future<void> gitStage(Directory workDir, List<String> paths) async {
|
||||
final args = ['add'];
|
||||
if (paths.isEmpty) {
|
||||
args.add('-A');
|
||||
} else {
|
||||
args.add('--');
|
||||
args.addAll(paths);
|
||||
}
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git add failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unstage files. Empty [paths] means unstage all.
|
||||
Future<void> gitUnstage(Directory workDir, List<String> paths) async {
|
||||
final args = ['reset', 'HEAD'];
|
||||
if (paths.isNotEmpty) {
|
||||
args.add('--');
|
||||
args.addAll(paths);
|
||||
}
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git reset failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage a single hunk via `git apply --cached`.
|
||||
Future<void> gitStageHunk(Directory workDir, String patch) async {
|
||||
await _applyPatch(workDir, patch, cached: true);
|
||||
}
|
||||
|
||||
/// Unstage a single hunk via `git apply --cached --reverse`.
|
||||
Future<void> gitUnstageHunk(Directory workDir, String patch) async {
|
||||
await _applyPatch(workDir, patch, cached: true, reverse: true);
|
||||
}
|
||||
|
||||
/// Discard unstaged changes for [paths]. Uses `git checkout -- <paths>`.
|
||||
Future<void> gitDiscard(Directory workDir, List<String> paths) async {
|
||||
if (paths.isEmpty) return;
|
||||
final r = await Process.run(gitBin, ['checkout', '--', ...paths], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git checkout failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit staged changes.
|
||||
Future<String> gitCommit(Directory workDir, String message, {bool amend = false}) async {
|
||||
final args = ['commit', '-m', message];
|
||||
if (amend) args.add('--amend');
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git commit failed', stderr: r.stderr as String);
|
||||
}
|
||||
// Return the new commit hash.
|
||||
final hashResult = await Process.run(gitBin, ['rev-parse', 'HEAD'], workingDirectory: workDir.path);
|
||||
return (hashResult.stdout as String).trim();
|
||||
}
|
||||
|
||||
/// Stash working changes.
|
||||
Future<void> gitStash(Directory workDir, {String? message, bool includeUntracked = false}) async {
|
||||
final args = ['stash', 'push'];
|
||||
if (message != null) {
|
||||
args.addAll(['-m', message]);
|
||||
}
|
||||
if (includeUntracked) args.add('--include-untracked');
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git stash failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the top stash entry.
|
||||
Future<void> gitStashPop(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['stash', 'pop'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git stash pop failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Git log. Returns the most recent [count] entries.
|
||||
Future<List<GitLogEntry>> gitLog(Directory workDir, {int count = 20}) async {
|
||||
final r = await Process.run(gitBin, ['log', '--format=%H%x00%h%x00%s%x00%an%x00%aI%x00%b%x01', '-n', '$count'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return const [];
|
||||
return _parseLog(r.stdout as String);
|
||||
}
|
||||
|
||||
/// Pull from remote.
|
||||
Future<String> gitPull(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['pull'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git pull failed', stderr: r.stderr as String);
|
||||
}
|
||||
return (r.stdout as String).trim();
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
Future<String> gitPush(Directory workDir, {String? remote, String? branch, bool setUpstream = false}) async {
|
||||
if (remote != null) validateGitRef(remote, kind: 'remote');
|
||||
if (branch != null) validateGitRef(branch, kind: 'branch');
|
||||
final args = ['push'];
|
||||
if (setUpstream) args.add('-u');
|
||||
// `--` terminates option parsing — belt-and-suspenders alongside
|
||||
// the ref validator above. Without it a future caller that bypasses
|
||||
// the validator could still inject `--upload-pack=...`.
|
||||
args.add('--');
|
||||
if (remote != null) args.add(remote);
|
||||
if (branch != null) args.add(branch);
|
||||
final r = await Process.run(gitBin, args, workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git push failed', stderr: r.stderr as String);
|
||||
}
|
||||
return ((r.stdout as String) + (r.stderr as String)).trim();
|
||||
}
|
||||
|
||||
/// List local branches. Returns (name, isCurrent) pairs.
|
||||
Future<List<({String name, bool current})>> gitBranches(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['branch', '--format=%(refname:short)|%(HEAD)'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return const [];
|
||||
final out = <({String name, bool current})>[];
|
||||
for (final line in (r.stdout as String).split('\n')) {
|
||||
if (line.trim().isEmpty) continue;
|
||||
final sep = line.lastIndexOf('|');
|
||||
if (sep < 0) continue;
|
||||
final name = line.substring(0, sep);
|
||||
final head = line.substring(sep + 1).trim();
|
||||
out.add((name: name, current: head == '*'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Checkout a branch.
|
||||
///
|
||||
/// `git checkout` overloads positionals: `-- <name>` means "restore
|
||||
/// pathspec `<name>`", not "checkout branch `<name>`". So this can't
|
||||
/// use `--` as an option terminator without changing semantics — the
|
||||
/// [validateGitRef] guard against `-`-prefixed values is the only
|
||||
/// argv-injection defence here. Use `gitSwitch` if/when we adopt it.
|
||||
Future<void> gitCheckout(Directory workDir, String branch) async {
|
||||
validateGitRef(branch, kind: 'branch');
|
||||
final r = await Process.run(gitBin, ['checkout', branch], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) {
|
||||
throw GitException('git checkout failed', stderr: r.stderr as String);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current branch name.
|
||||
Future<String?> gitCurrentBranch(Directory workDir) async {
|
||||
final r = await Process.run(gitBin, ['symbolic-ref', '--short', 'HEAD'], workingDirectory: workDir.path);
|
||||
if (r.exitCode != 0) return null;
|
||||
return (r.stdout as String).trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
List<GitLogEntry> _parseLog(String output) {
|
||||
if (output.trim().isEmpty) return const [];
|
||||
final records = output.split('\x01');
|
||||
final entries = <GitLogEntry>[];
|
||||
for (final record in records) {
|
||||
final trimmed = record.trim();
|
||||
if (trimmed.isEmpty) continue;
|
||||
final fields = trimmed.split('\x00');
|
||||
if (fields.length < 5) continue;
|
||||
entries.add(
|
||||
GitLogEntry(
|
||||
hash: fields[0],
|
||||
shortHash: fields[1],
|
||||
subject: fields[2],
|
||||
author: fields[3],
|
||||
date: fields[4],
|
||||
body: fields.length > 5 ? fields[5].trim() : '',
|
||||
),
|
||||
);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<void> _applyPatch(Directory workDir, String patch, {bool cached = false, bool reverse = false}) async {
|
||||
final args = ['apply'];
|
||||
if (cached) args.add('--cached');
|
||||
if (reverse) args.add('--reverse');
|
||||
args.add('--unidiff-zero');
|
||||
args.add('-');
|
||||
|
||||
final proc = await Process.start('git', args, workingDirectory: workDir.path);
|
||||
proc.stdin.write(patch);
|
||||
await proc.stdin.close();
|
||||
final exitCode = await proc.exitCode;
|
||||
if (exitCode != 0) {
|
||||
final stderr = await proc.stderr.transform(const SystemEncoding().decoder).join();
|
||||
throw GitException('git apply failed', stderr: stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ library;
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:clide/src/daemon/dispatcher.dart';
|
||||
@@ -33,6 +34,12 @@ import 'package:clide/src/ipc/envelope.dart';
|
||||
/// separate `/ide` minimum (D-68).
|
||||
const String _clideToolPrefix = 'mcp__clide__';
|
||||
|
||||
/// Auth header Claude Code's `/ide` client sends, populated from the lock
|
||||
/// file's `authToken`. Every request must carry it (T-362): the unix socket
|
||||
/// is gated by 0600 per D-71, and an unauthenticated localhost HTTP port
|
||||
/// would bypass that gate wholesale.
|
||||
const String kMcpAuthHeader = 'x-claude-code-ide-authorization';
|
||||
|
||||
/// One connected SSE client. Each session has its own response
|
||||
/// stream; POST /messages routes back to the right one via the
|
||||
/// `sessionId` query param.
|
||||
@@ -89,6 +96,7 @@ class McpServer {
|
||||
HttpServer? _http;
|
||||
String? _lockFile;
|
||||
int? _port;
|
||||
String? _authToken;
|
||||
final Map<String, _McpSession> _sessions = {};
|
||||
int _sessionCounter = 0;
|
||||
|
||||
@@ -96,11 +104,16 @@ class McpServer {
|
||||
int? get port => _port;
|
||||
String? get lockFilePath => _lockFile;
|
||||
|
||||
/// The per-start bearer token clients must present in [kMcpAuthHeader].
|
||||
/// Published to legitimate clients via the 0600 lock file only.
|
||||
String? get authToken => _authToken;
|
||||
|
||||
Future<void> start() async {
|
||||
if (isRunning) return;
|
||||
final server = await HttpServer.bind(bindHost, bindPort);
|
||||
_http = server;
|
||||
_port = server.port;
|
||||
_authToken = _generateToken();
|
||||
_lockFile = await _writeDiscoveryFile();
|
||||
server.listen(
|
||||
_route,
|
||||
@@ -136,6 +149,13 @@ class McpServer {
|
||||
// -- routing --------------------------------------------------------------
|
||||
|
||||
Future<void> _route(HttpRequest req) async {
|
||||
// Token gate first, on every path (T-362). Without it, any local
|
||||
// process could drive the entire dispatcher D-71's 0600 socket guards.
|
||||
if (req.headers.value(kMcpAuthHeader) != _authToken) {
|
||||
req.response.statusCode = HttpStatus.unauthorized;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
final path = req.uri.path;
|
||||
if (path == '/sse' && req.method == 'GET') {
|
||||
await _openSseStream(req);
|
||||
@@ -327,8 +347,39 @@ class McpServer {
|
||||
dirHandle.createSync(recursive: true);
|
||||
}
|
||||
final path = '$dir/$pid.lock';
|
||||
final body = jsonEncode({'pid': pid, 'workspace': workspaceRoot, 'transport': 'sse', 'url': 'http://$bindHost:$_port/sse'});
|
||||
final body = jsonEncode({
|
||||
'pid': pid,
|
||||
'workspace': workspaceRoot,
|
||||
'transport': 'sse',
|
||||
'url': 'http://$bindHost:$_port/sse',
|
||||
// Claude Code's /ide lock format carries the bearer token here; the
|
||||
// 0600 below is what scopes it to this user (T-362).
|
||||
'authToken': _authToken,
|
||||
});
|
||||
File(path).writeAsStringSync(body);
|
||||
try {
|
||||
await _chmod(path, '600');
|
||||
} catch (e) {
|
||||
// Not fatal like the socket's chmod (D-71): the lock lives under
|
||||
// ~/.claude which the home-dir perms usually already protect. But say so.
|
||||
log.warn('mcp', 'chmod 600 on $path failed: $e — the auth token may be readable by other local users');
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/// 32 bytes of CSPRNG entropy, base64url — the per-start bearer token.
|
||||
static String _generateToken() {
|
||||
final rng = Random.secure();
|
||||
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
|
||||
return base64UrlEncode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
/// `chmod` via `chmod(1)` — dart:io doesn't expose mode bits (same
|
||||
/// approach as the unix-socket server, D-71).
|
||||
static Future<void> _chmod(String path, String octal) async {
|
||||
final r = await Process.run('chmod', [octal, path]);
|
||||
if (r.exitCode != 0) {
|
||||
throw ProcessException('chmod', [octal, path], r.stderr.toString(), r.exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-27
@@ -150,33 +150,26 @@ class IpcServer {
|
||||
|
||||
void _onClient(Socket client) {
|
||||
_clients.add(client);
|
||||
final buffer = StringBuffer();
|
||||
late StreamSubscription<List<int>> sub;
|
||||
sub = client.listen(
|
||||
(chunk) async {
|
||||
buffer.write(utf8.decode(chunk, allowMalformed: true));
|
||||
var idx = buffer.toString().indexOf('\n');
|
||||
while (idx >= 0) {
|
||||
final raw = buffer.toString().substring(0, idx);
|
||||
// Trim consumed bytes by rebuilding the buffer with the
|
||||
// tail — StringBuffer can't slice in place.
|
||||
final tail = buffer.toString().substring(idx + 1);
|
||||
buffer.clear();
|
||||
buffer.write(tail);
|
||||
await _handleLine(client, raw);
|
||||
idx = buffer.toString().indexOf('\n');
|
||||
}
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
},
|
||||
onDone: () {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
sub.cancel();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
unawaited(_serveClient(client));
|
||||
}
|
||||
|
||||
/// One read loop per connection: persistent UTF-8 decode, line framing,
|
||||
/// and true serial dispatch in a single `await for` (D-72, T-372). The
|
||||
/// old async onData handler never paused its subscription — pipelined
|
||||
/// requests interleaved mid-handler, the shared StringBuffer could
|
||||
/// re-frame while an await was in flight, and per-chunk decode corrupted
|
||||
/// runes split across reads.
|
||||
Future<void> _serveClient(Socket client) async {
|
||||
try {
|
||||
await for (final line in client.cast<List<int>>().transform(const Utf8Decoder(allowMalformed: true)).transform(const LineSplitter())) {
|
||||
await _handleLine(client, line);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('ipc', 'client read error: $e');
|
||||
} finally {
|
||||
_clients.remove(client);
|
||||
_subscribers.remove(client);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleLine(Socket client, String line) async {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/// DaemonTransport (T-331): the seam between the local app and its
|
||||
/// backend. The UI's [DaemonClient] talks JSON-lines through a
|
||||
/// [DaemonTransport] instead of a hard-coded unix-socket connect, so a
|
||||
/// remote transport (SSH-tunnelled agent socket or ssh-exec channel,
|
||||
/// T-329/Q-23) can slot in without touching the client's correlation,
|
||||
/// reconnect, or event-forwarding logic.
|
||||
///
|
||||
/// The wire protocol is unchanged either way: one JSON envelope
|
||||
/// (IpcRequest/IpcResponse/IpcEvent, see envelope.dart) per line.
|
||||
///
|
||||
/// Kept Flutter-free — this file runs under plain `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
/// How the app reaches its backend. Implementations own endpoint
|
||||
/// resolution + connection establishment; the caller owns retry policy
|
||||
/// (the client's backoff loop calls [open] again after a failure).
|
||||
abstract interface class DaemonTransport {
|
||||
/// Stable, human-readable endpoint description — the unix socket path
|
||||
/// locally, a `ssh://host/path` form remotely. Used for logs, status
|
||||
/// surfaces, and same-endpoint reconnect short-circuits.
|
||||
String get endpoint;
|
||||
|
||||
/// Establish one connection. Throws on failure (caller retries).
|
||||
Future<DaemonConnection> open();
|
||||
}
|
||||
|
||||
/// One live backend connection carrying JSON-lines both ways.
|
||||
abstract interface class DaemonConnection {
|
||||
/// Incoming lines, one JSON envelope each. Done/error signals the
|
||||
/// connection dropped.
|
||||
Stream<String> get lines;
|
||||
|
||||
/// Send one JSON envelope line (the newline is appended here).
|
||||
void writeLine(String line);
|
||||
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
/// Today's path: connect to the workspace-derived unix domain socket
|
||||
/// (D-70) the in-process IpcServer is bound to.
|
||||
class LocalSocketTransport implements DaemonTransport {
|
||||
LocalSocketTransport(this.socketPath);
|
||||
|
||||
final String socketPath;
|
||||
|
||||
@override
|
||||
String get endpoint => socketPath;
|
||||
|
||||
@override
|
||||
Future<DaemonConnection> open() async {
|
||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
||||
return _SocketConnection(await Socket.connect(addr, 0));
|
||||
}
|
||||
}
|
||||
|
||||
class _SocketConnection implements DaemonConnection {
|
||||
_SocketConnection(this._socket);
|
||||
|
||||
final Socket _socket;
|
||||
|
||||
@override
|
||||
Stream<String> get lines => _socket.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter());
|
||||
|
||||
@override
|
||||
void writeLine(String line) => _socket.writeln(line);
|
||||
|
||||
@override
|
||||
Future<void> close() => _socket.close();
|
||||
}
|
||||
+20
-181
@@ -1,63 +1,46 @@
|
||||
/// Raw FFI bindings to the libc functions the PTY wrapper needs.
|
||||
/// Raw FFI bindings to the libc symbols the PTY layer still needs.
|
||||
///
|
||||
/// `dart:io` doesn't expose `forkpty`, `read`/`write` on raw fds,
|
||||
/// `ioctl`, or `poll` — FFI is the minimum tool for the job.
|
||||
/// `dart:io` doesn't expose `socketpair`, `close` on raw fds, `errno`,
|
||||
/// or the `poll()` event bits — FFI is the minimum tool for the job.
|
||||
/// The fd-passing-era surface that used to live here (recvmsg + the
|
||||
/// msghdr/cmsghdr/iovec structs, read/write, ioctl/winsize, fcntl
|
||||
/// non-blocking helpers) had no callers since the daemon dissolution
|
||||
/// (D-56) and was removed in the T-385 dead-code sweep; `NativePty`
|
||||
/// binds its own symbols.
|
||||
///
|
||||
/// Linux + macOS only for now. Windows is covered by platform checks
|
||||
/// higher up; when Windows support lands it'll need a parallel binding
|
||||
/// set against the Win32 API (named pipes instead of unix sockets).
|
||||
library;
|
||||
|
||||
// File-wide analyzer exceptions, with reason — see CLAUDE.md
|
||||
// no-lint-suppression rule. These are the textbook FFI-binding
|
||||
// case where the lints work against the file's purpose:
|
||||
// File-wide analyzer exception, with reason — see CLAUDE.md
|
||||
// no-lint-suppression rule. This is the textbook FFI-binding case
|
||||
// where the lint works against the file's purpose:
|
||||
//
|
||||
// * `non_constant_identifier_names` — struct field names map 1:1
|
||||
// to POSIX (`man 2 socketpair`, `recvmsg`, `iovec`, `msghdr`).
|
||||
// Keeping snake_case makes the code greppable against the spec
|
||||
// and the field offsets readable next to the C ABI. Dart FFI
|
||||
// layout depends on declaration order + types, not names, so
|
||||
// this is purely a readability call.
|
||||
// * `library_private_types_in_public_api` — the C / Dart function-
|
||||
// signature typedefs (`_SocketpairC`, `_SocketpairDart`, etc.)
|
||||
// are implementation details consumed only by the public
|
||||
// `lookupFunction<...>()` calls in this file. Promoting them
|
||||
// to public would just add noise to the import surface.
|
||||
// signature typedefs (`_SocketpairC`, `_SocketpairD`, etc.) are
|
||||
// implementation details consumed only by the public
|
||||
// `lookupFunction<...>()` calls in this file. Promoting them to
|
||||
// public would just add noise to the import surface.
|
||||
//
|
||||
// ignore_for_file: non_constant_identifier_names, library_private_types_in_public_api
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:ffi/ffi.dart' as pkg_ffi;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants (POSIX — platform-dispatched where Linux/macOS diverge)
|
||||
// Constants (POSIX — identical numeric values on Linux + macOS for the
|
||||
// entries we touch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const int afUnix = 1;
|
||||
const int sockStream = 1;
|
||||
|
||||
final int solSocket = Platform.isMacOS ? 0xffff : 1;
|
||||
final int scmRights = Platform.isMacOS ? 0x01 : 1;
|
||||
|
||||
final int oNonblock = Platform.isMacOS ? 0x0004 : 0x0800;
|
||||
const int fGetFl = 3;
|
||||
const int fSetFl = 4;
|
||||
|
||||
final int tiocswinsz = Platform.isMacOS ? 0x80087467 : 0x5414;
|
||||
|
||||
// poll() event bits (POSIX — same numeric values on Linux + macOS).
|
||||
// poll() event bits.
|
||||
const int pollin = 0x0001;
|
||||
const int pollerr = 0x0008;
|
||||
const int pollhup = 0x0010;
|
||||
const int pollnval = 0x0020;
|
||||
const int pollAnyErr = pollerr | pollhup | pollnval;
|
||||
|
||||
// Signal numbers used from the PTY layer (POSIX standard; identical
|
||||
// across Linux + macOS for the entries we touch).
|
||||
// Signal numbers used from the PTY layer.
|
||||
const int sighup = 1;
|
||||
const int sigkill = 9;
|
||||
const int sigwinch = 28;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,108 +50,12 @@ const int sigwinch = 28;
|
||||
typedef _SocketpairC = ffi.Int32 Function(ffi.Int32 domain, ffi.Int32 type, ffi.Int32 protocol, ffi.Pointer<ffi.Int32> sv);
|
||||
typedef _SocketpairD = int Function(int domain, int type, int protocol, ffi.Pointer<ffi.Int32> sv);
|
||||
|
||||
typedef _RecvmsgC = ffi.IntPtr Function(ffi.Int32 sockfd, ffi.Pointer<Msghdr> msg, ffi.Int32 flags);
|
||||
typedef _RecvmsgD = int Function(int sockfd, ffi.Pointer<Msghdr> msg, int flags);
|
||||
|
||||
typedef _RecvmsgDarwinC = ffi.IntPtr Function(ffi.Int32 sockfd, ffi.Pointer<MsghdrDarwin> msg, ffi.Int32 flags);
|
||||
typedef _RecvmsgDarwinD = int Function(int sockfd, ffi.Pointer<MsghdrDarwin> msg, int flags);
|
||||
|
||||
typedef _ReadC = ffi.IntPtr Function(ffi.Int32 fd, ffi.Pointer<ffi.Uint8> buf, ffi.IntPtr count);
|
||||
typedef _ReadD = int Function(int fd, ffi.Pointer<ffi.Uint8> buf, int count);
|
||||
|
||||
typedef _WriteC = ffi.IntPtr Function(ffi.Int32 fd, ffi.Pointer<ffi.Uint8> buf, ffi.IntPtr count);
|
||||
typedef _WriteD = int Function(int fd, ffi.Pointer<ffi.Uint8> buf, int count);
|
||||
|
||||
typedef _CloseC = ffi.Int32 Function(ffi.Int32 fd);
|
||||
typedef _CloseD = int Function(int fd);
|
||||
|
||||
typedef _IoctlPtrC = ffi.Int32 Function(ffi.Int32 fd, ffi.UnsignedLong request, ffi.Pointer<Winsize> argp);
|
||||
typedef _IoctlPtrD = int Function(int fd, int request, ffi.Pointer<Winsize> argp);
|
||||
|
||||
typedef _FcntlIntC = ffi.Int32 Function(ffi.Int32 fd, ffi.Int32 cmd, ffi.Int32 arg);
|
||||
typedef _FcntlIntD = int Function(int fd, int cmd, int arg);
|
||||
|
||||
typedef _ErrnoLocationC = ffi.Pointer<ffi.Int32> Function();
|
||||
typedef _ErrnoLocationD = ffi.Pointer<ffi.Int32> Function();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// POSIX `struct iovec`.
|
||||
final class Iovec extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Uint8> iov_base;
|
||||
@ffi.IntPtr()
|
||||
external int iov_len;
|
||||
}
|
||||
|
||||
/// Linux `struct msghdr`. msg_iovlen/msg_controllen are size_t (8 bytes
|
||||
/// on 64-bit). macOS uses int/socklen_t (4 bytes) — see MsghdrDarwin.
|
||||
final class Msghdr extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> msg_name;
|
||||
@ffi.Uint32()
|
||||
external int msg_namelen;
|
||||
external ffi.Pointer<Iovec> msg_iov;
|
||||
@ffi.IntPtr()
|
||||
external int msg_iovlen;
|
||||
external ffi.Pointer<ffi.Void> msg_control;
|
||||
@ffi.IntPtr()
|
||||
external int msg_controllen;
|
||||
@ffi.Int32()
|
||||
external int msg_flags;
|
||||
}
|
||||
|
||||
/// macOS `struct msghdr`. msg_iovlen is int (4 bytes), msg_controllen
|
||||
/// is socklen_t (4 bytes) — smaller than Linux's size_t fields.
|
||||
final class MsghdrDarwin extends ffi.Struct {
|
||||
external ffi.Pointer<ffi.Void> msg_name;
|
||||
@ffi.Uint32()
|
||||
external int msg_namelen;
|
||||
external ffi.Pointer<Iovec> msg_iov;
|
||||
@ffi.Int32()
|
||||
external int msg_iovlen;
|
||||
external ffi.Pointer<ffi.Void> msg_control;
|
||||
@ffi.Uint32()
|
||||
external int msg_controllen;
|
||||
@ffi.Int32()
|
||||
external int msg_flags;
|
||||
}
|
||||
|
||||
/// POSIX `struct cmsghdr` prefix. We treat the rest of the control
|
||||
/// buffer as a raw byte region and compute offsets by hand.
|
||||
// On Linux, cmsg_len is size_t (8 bytes on 64-bit).
|
||||
// On macOS, cmsg_len is socklen_t (4 bytes, always).
|
||||
// Use platform-specific structs.
|
||||
final class CmsghdrLinux extends ffi.Struct {
|
||||
@ffi.IntPtr()
|
||||
external int cmsg_len;
|
||||
@ffi.Int32()
|
||||
external int cmsg_level;
|
||||
@ffi.Int32()
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
final class CmsghdrDarwin extends ffi.Struct {
|
||||
@ffi.Uint32()
|
||||
external int cmsg_len;
|
||||
@ffi.Int32()
|
||||
external int cmsg_level;
|
||||
@ffi.Int32()
|
||||
external int cmsg_type;
|
||||
}
|
||||
|
||||
/// POSIX `struct winsize` for `TIOCSWINSZ`.
|
||||
final class Winsize extends ffi.Struct {
|
||||
@ffi.Uint16()
|
||||
external int ws_row;
|
||||
@ffi.Uint16()
|
||||
external int ws_col;
|
||||
@ffi.Uint16()
|
||||
external int ws_xpixel;
|
||||
@ffi.Uint16()
|
||||
external int ws_ypixel;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library handle + lazy-resolved function pointers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -184,20 +71,8 @@ ffi.DynamicLibrary _openLibc() {
|
||||
|
||||
final _SocketpairD socketpair = _libc.lookupFunction<_SocketpairC, _SocketpairD>('socketpair');
|
||||
|
||||
final _RecvmsgD recvmsgLinux = _libc.lookupFunction<_RecvmsgC, _RecvmsgD>('recvmsg');
|
||||
|
||||
final _RecvmsgDarwinD recvmsgDarwin = _libc.lookupFunction<_RecvmsgDarwinC, _RecvmsgDarwinD>('recvmsg');
|
||||
|
||||
final _ReadD read = _libc.lookupFunction<_ReadC, _ReadD>('read');
|
||||
|
||||
final _WriteD write = _libc.lookupFunction<_WriteC, _WriteD>('write');
|
||||
|
||||
final _CloseD close = _libc.lookupFunction<_CloseC, _CloseD>('close');
|
||||
|
||||
final _IoctlPtrD ioctlWinsize = _libc.lookupFunction<_IoctlPtrC, _IoctlPtrD>('ioctl');
|
||||
|
||||
final _FcntlIntD fcntlInt = _libc.lookupFunction<_FcntlIntC, _FcntlIntD>('fcntl');
|
||||
|
||||
/// Resolve `errno` through the platform-appropriate thread-local
|
||||
/// accessor. glibc exposes `__errno_location`, musl the same, macOS
|
||||
/// uses `__error`.
|
||||
@@ -211,39 +86,3 @@ int get errno {
|
||||
final fn = _libc.lookupFunction<_ErrnoLocationC, _ErrnoLocationD>('__error');
|
||||
return fn().value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience — scoped allocations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Allocate a typed native block, run [action], free. Frees even if
|
||||
/// [action] throws.
|
||||
T withBuffer<T>(int bytes, T Function(ffi.Pointer<ffi.Uint8>) action) {
|
||||
final p = pkg_ffi.calloc<ffi.Uint8>(bytes);
|
||||
try {
|
||||
return action(p);
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set [fd] non-blocking. Returns whether the flag was changed.
|
||||
bool setNonBlocking(int fd) {
|
||||
final flags = fcntlInt(fd, fGetFl, 0);
|
||||
if (flags < 0) return false;
|
||||
if ((flags & oNonblock) != 0) return false;
|
||||
fcntlInt(fd, fSetFl, flags | oNonblock);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Apply `TIOCSWINSZ` to the master PTY fd.
|
||||
int setWinsize(int fd, int cols, int rows) {
|
||||
final ws = pkg_ffi.calloc<Winsize>();
|
||||
try {
|
||||
ws.ref.ws_col = cols;
|
||||
ws.ref.ws_row = rows;
|
||||
return ioctlWinsize(fd, tiocswinsz, ws);
|
||||
} finally {
|
||||
pkg_ffi.calloc.free(ws);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,6 +451,11 @@ class NativePty implements PtySession {
|
||||
void _reap() {
|
||||
if (_dead) return;
|
||||
_dead = true;
|
||||
// The reader isolate sends EOF only after exiting its poll loop, so
|
||||
// nothing touches the master fd anymore. Release it here — close()
|
||||
// short-circuits on _dead, so skipping this leaks the fd and its pty
|
||||
// device for the life of the app on every natural child exit (T-360).
|
||||
_nativeClose(_fd);
|
||||
final s = calloc<ffi.Int32>();
|
||||
_waitpid(pid, s, _kWnohang);
|
||||
calloc.free(s);
|
||||
|
||||
@@ -56,11 +56,11 @@ Stream<List<SearchMatch>> grepWorkspace({
|
||||
final walk = await walkFiles(root: root, ignore: ignore);
|
||||
if (cancel?.isCancelled ?? false) return;
|
||||
|
||||
final includes = [for (final g in query.include) _globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) _globToRegExp(g)];
|
||||
final includes = [for (final g in query.include) globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) globToRegExp(g)];
|
||||
final candidates = <String>[];
|
||||
for (final e in walk.files) {
|
||||
if (_acceptGlobs(e.path, includes, excludes)) candidates.add(e.path);
|
||||
if (acceptGlobs(e.path, includes, excludes)) candidates.add(e.path);
|
||||
}
|
||||
if (candidates.isEmpty) return;
|
||||
|
||||
@@ -193,7 +193,10 @@ class CompiledQuery {
|
||||
|
||||
// -- Glob filtering ----------------------------------------------------------
|
||||
|
||||
bool _acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
/// Whether [path] passes the compiled include/exclude filters. Shared with
|
||||
/// the replace engine so search and replace can never disagree on scope
|
||||
/// (T-364).
|
||||
bool acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
if (includes.isNotEmpty && !includes.any((r) => r.hasMatch(path))) return false;
|
||||
if (excludes.any((r) => r.hasMatch(path))) return false;
|
||||
return true;
|
||||
@@ -202,7 +205,7 @@ bool _acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
|
||||
/// Compile a gitignore-flavoured glob to a full-path regex. A `/` in
|
||||
/// the glob anchors it to the workspace root; otherwise it may match at
|
||||
/// any depth (basename-style). Supports `*`, `**`, `?`.
|
||||
RegExp _globToRegExp(String glob) {
|
||||
RegExp globToRegExp(String glob) {
|
||||
final anchored = glob.contains('/');
|
||||
final b = StringBuffer('^');
|
||||
if (!anchored) b.write(r'(?:.*/)?');
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'dart:io';
|
||||
|
||||
import '../files/ignore.dart';
|
||||
import '../files/listing.dart';
|
||||
import 'grep_engine.dart' show acceptGlobs, globToRegExp;
|
||||
import 'match.dart';
|
||||
|
||||
/// One changed line within a file.
|
||||
@@ -133,9 +134,14 @@ Future<List<FileReplacement>> computeReplacements({
|
||||
|
||||
final walk = await walkFiles(root: root, ignore: ignore);
|
||||
final rootPath = root.absolute.path;
|
||||
// Same compiled glob filters as the grep engine — replace must never
|
||||
// touch a file the equivalent search wouldn't have matched (T-364).
|
||||
final includes = [for (final g in query.include) globToRegExp(g)];
|
||||
final excludes = [for (final g in query.exclude) globToRegExp(g)];
|
||||
final out = <FileReplacement>[];
|
||||
for (final entry in walk.files) {
|
||||
if (out.length >= maxFiles) break;
|
||||
if (!acceptGlobs(entry.path, includes, excludes)) continue;
|
||||
final fr = _replaceInFile(rootPath, entry.path, query, replacement);
|
||||
if (fr != null) out.add(fr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/// The top window-chrome bar (D-57): drag region, menu bar, project
|
||||
/// switcher, window controls. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/project_switcher.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class HatBar extends StatelessWidget {
|
||||
const HatBar({super.key, required this.kernel, required this.menuBar});
|
||||
final KernelServices kernel;
|
||||
final MenuBarController menuBar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => kernel.window.startDrag(),
|
||||
child: Container(
|
||||
height: hatHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.chromeBackground,
|
||||
border: Border(bottom: BorderSide(color: tokens.chromeBorder, width: 1)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
_LeftHatContent(tokens: tokens, wc: kernel.window),
|
||||
MenuBar(controller: menuBar),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: ProjectSwitcherButton(kernel: kernel, tokens: tokens),
|
||||
),
|
||||
),
|
||||
_RightHatContent(tokens: tokens, wc: kernel.window),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LeftHatContent extends StatelessWidget {
|
||||
const _LeftHatContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
// On macOS the native titlebar draws traffic lights; skip duplicates.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _RightHatContent extends StatelessWidget {
|
||||
const _RightHatContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.shrink();
|
||||
if (!kIsWeb && Platform.isMacOS) return const SizedBox.shrink();
|
||||
return Row(
|
||||
children: [
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinBtn(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinBtn(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WinBtn extends StatelessWidget {
|
||||
const _WinBtn({required this.icon, required this.onTap, required this.tokens, this.isClose = false});
|
||||
final ClideIconPainter icon;
|
||||
final VoidCallback onTap;
|
||||
final SurfaceTokens tokens;
|
||||
final bool isClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 36,
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.chromeForeground),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/// The root three-column layout grid, the status bar, and its
|
||||
/// collapse toggles + bottom icon rails. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/slot_host.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class RootLayout extends StatelessWidget {
|
||||
const RootLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.arrangement]),
|
||||
builder: (ctx, _) {
|
||||
final a = kernel.arrangement;
|
||||
final sidebarVisible = a.isVisible(Slots.sidebar);
|
||||
final sidebarCollapsed = a.isCollapsed(Slots.sidebar);
|
||||
final contextVisible = a.isVisible(Slots.contextPanel);
|
||||
final contextCollapsed = a.isCollapsed(Slots.contextPanel);
|
||||
final statusVisible = a.isVisible(Slots.statusbar);
|
||||
final sidebarSize = a.sizeOf(Slots.sidebar) ?? 400;
|
||||
final contextSize = a.sizeOf(Slots.contextPanel) ?? 420;
|
||||
final statusHeight = a.sizeOf(Slots.statusbar) ?? 26;
|
||||
// Bottom output dock (T-54 / D-87): pushes the workspace up when open,
|
||||
// capped at half the window so Claude stays the largest surface (the
|
||||
// D-47 amendment).
|
||||
final dockVisible = a.isVisible(Slots.dock);
|
||||
final dockMax = (((MediaQuery.of(ctx).size.height) - statusHeight) * 0.5).clamp(80.0, double.infinity).toDouble();
|
||||
final dockHeight = dockVisible ? ((a.sizeOf(Slots.dock) ?? 200).clamp(0.0, dockMax)).toDouble() : 0.0;
|
||||
|
||||
final column = Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
if (sidebarVisible && sidebarCollapsed)
|
||||
ClideSpine(label: _sidebarSpineLabel(kernel), side: SpineSide.left, onExpand: () => a.setCollapsed(Slots.sidebar, false))
|
||||
else if (sidebarVisible) ...[
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: SlotHost(slot: Slots.sidebar),
|
||||
),
|
||||
DragResizeHandle(arrangement: a, slot: Slots.sidebar, axis: Axis.horizontal),
|
||||
],
|
||||
const Expanded(child: SlotHost(slot: Slots.workspace)),
|
||||
if (contextVisible && contextCollapsed)
|
||||
ClideSpine(label: 'context', side: SpineSide.right, onExpand: () => a.setCollapsed(Slots.contextPanel, false))
|
||||
else if (contextVisible) ...[
|
||||
DragResizeHandle(arrangement: a, slot: Slots.contextPanel, axis: Axis.horizontal),
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: SlotHost(slot: Slots.contextPanel),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (dockVisible)
|
||||
SizedBox(
|
||||
height: dockHeight,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: SlotHost(slot: Slots.dock),
|
||||
),
|
||||
),
|
||||
if (statusVisible)
|
||||
Container(
|
||||
height: statusHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: ClideTheme.of(ctx).surface.chromeBorder)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Collapse toggles are pinned to the screen edges (outermost
|
||||
// children) so they never shift when a pane collapses (T-294).
|
||||
StatusbarCollapseToggle(slot: Slots.sidebar, collapsed: sidebarCollapsed, visible: sidebarVisible),
|
||||
if (sidebarVisible && !sidebarCollapsed)
|
||||
SizedBox(
|
||||
width: sidebarSize,
|
||||
child: _BottomRail(slot: Slots.sidebar),
|
||||
)
|
||||
else if (sidebarVisible && sidebarCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
const Expanded(child: StatusbarHost()),
|
||||
if (contextVisible && !contextCollapsed)
|
||||
SizedBox(
|
||||
width: contextSize,
|
||||
child: _BottomRail(slot: Slots.contextPanel),
|
||||
)
|
||||
else if (contextVisible && contextCollapsed)
|
||||
const SizedBox(width: ClideSpine.width),
|
||||
StatusbarCollapseToggle(slot: Slots.contextPanel, collapsed: contextCollapsed, visible: contextVisible),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
// When the status bar is hidden it no longer occupies the window's
|
||||
// bottom edge, so the bottom-most content (the Claude composer, an
|
||||
// editor, a terminal) would otherwise run flush into the resize-drag
|
||||
// strip and look jammed against the window bottom (T-298). Reserve a
|
||||
// matching inset so the interaction zone bottom-anchors consistently,
|
||||
// independent of status-bar visibility.
|
||||
if (statusVisible) return column;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: ClideResizeBorder.edgeThickness),
|
||||
child: column,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static String _sidebarSpineLabel(KernelServices kernel) {
|
||||
final activeTab = kernel.panels.activeTabIn(Slots.sidebar);
|
||||
if (activeTab == null) return 'overview';
|
||||
final tabs = kernel.panels.tabsFor(Slots.sidebar);
|
||||
for (final t in tabs) {
|
||||
if (t.id == activeTab) return t.title.toLowerCase();
|
||||
}
|
||||
return 'overview';
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomRail extends StatelessWidget {
|
||||
const _BottomRail({required this.slot});
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(slot);
|
||||
if (tabs.isEmpty) return Container(color: tokens.chromeBackground);
|
||||
final activeId = kernel.panels.activeTabIn(slot) ?? tabs.first.id;
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
child: ClideIconRail(
|
||||
items: [for (final t in tabs) ClideIconRailItem(id: t.id, icon: _iconFor(slot, t), tooltip: resolveTabTitle(ctx, t), iconColor: t.iconColor)],
|
||||
activeId: activeId,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static ClideIconPainter _iconFor(SlotId slot, TabContribution t) {
|
||||
if (t.icon is ClideIconPainter) return t.icon as ClideIconPainter;
|
||||
if (slot == Slots.sidebar) {
|
||||
return switch (t.id) {
|
||||
'files.tree' => PhosphorIcons.byName('folder'),
|
||||
'git.panel' => PhosphorIcons.byName('git-branch'),
|
||||
'pql.panel' => PhosphorIcons.byName('magnifying-glass'),
|
||||
'problems.panel' => PhosphorIcons.byName('warning-circle'),
|
||||
'decisions.panel' => PhosphorIcons.byName('lightbulb'),
|
||||
'tickets.panel' => PhosphorIcons.byName('ticket'),
|
||||
_ => PhosphorIcons.byName('circles-four'),
|
||||
};
|
||||
}
|
||||
return switch (t.id) {
|
||||
'markdown.viewer' => PhosphorIcons.byName('eye'),
|
||||
'graph.view' => PhosphorIcons.byName('graph'),
|
||||
'pql.backlinks' => PhosphorIcons.byName('link'),
|
||||
_ => PhosphorIcons.byName('circles-four'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed-position collapse/expand toggle bookending the status bar (T-294).
|
||||
/// The left cell controls the sidebar, the right cell the context pane; both
|
||||
/// fire the existing `sidebar.collapse` / `context.collapse` commands and flip a
|
||||
/// caret-line chevron per `arrangement.isCollapsed` (outward = expand, inward =
|
||||
/// collapse). The collapse behaviour itself lives in the commands (D-51/D-54);
|
||||
/// this is the mouse affordance for the keyboard/CLI-addressable action (D-6).
|
||||
/// A fixed collapse/expand toggle pinned to a screen edge of the status bar
|
||||
/// (T-294). Lives at the outer ends of the bar — NOT inside the centre
|
||||
/// [StatusbarHost] — so it never shifts when a pane collapses and the centre
|
||||
/// bar resizes. [collapsed]/[visible] are passed in (not read from the
|
||||
/// arrangement here) so the widget varies with state and rebuilds when its
|
||||
/// parent's `ListenableBuilder` fires — a const widget reading the arrangement
|
||||
/// itself is skipped as identical on rebuild, freezing the chevron.
|
||||
class StatusbarCollapseToggle extends StatelessWidget {
|
||||
const StatusbarCollapseToggle({super.key, required this.slot, required this.collapsed, required this.visible});
|
||||
|
||||
final SlotId slot;
|
||||
final bool collapsed;
|
||||
final bool visible;
|
||||
|
||||
bool get _isSidebar => slot == Slots.sidebar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (!visible) return const SizedBox(width: 24);
|
||||
// The chevron points the DIRECTION OF THE ACTION: collapsing tucks the pane
|
||||
// toward its own edge, expanding brings it back toward the centre.
|
||||
final icon = _isSidebar
|
||||
? (collapsed ? PhosphorIcons.byName('caret-line-right') : PhosphorIcons.byName('caret-line-left'))
|
||||
: (collapsed ? PhosphorIcons.byName('caret-line-left') : PhosphorIcons.byName('caret-line-right'));
|
||||
final what = _isSidebar ? 'sidebar' : 'context panel';
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
child: ClideTappable(
|
||||
onTap: () => kernel.commands.execute(_isSidebar ? 'sidebar.collapse' : 'context.collapse'),
|
||||
tooltip: collapsed ? 'Show $what' : 'Hide $what',
|
||||
builder: (ctx, hovered, focused) => Container(
|
||||
alignment: Alignment.center,
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : null,
|
||||
child: ClideIcon(icon, size: 13, color: tokens.statusBarForeground),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StatusbarHost extends StatelessWidget {
|
||||
const StatusbarHost({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.panels,
|
||||
builder: (ctx, _) {
|
||||
final items = kernel.panels.contributionsFor(Slots.statusbar).whereType<StatusItemContribution>().toList();
|
||||
final left = items.where((i) => i.priority < 100).toList();
|
||||
final right = items.where((i) => i.priority >= 100).toList();
|
||||
// Two explicit columns within the center (workspace) bar: the LEFT
|
||||
// group lives in an Expanded so it absorbs all free space and is
|
||||
// start-aligned, and the RIGHT group (tool status, theme switcher)
|
||||
// trails it at intrinsic width — so it hugs the workspace block's
|
||||
// right edge by construction, no Spacer to fight a flex item (T-239).
|
||||
// Left items with flex > 0 wrap in Flexible(loose) so they yield width
|
||||
// when tight (T-160).
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
for (final item in left)
|
||||
if (item.flex > 0) Flexible(flex: item.flex, fit: FlexFit.loose, child: item.build(ctx)) else item.build(ctx),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final item in right) item.build(ctx),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/// The hat bar's project switcher: current-project label opening a
|
||||
/// recents + file-actions dropdown. Split out of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart' show clideName;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ProjectSwitcherButton extends StatelessWidget {
|
||||
const ProjectSwitcherButton({super.key, required this.kernel, required this.tokens});
|
||||
final KernelServices kernel;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
void _openSwitcher() {
|
||||
kernel.dialog.show<String>((ctx, dismiss) {
|
||||
return _ProjectSwitcherDropdown(kernel: kernel, onDismiss: dismiss);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
final name = kernel.project.current?.path.split('/').last;
|
||||
final label = name != null ? '$clideName > $name' : clideName;
|
||||
return ClideTappable(
|
||||
onTap: _openSwitcher,
|
||||
builder: (context, hovered, _) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ClideText(label, fontSize: 12, color: hovered ? tokens.globalForeground : tokens.chromeForeground, fontFamily: clideMonoFamily),
|
||||
const SizedBox(width: 4),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 8, color: tokens.chromeForeground),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProjectSwitcherDropdown extends StatefulWidget {
|
||||
const _ProjectSwitcherDropdown({required this.kernel, required this.onDismiss});
|
||||
final KernelServices kernel;
|
||||
final void Function([String?]) onDismiss;
|
||||
|
||||
@override
|
||||
State<_ProjectSwitcherDropdown> createState() => _ProjectSwitcherDropdownState();
|
||||
}
|
||||
|
||||
class _ProjectSwitcherDropdownState extends State<_ProjectSwitcherDropdown> {
|
||||
String _filter = '';
|
||||
late final FocusNode _focus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus = FocusNode()..requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openProject(String path) async {
|
||||
final ok = await widget.kernel.project.open(path);
|
||||
if (ok) {
|
||||
widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary');
|
||||
widget.onDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// File actions now live as commands (file.openFolder / file.newWindow /
|
||||
// file.closeWorkspace) owned by the menu-bar extension (T-48). The switcher
|
||||
// dismisses itself and dispatches the command so both surfaces share one
|
||||
// implementation.
|
||||
void _runFileCommand(String command) {
|
||||
widget.onDismiss();
|
||||
unawaited(widget.kernel.commands.execute(command));
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
widget.onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final recents = widget.kernel.project.recents;
|
||||
final lf = _filter.toLowerCase();
|
||||
final filtered = lf.isEmpty ? recents : recents.where((r) => r.name.toLowerCase().contains(lf) || r.path.toLowerCase().contains(lf)).toList();
|
||||
|
||||
return Focus(
|
||||
focusNode: _focus,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: 480,
|
||||
constraints: const BoxConstraints(maxHeight: 420),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.dropdownBackground,
|
||||
border: Border.all(color: tokens.dropdownBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ClideFilterBox(hint: 'Search projects…', onChanged: (v) => setState(() => _filter = v)),
|
||||
if (filtered.isNotEmpty) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: ClideText('Recent Projects', fontSize: clideFontCaption, color: tokens.globalTextMuted),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (ctx, i) => _RecentProjectRow(project: filtered[i], tokens: tokens, onTap: () => _openProject(filtered[i].path)),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Padding(padding: EdgeInsets.all(12), child: ClideText('No recent projects.', muted: true)),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: tokens.dividerColor)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_ActionRow(
|
||||
label: 'Open Local Project',
|
||||
shortcut: Platform.isMacOS ? '⌘O' : 'Ctrl+O',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.openFolder'),
|
||||
),
|
||||
_ActionRow(
|
||||
label: 'New Window',
|
||||
shortcut: Platform.isMacOS ? '⌘⇧N' : 'Ctrl+Shift+N',
|
||||
tokens: tokens,
|
||||
onTap: () => _runFileCommand('file.newWindow'),
|
||||
),
|
||||
if (widget.kernel.project.isOpen)
|
||||
_ActionRow(label: 'Close Project', shortcut: '', tokens: tokens, onTap: () => _runFileCommand('file.closeWorkspace')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentProjectRow extends StatelessWidget {
|
||||
const _RecentProjectRow({required this.project, required this.tokens, required this.onTap});
|
||||
final RecentProject project;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideIcon(PhosphorIcons.byName('folder'), size: 14, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(project.name, fontSize: 14),
|
||||
if (project.branch != null)
|
||||
Row(
|
||||
children: [
|
||||
// Elide a long path instead of overflowing the row
|
||||
// (matches the welcome recents row; T-160 discipline).
|
||||
Flexible(
|
||||
child: ClideText(
|
||||
project.relativePath,
|
||||
muted: true,
|
||||
fontSize: 12,
|
||||
fontFamily: clideMonoFamily,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
ClideText(' · ', muted: true, fontSize: 12),
|
||||
ClideIcon(PhosphorIcons.byName('git-branch'), size: 10, color: tokens.globalTextMuted),
|
||||
const SizedBox(width: 3),
|
||||
ClideText(project.branch!, muted: true, fontSize: 12, fontFamily: clideMonoFamily),
|
||||
],
|
||||
)
|
||||
else
|
||||
ClideText(project.relativePath, muted: true, fontSize: 12, fontFamily: clideMonoFamily, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
ClideText(project.timeAgo, muted: true, fontSize: 11),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatelessWidget {
|
||||
const _ActionRow({required this.label, this.shortcut, required this.tokens, required this.onTap});
|
||||
final String label;
|
||||
final String? shortcut;
|
||||
final SurfaceTokens tokens;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(label, fontSize: 14)),
|
||||
if (shortcut != null && shortcut!.isNotEmpty) ClideText(shortcut!, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/// The application root shell: global keyboard/intent routing (keymap
|
||||
/// resolution, double-tap modifiers, menu mnemonics), the hat bar, and
|
||||
/// the overlay stack (palette, quick-open, welcome, toasts). Split out
|
||||
/// of app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/menubar/menubar.dart';
|
||||
import 'package:clide/builtin/welcome/src/welcome_view.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/src/shell/hat_bar.dart';
|
||||
import 'package:clide/src/shell/layout.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class RootShell extends StatefulWidget {
|
||||
const RootShell({super.key, required this.services});
|
||||
final KernelServices services;
|
||||
|
||||
@override
|
||||
State<RootShell> createState() => RootShellState();
|
||||
}
|
||||
|
||||
class RootShellState extends State<RootShell> {
|
||||
late final FocusNode _keyFocus;
|
||||
final MenuBarController _menuBar = MenuBarController();
|
||||
// Detects double-tapped bare modifiers (e.g. double-Shift → quick-open,
|
||||
// JetBrains "Search Everywhere"). Fed from a HardwareKeyboard handler, not
|
||||
// the focus tree: a focused editor consumes the chorded key of `Shift+;`,
|
||||
// so the gesture must observe every event to know a press wasn't bare
|
||||
// (T-341, T-409).
|
||||
final ModifierTapTracker _modTap = ModifierTapTracker();
|
||||
|
||||
// Global multi-chord matcher for window/tab commands (ctrl+w h, gt …) (T-404).
|
||||
// The passive KeyboardListener can't run sequences or consume the second
|
||||
// chord (a focused editor/pane swallows it), so this lives at the
|
||||
// HardwareKeyboard level where returning true consumes the event before focus
|
||||
// dispatch. It only engages for chords that START a multi-chord binding in the
|
||||
// active keymap, so single-chord presets (default/vscode/jetbrains) are
|
||||
// untouched.
|
||||
late final SequenceMatcher _globalSeq;
|
||||
Timer? _seqTimeout;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keyFocus = FocusNode()..requestFocus();
|
||||
widget.services.textZoom.addListener(_onZoom);
|
||||
_globalSeq = SequenceMatcher(
|
||||
keymap: () => widget.services.keymap.keymap ?? Keymap(const []),
|
||||
context: () => widget.services.keymap.scope,
|
||||
captureCounts: false,
|
||||
);
|
||||
HardwareKeyboard.instance.addHandler(_onRawKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
HardwareKeyboard.instance.removeHandler(_onRawKey);
|
||||
_seqTimeout?.cancel();
|
||||
widget.services.textZoom.removeListener(_onZoom);
|
||||
_menuBar.dispose();
|
||||
_keyFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onZoom() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: tokens.globalForeground,
|
||||
fontSize: 15,
|
||||
height: clideLineHeight,
|
||||
fontWeight: clideUiDefaultWeight,
|
||||
fontFamily: clideUiFamily,
|
||||
fontFamilyFallback: clideUiFamilyFallback,
|
||||
),
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(widget.services.textZoom.scale)),
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
TextScaleIncreaseIntent: CallbackAction<TextScaleIncreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.increase();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleDecreaseIntent: CallbackAction<TextScaleDecreaseIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.decrease();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
TextScaleResetIntent: CallbackAction<TextScaleResetIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.textZoom.reset();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
InvokeCommandIntent: CallbackAction<InvokeCommandIntent>(
|
||||
onInvoke: (intent) {
|
||||
widget.services.commands.execute(intent.commandId);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
PaletteOpenIntent: CallbackAction<PaletteOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.palette.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
QuickOpenIntent: CallbackAction<QuickOpenIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.quickOpen.open();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FindInFilesIntent: CallbackAction<FindInFilesIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.arrangement.setVisible(Slots.sidebar, true);
|
||||
widget.services.arrangement.setCollapsed(Slots.sidebar, false);
|
||||
widget.services.panels.activateTab(Slots.sidebar, 'search.findInFiles');
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusNextPanelIntent: CallbackAction<FocusNextPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusNextSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
FocusPreviousPanelIntent: CallbackAction<FocusPreviousPanelIntent>(
|
||||
onInvoke: (_) {
|
||||
widget.services.focus.focusPreviousSlot();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: KeyboardListener(
|
||||
focusNode: _keyFocus,
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: ColoredBox(
|
||||
color: tokens.globalBackground,
|
||||
child: ClideResizeBorder(
|
||||
windowControls: widget.services.window,
|
||||
child: Column(
|
||||
children: [
|
||||
HatBar(kernel: widget.services, menuBar: _menuBar),
|
||||
Expanded(
|
||||
child: DialogHost(
|
||||
router: widget.services.dialog,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: RootLayout()),
|
||||
const ClidePalette(),
|
||||
const QuickOpenOverlay(),
|
||||
const Positioned.fill(child: _WelcomeOverlay()),
|
||||
const ToastOverlay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onKey(KeyEvent event) {
|
||||
if (_handleMenuMnemonic(event)) return;
|
||||
final intent = widget.services.keymap.resolveEvent(event, HardwareKeyboard.instance);
|
||||
if (intent == null) return;
|
||||
_dispatchIntent(intent);
|
||||
}
|
||||
|
||||
/// Double-tapped bare modifier (e.g. double-Shift → quick-open). Observed
|
||||
/// at the HardwareKeyboard level — before focus dispatch and regardless of
|
||||
/// who consumes the event — so a chorded key the focused editor swallows
|
||||
/// (the `;` of `Shift+;`) still dirties the press (T-341, T-409). Fires on
|
||||
/// the second clean *release*; never consumes anything.
|
||||
bool _onRawKey(KeyEvent event) {
|
||||
// Global window/tab sequences (ctrl+w h, gt …) get first claim — handled
|
||||
// here so a focused editor/pane can't swallow the second chord (T-404).
|
||||
if (_handleGlobalSequence(event)) return true;
|
||||
if (event is KeyDownEvent) {
|
||||
var mod = KeyChord.modifierForLogicalKey(event.logicalKey);
|
||||
// A modifier pressed while a non-modifier is already held (rolled
|
||||
// `a`+Shift) is a chord, not a tap.
|
||||
if (mod != null && _nonModifierHeld()) mod = null;
|
||||
_modTap.down(mod);
|
||||
} else if (event is KeyUpEvent) {
|
||||
final mod = _modTap.up(KeyChord.modifierForLogicalKey(event.logicalKey), DateTime.now());
|
||||
if (mod != null) {
|
||||
final seq = [KeyChord.bareModifier(mod), KeyChord.bareModifier(mod)];
|
||||
final tapIntent = widget.services.keymap.resolveSequence(seq);
|
||||
if (tapIntent != null) _dispatchIntent(tapIntent);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _nonModifierHeld() => HardwareKeyboard.instance.logicalKeysPressed.any((k) => KeyChord.modifierForLogicalKey(k) == null);
|
||||
|
||||
/// Feed one key into the global multi-chord matcher (T-404). Returns true to
|
||||
/// CONSUME the event (suppressing focus dispatch) while a sequence is being
|
||||
/// built or completes; false leaves the normal single-chord [_onKey] path
|
||||
/// untouched. Only KeyDown events drive it — a held key must not re-fire a
|
||||
/// window command.
|
||||
bool _handleGlobalSequence(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
final chord = KeyChord.fromKeyEvent(event, HardwareKeyboard.instance);
|
||||
if (chord == null) return false;
|
||||
final km = widget.services.keymap.keymap;
|
||||
if (km == null) return false;
|
||||
final scope = widget.services.keymap.scope;
|
||||
// Not mid-sequence: only START on a MODIFIED chord that's a sequence prefix
|
||||
// (ctrl+w …). Bare-key sequences (gg, dd) are editor/pane-local — the
|
||||
// focused widget owns them, so a global grab would steal the first chord
|
||||
// before the editor ever saw it. Once pending, the bare second chord (the
|
||||
// `h` of `ctrl+w h`) is consumed normally. Single-chord presets are
|
||||
// untouched (no prefix → no engage).
|
||||
if (!_globalSeq.hasPending) {
|
||||
final modified = chord.modifiers.any((m) => m != KeyModifier.shift);
|
||||
if (!modified || !km.match([chord], scope).isPrefix) return false;
|
||||
}
|
||||
final r = _globalSeq.feed(chord);
|
||||
switch (r.outcome) {
|
||||
case SeqOutcome.pending:
|
||||
_armSeqTimeout();
|
||||
return true;
|
||||
case SeqOutcome.fired:
|
||||
_cancelSeqTimeout();
|
||||
_dispatchIntent(r.intent!);
|
||||
return true;
|
||||
case SeqOutcome.unmatched:
|
||||
// The sequence broke — drop the buffer and let this lone key through to
|
||||
// normal handling (the abandoned prefix, e.g. a bare ctrl+w, simply
|
||||
// does nothing rather than firing late).
|
||||
_cancelSeqTimeout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// After a pending prefix, fire its buffered exact match (bare ctrl+w →
|
||||
/// editor.close) if no completing chord arrives in time — the d-vs-dd timeout
|
||||
/// (D-82), applied globally.
|
||||
void _armSeqTimeout() {
|
||||
_seqTimeout?.cancel();
|
||||
_seqTimeout = Timer(const Duration(milliseconds: 400), () {
|
||||
final r = _globalSeq.flush();
|
||||
if (r.outcome == SeqOutcome.fired) _dispatchIntent(r.intent!);
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelSeqTimeout() {
|
||||
_seqTimeout?.cancel();
|
||||
_seqTimeout = null;
|
||||
}
|
||||
|
||||
void _dispatchIntent(Intent intent) {
|
||||
// Try the focused context first so feature widgets (palette, editor, …)
|
||||
// get a chance to handle their own intents; fall back to the app root's
|
||||
// Actions for global ones (text scale, generic command bridge).
|
||||
final ctx = FocusManager.instance.primaryFocus?.context ?? context;
|
||||
Actions.maybeInvoke(ctx, intent);
|
||||
}
|
||||
|
||||
/// `Alt+<mnemonic>` opens (or toggles) the matching application menu (T-48).
|
||||
/// Returns true when consumed so it never falls through to keymap resolution.
|
||||
bool _handleMenuMnemonic(KeyEvent event) {
|
||||
if (event is! KeyDownEvent || !HardwareKeyboard.instance.isAltPressed) return false;
|
||||
final label = event.logicalKey.keyLabel.toLowerCase();
|
||||
if (label.length != 1) return false;
|
||||
final idx = _menuBar.indexForMnemonic(label);
|
||||
if (idx == null) return false;
|
||||
_menuBar.toggle(idx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeOverlay extends StatelessWidget {
|
||||
const _WelcomeOverlay();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.project,
|
||||
builder: (ctx, _) {
|
||||
if (kernel.project.isOpen) return const SizedBox.shrink();
|
||||
final tokens = ClideTheme.of(ctx).surface;
|
||||
return ColoredBox(color: tokens.globalBackground, child: const WelcomeView());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/// Slot hosting: mounts a slot's tab contributions, integrates focus
|
||||
/// scopes, and renders the slot-specific bodies (sidebar / workspace
|
||||
/// split incl. the editor drag handle / context). Split out of
|
||||
/// app.dart (T-394).
|
||||
library;
|
||||
|
||||
import 'package:clide/extension/src/contribution.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SlotHost extends StatefulWidget {
|
||||
const SlotHost({super.key, required this.slot});
|
||||
final SlotId slot;
|
||||
|
||||
@override
|
||||
State<SlotHost> createState() => _SlotHostState();
|
||||
}
|
||||
|
||||
class _SlotHostState extends State<SlotHost> {
|
||||
late final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'SlotScope:${widget.slot.value}');
|
||||
FocusTracker? _tracker;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final kernel = ClideKernel.of(context);
|
||||
if (!identical(_tracker, kernel.focus)) {
|
||||
_tracker?.unregisterSlotScope(widget.slot, _scope);
|
||||
_tracker = kernel.focus;
|
||||
_tracker!.registerSlotScope(widget.slot, _scope);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tracker?.unregisterSlotScope(widget.slot, _scope);
|
||||
_scope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange(bool hasFocus) {
|
||||
if (!hasFocus || _tracker == null) return;
|
||||
final kernel = ClideKernel.of(context);
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot);
|
||||
if (activeId != null) {
|
||||
_tracker!.setActive(slot: widget.slot, contributionId: activeId);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return FocusScope(
|
||||
node: _scope,
|
||||
onFocusChange: _onFocusChange,
|
||||
child: FocusTraversalGroup(
|
||||
child: ListenableBuilder(
|
||||
listenable: Listenable.merge([kernel.panels, kernel.i18n]),
|
||||
builder: (ctx, _) {
|
||||
final tabs = kernel.panels.tabsFor(widget.slot);
|
||||
if (tabs.isEmpty) {
|
||||
return Container(color: tokens.panelBackground);
|
||||
}
|
||||
final activeId = kernel.panels.activeTabIn(widget.slot) ?? tabs.first.id;
|
||||
final active = tabs.firstWhere((t) => t.id == activeId, orElse: () => tabs.first);
|
||||
return _SlotBody(slot: widget.slot, tabs: tabs, active: active, activeId: activeId);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotBody extends StatelessWidget {
|
||||
const _SlotBody({required this.slot, required this.tabs, required this.active, required this.activeId});
|
||||
final SlotId slot;
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
|
||||
if (slot == Slots.sidebar) {
|
||||
return _SidebarSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.contextPanel) {
|
||||
return _ContextSlot(tabs: tabs, active: active, activeId: activeId, onSelect: (id) => kernel.panels.activateTab(slot, id));
|
||||
}
|
||||
|
||||
if (slot == Slots.workspace) {
|
||||
return _WorkspaceSlot(tabs: tabs, active: active);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: Column(
|
||||
children: [
|
||||
ClideTabBar(
|
||||
items: [for (final t in tabs) ClideTabItem(id: t.id, title: resolveTabTitle(context, t))],
|
||||
activeId: active.id,
|
||||
onSelect: (id) => kernel.panels.activateTab(slot, id),
|
||||
),
|
||||
ClideDivider(),
|
||||
Expanded(child: active.build(context)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SidebarSlot extends StatelessWidget {
|
||||
const _SidebarSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
color: tokens.chromeBackground,
|
||||
alignment: Alignment.topLeft,
|
||||
padding: const EdgeInsets.fromLTRB(2, 2, 0, 0),
|
||||
child: active.build(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stable identity for the workspace's primary pane (Claude). Opening the
|
||||
// editor reparents it from a direct child into a Column/Expanded; without a
|
||||
// stable key Flutter disposes + rebuilds the subtree, and the Claude
|
||||
// conversation's SelectableRegion then runs a pending selection update
|
||||
// against now-inactive elements ("selectable not in this registrar" /
|
||||
// "renderObject of inactive element"). The GlobalKey makes Flutter MOVE the
|
||||
// element instead, preserving the selection subtree.
|
||||
final GlobalKey _kWorkspacePrimary = GlobalKey(debugLabel: 'workspace.primary');
|
||||
|
||||
class _WorkspaceSlot extends StatelessWidget {
|
||||
const _WorkspaceSlot({required this.tabs, required this.active});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
|
||||
static const _editorTabId = 'editor.active';
|
||||
static const _claudeTabId = 'claude.primary';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return ListenableBuilder(
|
||||
listenable: kernel.arrangement,
|
||||
builder: (ctx, _) {
|
||||
final editorOpen = kernel.arrangement.editorOpen;
|
||||
final editorTab = tabs.where((t) => t.id == _editorTabId).firstOrNull;
|
||||
|
||||
final claude = tabs.where((t) => t.id == _claudeTabId).firstOrNull;
|
||||
final primaryPane = KeyedSubtree(key: _kWorkspacePrimary, child: (claude ?? active).build(ctx));
|
||||
|
||||
// A non-Claude, non-editor workspace tab being the active one (e.g.
|
||||
// diff.view revealed by `clide ui open diff`, T-233) shows in the split
|
||||
// region above Claude — "review alongside the conversation" — with a
|
||||
// close affordance back to full-Claude. Only when Claude exists below
|
||||
// it; with no Claude pane the active tab just takes the whole slot, as
|
||||
// before. The editor keeps its own editorOpen-gated split.
|
||||
final reveal = (claude != null && active.id != _claudeTabId && active.id != _editorTabId) ? active : null;
|
||||
final topTab = reveal ?? (editorOpen ? editorTab : null);
|
||||
|
||||
if (topTab == null) {
|
||||
return Container(color: tokens.panelBackground, child: primaryPane);
|
||||
}
|
||||
|
||||
final ratio = kernel.arrangement.editorRatio;
|
||||
return Container(
|
||||
color: tokens.panelBackground,
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final totalHeight = constraints.maxHeight;
|
||||
final topHeight = (totalHeight * ratio).clamp(60.0, totalHeight - 60.0);
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: topHeight,
|
||||
child: reveal != null
|
||||
? _RevealedTab(tab: reveal, onClose: () => kernel.panels.activateTab(Slots.workspace, _claudeTabId))
|
||||
: topTab.build(ctx),
|
||||
),
|
||||
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
|
||||
Expanded(child: primaryPane),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-Claude workspace tab revealed in the split region above Claude
|
||||
/// (T-233): a thin chrome header (title + close) over the tab's body, so the
|
||||
/// user can review it alongside the conversation and dismiss it back to
|
||||
/// full-Claude. The editor uses its own split path and never renders here.
|
||||
class _RevealedTab extends StatelessWidget {
|
||||
const _RevealedTab({required this.tab, required this.onClose});
|
||||
|
||||
final TabContribution tab;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 28,
|
||||
padding: const EdgeInsets.only(left: 10, right: 4),
|
||||
color: tokens.panelHeader,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText(resolveTabTitle(context, tab), fontSize: clideFontCaption, color: tokens.panelHeaderForeground, maxLines: 1),
|
||||
),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Close',
|
||||
excludeSemantics: true,
|
||||
onTap: onClose,
|
||||
child: ClideTappable(
|
||||
onTap: onClose,
|
||||
tooltip: 'Close',
|
||||
builder: (_, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: ClideIcon(PhosphorIcons.byName('x'), size: 12, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: tab.build(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorDragHandle extends StatefulWidget {
|
||||
const _EditorDragHandle({required this.arrangement, required this.totalHeight});
|
||||
|
||||
final LayoutArrangement arrangement;
|
||||
final double totalHeight;
|
||||
|
||||
@override
|
||||
State<_EditorDragHandle> createState() => _EditorDragHandleState();
|
||||
}
|
||||
|
||||
class _EditorDragHandleState extends State<_EditorDragHandle> {
|
||||
bool _hovered = false;
|
||||
bool _focused = false;
|
||||
double? _dragStartRatio;
|
||||
double? _dragStartY;
|
||||
|
||||
// Editor split is a 0..1 fraction; the kernel clamps to 0.15..0.70.
|
||||
// 2% per fine step, 10% per Shift step keeps keyboard feel close to
|
||||
// the pixel-based DragResizeHandle.
|
||||
static const double _stepFine = 0.02;
|
||||
static const double _stepCoarse = 0.10;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final lineColor = (_hovered || _focused) ? tokens.panelActiveBorder : tokens.panelBorder;
|
||||
|
||||
final ratio = widget.arrangement.editorRatio;
|
||||
String pct(double r) => '${(r.clamp(0.15, 0.70) * 100).round()}%';
|
||||
return Semantics(
|
||||
container: true,
|
||||
slider: true,
|
||||
label: 'Editor split',
|
||||
value: pct(ratio),
|
||||
// increase/decrease actions require matching increased/decreased
|
||||
// values, or Flutter asserts on every semantics flush.
|
||||
increasedValue: pct(ratio + _stepFine),
|
||||
decreasedValue: pct(ratio - _stepFine),
|
||||
onIncrease: () => _bump(_stepFine),
|
||||
onDecrease: () => _bump(-_stepFine),
|
||||
child: FocusableActionDetector(
|
||||
onShowFocusHighlight: (v) => setState(() => _focused = v),
|
||||
shortcuts: const <ShortcutActivator, Intent>{
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp): _EditorBumpIntent(-_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown): _EditorBumpIntent(_stepFine),
|
||||
SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): _EditorBumpIntent(-_stepCoarse),
|
||||
SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): _EditorBumpIntent(_stepCoarse),
|
||||
},
|
||||
actions: <Type, Action<Intent>>{
|
||||
_EditorBumpIntent: CallbackAction<_EditorBumpIntent>(
|
||||
onInvoke: (intent) {
|
||||
_bump(intent.delta);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeRow,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Listener(
|
||||
onPointerDown: (e) {
|
||||
_dragStartRatio = widget.arrangement.editorRatio;
|
||||
_dragStartY = e.position.dy;
|
||||
},
|
||||
onPointerMove: (e) {
|
||||
final startR = _dragStartRatio;
|
||||
final startY = _dragStartY;
|
||||
if (startR == null || startY == null || widget.totalHeight <= 0) return;
|
||||
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
|
||||
widget.arrangement.setEditorRatio(startR + deltaRatio);
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
_dragStartRatio = null;
|
||||
_dragStartY = null;
|
||||
},
|
||||
child: Container(height: 4, color: lineColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _bump(double delta) {
|
||||
widget.arrangement.setEditorRatio(widget.arrangement.editorRatio + delta);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorBumpIntent extends Intent {
|
||||
const _EditorBumpIntent(this.delta);
|
||||
final double delta;
|
||||
}
|
||||
|
||||
class _ContextSlot extends StatelessWidget {
|
||||
const _ContextSlot({required this.tabs, required this.active, required this.activeId, required this.onSelect});
|
||||
|
||||
final List<TabContribution> tabs;
|
||||
final TabContribution active;
|
||||
final String activeId;
|
||||
final ValueChanged<String> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(color: tokens.panelBackground, alignment: Alignment.topLeft, padding: const EdgeInsets.only(right: 2), child: active.build(context));
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a tab's display title through i18n when it carries a key +
|
||||
/// namespace, else its static title. Shared by the slot bodies, the
|
||||
/// revealed-tab header, and the bottom icon rails.
|
||||
String resolveTabTitle(BuildContext context, TabContribution t) {
|
||||
final key = t.titleKey;
|
||||
final ns = t.i18nNamespace;
|
||||
if (key == null || ns == null) return t.title;
|
||||
return ClideKernel.of(context).i18n.string(key, namespace: ns, placeholder: t.title);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// CSI handlers: cursor movement, erase/scroll/line/char ops, device
|
||||
// attributes + status reports, margins, tab clear, repeat, and window
|
||||
// manipulation. Split out of parser.dart (T-123); dispatched from the
|
||||
// _csiHandlers table in the EscapeParser core.
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _CsiHandlers on _EscapeParserBase {
|
||||
/// `ESC [ Ps a` Cursor Horizontal Position Relative (HPR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sa/
|
||||
// void _csiHandleCursorHorizontalRelative() {
|
||||
// if (_csi.params.isEmpty) {
|
||||
// handler.cursorHorizontal(1);
|
||||
// } else {
|
||||
// handler.cursorHorizontal(_csi.params[0]);
|
||||
// }
|
||||
// }
|
||||
/// `ESC [ Ps b` Repeat Previous Character (REP)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sb/
|
||||
void _csiHandleRepeatPreviousCharacter() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.repeatPreviousCharacter(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps c` Device Attributes (DA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sc/
|
||||
void _csiHandleSendDeviceAttributes() {
|
||||
switch (_csi.prefix) {
|
||||
case Ascii.greaterThan:
|
||||
return handler.sendSecondaryDeviceAttributes();
|
||||
case Ascii.equal:
|
||||
return handler.sendTertiaryDeviceAttributes();
|
||||
default:
|
||||
handler.sendPrimaryDeviceAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps d` Cursor Vertical Position Absolute (VPA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sd/
|
||||
void _csiHandleLinePositionAbsolute() {
|
||||
var y = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
y = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.setCursorY(y - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps f` Alias: Set Cursor Position
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sf/
|
||||
void _csiHandleCursorPosition() {
|
||||
var row = 1;
|
||||
var col = 1;
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
row = _csi.params[0];
|
||||
col = _csi.params[1];
|
||||
}
|
||||
|
||||
handler.setCursor(col - 1, row - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps g` Tab Clear (TBC)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sg/
|
||||
void _csiHandelClearTabStop() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.clearTabStopUnderCursor();
|
||||
default:
|
||||
return handler.clearAllTabStops();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps n` Device Status Report [Dispatch] (DSR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sn/
|
||||
void _csiHandleDeviceStatusReport() {
|
||||
if (_csi.params.isEmpty) return;
|
||||
|
||||
switch (_csi.params[0]) {
|
||||
case 5:
|
||||
return handler.sendOperatingStatus();
|
||||
case 6:
|
||||
return handler.sendCursorPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps r` Set Top and Bottom Margins (DECSTBM)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sr/
|
||||
void _csiHandleSetMargins() {
|
||||
var top = 1;
|
||||
int? bottom;
|
||||
|
||||
if (_csi.params.length > 2) return;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
top = _csi.params[0];
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
bottom = _csi.params[1] - 1;
|
||||
}
|
||||
}
|
||||
|
||||
handler.setMargins(top - 1, bottom);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps t` Window operations [DISPATCH]
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_st/
|
||||
void _csiWindowManipulation() {
|
||||
// The sequence needs at least one parameter.
|
||||
if (_csi.params.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// Most the commands in this group are either of the scope of this package,
|
||||
// or should be disabled for security risks.
|
||||
switch (_csi.params.first) {
|
||||
// Window handling is currently not in the scope of the package.
|
||||
case 1: // Restore Terminal Window (show window if minimized)
|
||||
case 2: // Minimize Terminal Window
|
||||
case 3: // Set Terminal Window Position
|
||||
case 4: // Set Terminal Window Size in Pixels
|
||||
case 5: // Raise Terminal Window
|
||||
case 6: // Lower Terminal Window
|
||||
case 7: // Refresh/Redraw Terminal Window
|
||||
return;
|
||||
case 8: // Set Terminal Window Size (in characters)
|
||||
// This CSI contains 2 more parameters: width and height.
|
||||
if (_csi.params.length != 3) {
|
||||
return;
|
||||
}
|
||||
final rows = _csi.params[1];
|
||||
final cols = _csi.params[2];
|
||||
handler.resize(cols, rows);
|
||||
return;
|
||||
// Window handling is currently no in the scope of the package.
|
||||
case 9: // Maximize Terminal Window
|
||||
case 10: // Alias: Maximize Terminal Window
|
||||
case 11: // Report Terminal Window State
|
||||
case 13: // Report Terminal Window Position
|
||||
case 14: // Report Terminal Window Size in Pixels
|
||||
case 15: // Report Screen Size in Pixels
|
||||
case 16: // Report Cell Size in Pixels
|
||||
return;
|
||||
case 18: // Report Terminal Size (in characters)
|
||||
handler.sendSize();
|
||||
return;
|
||||
// Screen handling is currently no in the scope of the package.
|
||||
case 19: // Report Screen Size (in characters)
|
||||
// Disabled as these can a security risk.
|
||||
case 20: // Get Icon Title
|
||||
case 21: // Get Terminal Title
|
||||
// Not implemented.
|
||||
case 22: // Push Terminal Title
|
||||
case 23: // Pop Terminal Title
|
||||
return;
|
||||
// Unknown CSI.
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps A` Cursor Up (CUU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ca/
|
||||
void _csiHandleCursorUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps B` Cursor Down (CUD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cb/
|
||||
void _csiHandleCursorDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps C` Cursor Right (CUF)
|
||||
///
|
||||
/// Cursor Right (CUF)
|
||||
void _csiHandleCursorForward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps D` Cursor Left (CUB)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cd/
|
||||
void _csiHandleCursorBackward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps E` Cursor Next Line (CNL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ce/
|
||||
void _csiHandleCursorNextLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorNextLine(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps F` Cursor Previous Line (CPL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cf/
|
||||
void _csiHandleCursorPrecedingLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorPrecedingLine(amount);
|
||||
}
|
||||
|
||||
void _csiHandleCursorHorizontalAbsolute() {
|
||||
var x = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
x = _csi.params[0];
|
||||
if (x == 0) x = 1;
|
||||
}
|
||||
|
||||
handler.setCursorX(x - 1);
|
||||
}
|
||||
|
||||
/// ESC [ Ps J Erase Display [Dispatch] (ED)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cj/
|
||||
void _csiHandleEraseDisplay() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseDisplayBelow();
|
||||
case 1:
|
||||
return handler.eraseDisplayAbove();
|
||||
case 2:
|
||||
return handler.eraseDisplay();
|
||||
case 3:
|
||||
return handler.eraseScrollbackOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps K` Erase Line [Dispatch] (EL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ck/
|
||||
void _csiHandleEraseLine() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseLineRight();
|
||||
case 1:
|
||||
return handler.eraseLineLeft();
|
||||
case 2:
|
||||
return handler.eraseLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps L` Insert Line (IL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cl/
|
||||
void _csiHandleInsertLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps M Delete Line (DL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cm/
|
||||
void _csiHandleDeleteLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps P Delete Character (DCH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cp/
|
||||
void _csiHandleDelete() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps S` Scroll Up (SU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cs/
|
||||
void _csiHandleScrollUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollUp(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps T `Scroll Down (SD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ct_1param/
|
||||
void _csiHandleScrollDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollDown(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps X` Erase Character (ECH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cx/
|
||||
void _csiHandleEraseCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.eraseChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps @` Insert Blanks (ICH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_x40_at/
|
||||
///
|
||||
/// Inserts amount spaces at current cursor position moving existing cell
|
||||
/// contents to the right. The contents of the amount right-most columns in
|
||||
/// the scroll region are lost. The cursor position is not changed.
|
||||
void _csiHandleInsertBlankCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertBlankChars(amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// ANSI + DEC private mode set/reset (CSI h / CSI l, with and without
|
||||
// the ? prefix). Split out of parser.dart (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _ModeHandlers on _EscapeParserBase {
|
||||
/// - `ESC [ [ Pm ] h Set Mode (SM)` https://terminalguide.namepad.de/seq/csi_sm/
|
||||
/// - `ESC [ ? [ Pm ] h` Set Mode (?) (SM) https://terminalguide.namepad.de/seq/csi_sh__p/
|
||||
/// - `ESC [ [ Pm ] l` Reset Mode (RM) https://terminalguide.namepad.de/seq/csi_rm/
|
||||
/// - `ESC [ ? [ Pm ] l` Reset Mode (?) (RM) https://terminalguide.namepad.de/seq/csi_sl__p/
|
||||
void _csiHandleMode() {
|
||||
final isEnabled = _csi.finalByte == Ascii.h;
|
||||
|
||||
final isDecModes = _csi.prefix == Ascii.questionMark;
|
||||
|
||||
if (isDecModes) {
|
||||
for (var mode in _csi.params) {
|
||||
_setDecMode(mode, isEnabled);
|
||||
}
|
||||
} else {
|
||||
for (var mode in _csi.params) {
|
||||
_setMode(mode, isEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _setMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 4:
|
||||
return handler.setInsertMode(enabled);
|
||||
case 20:
|
||||
return handler.setLineFeedMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
void _setDecMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 1:
|
||||
return handler.setCursorKeysMode(enabled);
|
||||
case 3:
|
||||
return handler.setColumnMode(enabled);
|
||||
case 5:
|
||||
return handler.setReverseDisplayMode(enabled);
|
||||
case 6:
|
||||
return handler.setOriginMode(enabled);
|
||||
case 7:
|
||||
return handler.setAutoWrapMode(enabled);
|
||||
case 9:
|
||||
return enabled ? handler.setMouseMode(MouseMode.clickOnly) : handler.setMouseMode(MouseMode.none);
|
||||
case 12:
|
||||
case 13:
|
||||
return handler.setCursorBlinkMode(enabled);
|
||||
case 25:
|
||||
return handler.setCursorVisibleMode(enabled);
|
||||
case 47:
|
||||
if (enabled) {
|
||||
return handler.useAltBuffer();
|
||||
} else {
|
||||
return handler.useMainBuffer();
|
||||
}
|
||||
case 66:
|
||||
return handler.setAppKeypadMode(enabled);
|
||||
case 1000:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1001:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1002:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollDrag) : handler.setMouseMode(MouseMode.none);
|
||||
case 1003:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollMove) : handler.setMouseMode(MouseMode.none);
|
||||
case 1004:
|
||||
return handler.setReportFocusMode(enabled);
|
||||
case 1005:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.utf) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1006:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.sgr) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1007:
|
||||
return handler.setAltBufferMouseScrollMode(enabled);
|
||||
case 1015:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.urxvt) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1047:
|
||||
if (enabled) {
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.clearAltBuffer();
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 1048:
|
||||
if (enabled) {
|
||||
return handler.saveCursor();
|
||||
} else {
|
||||
return handler.restoreCursor();
|
||||
}
|
||||
case 1049:
|
||||
if (enabled) {
|
||||
handler.saveCursor();
|
||||
handler.clearAltBuffer();
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 2004:
|
||||
return handler.setBracketedPasteMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownDecMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// OSC string parsing + dispatch (title / icon name / private
|
||||
// pass-through), BEL or ST terminated. Split out of parser.dart
|
||||
// (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _OscHandlers on _EscapeParserBase {
|
||||
/// Parse a OSC sequence from the queue. Returns true if a sequence was
|
||||
/// found and handled.
|
||||
bool _escHandleOSC() {
|
||||
final consumed = _consumeOsc();
|
||||
if (!consumed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_osc.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common OSCs
|
||||
if (_osc.length >= 2) {
|
||||
final ps = _osc[0];
|
||||
final pt = _osc[1];
|
||||
|
||||
switch (ps) {
|
||||
case '0':
|
||||
handler.setTitle(pt);
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '1':
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '2':
|
||||
handler.setTitle(pt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Private extensions
|
||||
handler.unknownOSC(_osc[0], _osc.sublist(1));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
final _osc = <String>[];
|
||||
|
||||
bool _consumeOsc() {
|
||||
_osc.clear();
|
||||
final param = StringBuffer();
|
||||
|
||||
while (true) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final char = _queue.consume();
|
||||
|
||||
// OSC terminates with BEL
|
||||
if (char == Ascii.BEL) {
|
||||
_osc.add(param.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// OSC terminates with ST
|
||||
if (char == Ascii.ESC) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queue.consume() == Ascii.backslash) {
|
||||
_osc.add(param.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Parse next parameter
|
||||
if (char == Ascii.semicolon) {
|
||||
_osc.add(param.toString());
|
||||
param.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
param.writeCharCode(char);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,18 @@ import 'package:clide/src/terminal/src/utils/byte_consumer.dart';
|
||||
import 'package:clide/src/terminal/src/utils/char_code.dart';
|
||||
import 'package:clide/src/terminal/src/utils/lookup_table.dart';
|
||||
|
||||
/// [EscapeParser] translates control characters and escape sequences into
|
||||
/// function calls that the terminal can handle.
|
||||
///
|
||||
/// Design goals:
|
||||
/// * Zero object allocation during processing.
|
||||
/// * No internal state. Same input will always produce same output.
|
||||
class EscapeParser {
|
||||
final EscapeHandler handler;
|
||||
part 'csi_handlers.dart';
|
||||
part 'mode_handlers.dart';
|
||||
part 'osc_handlers.dart';
|
||||
part 'sgr_handlers.dart';
|
||||
|
||||
EscapeParser(this.handler);
|
||||
/// Shared parser state the handler mixins operate on: the escape
|
||||
/// handler sink, the byte queue, token bookkeeping, and the reusable
|
||||
/// CSI scratch object (zero-allocation design — see [EscapeParser]).
|
||||
abstract class _EscapeParserBase {
|
||||
_EscapeParserBase(this.handler);
|
||||
|
||||
final EscapeHandler handler;
|
||||
|
||||
final _queue = ByteConsumer();
|
||||
|
||||
@@ -27,6 +29,24 @@ class EscapeParser {
|
||||
/// End of sequence or character being processed. Useful for debugging.
|
||||
int get tokenEnd => _queue.totalConsumed;
|
||||
|
||||
/// The last parsed [_Csi]. This is a mutable singletion by design to reduce
|
||||
/// object allocations.
|
||||
final _csi = _Csi(finalByte: 0, params: []);
|
||||
}
|
||||
|
||||
/// [EscapeParser] translates control characters and escape sequences into
|
||||
/// function calls that the terminal can handle.
|
||||
///
|
||||
/// Design goals:
|
||||
/// * Zero object allocation during processing.
|
||||
/// * No internal state. Same input will always produce same output.
|
||||
///
|
||||
/// The handler groups live as mixins in this library's part files
|
||||
/// (csi/sgr/mode/osc handlers, T-123); this core owns the byte queue,
|
||||
/// the dispatch tables, and the ESC/CSI consumers.
|
||||
class EscapeParser extends _EscapeParserBase with _CsiHandlers, _ModeHandlers, _OscHandlers, _SgrHandlers {
|
||||
EscapeParser(super.handler);
|
||||
|
||||
void write(String chunk) {
|
||||
_queue.unrefConsumedBlocks();
|
||||
_queue.add(chunk);
|
||||
@@ -197,7 +217,11 @@ class EscapeParser {
|
||||
final consumed = _consumeCsi();
|
||||
if (!consumed) return false;
|
||||
|
||||
final csiHandler = _csiHandlers[_csi.finalByte];
|
||||
// An intermediate byte changes the meaning of the final byte
|
||||
// (`CSI 5 SP @` is scroll-left, not insert-blank). None of the
|
||||
// intermediate forms are implemented, so report them as unknown
|
||||
// rather than mis-dispatching on the bare final byte.
|
||||
final csiHandler = _csi.intermediates.isEmpty ? _csiHandlers[_csi.finalByte] : null;
|
||||
|
||||
if (csiHandler == null) {
|
||||
handler.unknownCSI(_csi.finalByte);
|
||||
@@ -208,10 +232,6 @@ class EscapeParser {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The last parsed [_Csi]. This is a mutable singletion by design to reduce
|
||||
/// object allocations.
|
||||
final _csi = _Csi(finalByte: 0, params: []);
|
||||
|
||||
/// Parse a CSI from the head of the queue. Return false if the CSI isn't
|
||||
/// complete. After a CSI is successfully parsed, [_csi] is updated.
|
||||
bool _consumeCsi() {
|
||||
@@ -220,6 +240,8 @@ class EscapeParser {
|
||||
}
|
||||
|
||||
_csi.params.clear();
|
||||
_csi.subParam.clear();
|
||||
_csi.intermediates.clear();
|
||||
|
||||
// test whether the csi is a `CSI ? Ps ...` or `CSI Ps ...`
|
||||
final prefix = _queue.peek();
|
||||
@@ -232,6 +254,11 @@ class EscapeParser {
|
||||
|
||||
var param = 0;
|
||||
var hasParam = false;
|
||||
// Whether the value being accumulated was attached to its predecessor
|
||||
// with a colon (ECMA-48 sub-parameter separator, ITU T.416 SGR colors).
|
||||
// Before T-369 colons were silently dropped mid-sequence, fusing
|
||||
// `38:2:255:0:0` into one bogus parameter.
|
||||
var linkedToPrev = false;
|
||||
while (true) {
|
||||
// The sequence isn't completed, just ignore it.
|
||||
if (_queue.isEmpty) {
|
||||
@@ -243,8 +270,21 @@ class EscapeParser {
|
||||
if (char == Ascii.semicolon) {
|
||||
if (hasParam) {
|
||||
_csi.params.add(param);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
}
|
||||
param = 0;
|
||||
linkedToPrev = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == Ascii.colon) {
|
||||
// Push the current value even when empty — `38:2::r:g:b` carries an
|
||||
// empty colorspace slot that must keep its position in the group.
|
||||
_csi.params.add(hasParam ? param : 0);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
hasParam = true;
|
||||
param = 0;
|
||||
linkedToPrev = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -255,14 +295,20 @@ class EscapeParser {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char >= Ascii.space && char <= Ascii.slash) {
|
||||
_csi.intermediates.add(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char > Ascii.NULL && char < Ascii.num0) {
|
||||
// intermediates.add(char);
|
||||
// Other C0 controls embedded in a CSI: ignore, as before.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char >= Ascii.atSign && char <= Ascii.tilde) {
|
||||
if (hasParam) {
|
||||
_csi.params.add(param);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
}
|
||||
|
||||
_csi.finalByte = char;
|
||||
@@ -302,827 +348,26 @@ class EscapeParser {
|
||||
'X'.codeUnitAt(0): _csiHandleEraseCharacters,
|
||||
'@'.codeUnitAt(0): _csiHandleInsertBlankCharacters,
|
||||
});
|
||||
|
||||
/// `ESC [ Ps a` Cursor Horizontal Position Relative (HPR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sa/
|
||||
// void _csiHandleCursorHorizontalRelative() {
|
||||
// if (_csi.params.isEmpty) {
|
||||
// handler.cursorHorizontal(1);
|
||||
// } else {
|
||||
// handler.cursorHorizontal(_csi.params[0]);
|
||||
// }
|
||||
// }
|
||||
|
||||
/// `ESC [ Ps b` Repeat Previous Character (REP)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sb/
|
||||
void _csiHandleRepeatPreviousCharacter() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.repeatPreviousCharacter(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps c` Device Attributes (DA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sc/
|
||||
void _csiHandleSendDeviceAttributes() {
|
||||
switch (_csi.prefix) {
|
||||
case Ascii.greaterThan:
|
||||
return handler.sendSecondaryDeviceAttributes();
|
||||
case Ascii.equal:
|
||||
return handler.sendTertiaryDeviceAttributes();
|
||||
default:
|
||||
handler.sendPrimaryDeviceAttributes();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps d` Cursor Vertical Position Absolute (VPA)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sd/
|
||||
void _csiHandleLinePositionAbsolute() {
|
||||
var y = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
y = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.setCursorY(y - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps f` Alias: Set Cursor Position
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sf/
|
||||
void _csiHandleCursorPosition() {
|
||||
var row = 1;
|
||||
var col = 1;
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
row = _csi.params[0];
|
||||
col = _csi.params[1];
|
||||
}
|
||||
|
||||
handler.setCursor(col - 1, row - 1);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps g` Tab Clear (TBC)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sg/
|
||||
void _csiHandelClearTabStop() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.clearTabStopUnderCursor();
|
||||
default:
|
||||
return handler.clearAllTabStops();
|
||||
}
|
||||
}
|
||||
|
||||
/// - `ESC [ [ Pm ] h Set Mode (SM)` https://terminalguide.namepad.de/seq/csi_sm/
|
||||
/// - `ESC [ ? [ Pm ] h` Set Mode (?) (SM) https://terminalguide.namepad.de/seq/csi_sh__p/
|
||||
/// - `ESC [ [ Pm ] l` Reset Mode (RM) https://terminalguide.namepad.de/seq/csi_rm/
|
||||
/// - `ESC [ ? [ Pm ] l` Reset Mode (?) (RM) https://terminalguide.namepad.de/seq/csi_sl__p/
|
||||
void _csiHandleMode() {
|
||||
final isEnabled = _csi.finalByte == Ascii.h;
|
||||
|
||||
final isDecModes = _csi.prefix == Ascii.questionMark;
|
||||
|
||||
if (isDecModes) {
|
||||
for (var mode in _csi.params) {
|
||||
_setDecMode(mode, isEnabled);
|
||||
}
|
||||
} else {
|
||||
for (var mode in _csi.params) {
|
||||
_setMode(mode, isEnabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ [ Ps ] m` Select Graphic Rendition (SGR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sm/
|
||||
void _csiHandleSgr() {
|
||||
final params = _csi.params;
|
||||
|
||||
if (params.isEmpty) {
|
||||
return handler.resetCursorStyle();
|
||||
}
|
||||
|
||||
for (var i = 0; i < _csi.params.length; i++) {
|
||||
final param = params[i];
|
||||
switch (param) {
|
||||
case 0:
|
||||
handler.resetCursorStyle();
|
||||
continue;
|
||||
case 1:
|
||||
handler.setCursorBold();
|
||||
continue;
|
||||
case 2:
|
||||
handler.setCursorFaint();
|
||||
continue;
|
||||
case 3:
|
||||
handler.setCursorItalic();
|
||||
continue;
|
||||
case 4:
|
||||
handler.setCursorUnderline();
|
||||
continue;
|
||||
case 5:
|
||||
handler.setCursorBlink();
|
||||
continue;
|
||||
case 7:
|
||||
handler.setCursorInverse();
|
||||
continue;
|
||||
case 8:
|
||||
handler.setCursorInvisible();
|
||||
continue;
|
||||
case 9:
|
||||
handler.setCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 21:
|
||||
handler.unsetCursorBold();
|
||||
continue;
|
||||
case 22:
|
||||
handler.unsetCursorFaint();
|
||||
continue;
|
||||
case 23:
|
||||
handler.unsetCursorItalic();
|
||||
continue;
|
||||
case 24:
|
||||
handler.unsetCursorUnderline();
|
||||
continue;
|
||||
case 25:
|
||||
handler.unsetCursorBlink();
|
||||
continue;
|
||||
case 27:
|
||||
handler.unsetCursorInverse();
|
||||
continue;
|
||||
case 28:
|
||||
handler.unsetCursorInvisible();
|
||||
continue;
|
||||
case 29:
|
||||
handler.unsetCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 30:
|
||||
handler.setForegroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 31:
|
||||
handler.setForegroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 32:
|
||||
handler.setForegroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 33:
|
||||
handler.setForegroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 34:
|
||||
handler.setForegroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 35:
|
||||
handler.setForegroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 36:
|
||||
handler.setForegroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 37:
|
||||
handler.setForegroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 38:
|
||||
final mode = params[i + 1];
|
||||
switch (mode) {
|
||||
case 2:
|
||||
final r = params[i + 2];
|
||||
final g = params[i + 3];
|
||||
final b = params[i + 4];
|
||||
handler.setForegroundColorRgb(r, g, b);
|
||||
i += 4;
|
||||
break;
|
||||
case 5:
|
||||
final index = params[i + 2];
|
||||
handler.setForegroundColor256(index);
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
case 39:
|
||||
handler.resetForeground();
|
||||
continue;
|
||||
|
||||
case 40:
|
||||
handler.setBackgroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 41:
|
||||
handler.setBackgroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 42:
|
||||
handler.setBackgroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 43:
|
||||
handler.setBackgroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 44:
|
||||
handler.setBackgroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 45:
|
||||
handler.setBackgroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 46:
|
||||
handler.setBackgroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 47:
|
||||
handler.setBackgroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 48:
|
||||
final mode = params[i + 1];
|
||||
switch (mode) {
|
||||
case 2:
|
||||
final r = params[i + 2];
|
||||
final g = params[i + 3];
|
||||
final b = params[i + 4];
|
||||
handler.setBackgroundColorRgb(r, g, b);
|
||||
i += 4;
|
||||
break;
|
||||
case 5:
|
||||
final index = params[i + 2];
|
||||
handler.setBackgroundColor256(index);
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
case 49:
|
||||
handler.resetBackground();
|
||||
continue;
|
||||
|
||||
case 90:
|
||||
handler.setForegroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 91:
|
||||
handler.setForegroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 92:
|
||||
handler.setForegroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 93:
|
||||
handler.setForegroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 94:
|
||||
handler.setForegroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 95:
|
||||
handler.setForegroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 96:
|
||||
handler.setForegroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 97:
|
||||
handler.setForegroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
case 100:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 101:
|
||||
handler.setBackgroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 102:
|
||||
handler.setBackgroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 103:
|
||||
handler.setBackgroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 104:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 105:
|
||||
handler.setBackgroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 106:
|
||||
handler.setBackgroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 107:
|
||||
handler.setBackgroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
default:
|
||||
handler.unsupportedStyle(param);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps n` Device Status Report [Dispatch] (DSR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sn/
|
||||
void _csiHandleDeviceStatusReport() {
|
||||
if (_csi.params.isEmpty) return;
|
||||
|
||||
switch (_csi.params[0]) {
|
||||
case 5:
|
||||
return handler.sendOperatingStatus();
|
||||
case 6:
|
||||
return handler.sendCursorPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps ; Ps r` Set Top and Bottom Margins (DECSTBM)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sr/
|
||||
void _csiHandleSetMargins() {
|
||||
var top = 1;
|
||||
int? bottom;
|
||||
|
||||
if (_csi.params.length > 2) return;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
top = _csi.params[0];
|
||||
|
||||
if (_csi.params.length == 2) {
|
||||
bottom = _csi.params[1] - 1;
|
||||
}
|
||||
}
|
||||
|
||||
handler.setMargins(top - 1, bottom);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps t` Window operations [DISPATCH]
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_st/
|
||||
void _csiWindowManipulation() {
|
||||
// The sequence needs at least one parameter.
|
||||
if (_csi.params.isEmpty) {
|
||||
return;
|
||||
}
|
||||
// Most the commands in this group are either of the scope of this package,
|
||||
// or should be disabled for security risks.
|
||||
switch (_csi.params.first) {
|
||||
// Window handling is currently not in the scope of the package.
|
||||
case 1: // Restore Terminal Window (show window if minimized)
|
||||
case 2: // Minimize Terminal Window
|
||||
case 3: // Set Terminal Window Position
|
||||
case 4: // Set Terminal Window Size in Pixels
|
||||
case 5: // Raise Terminal Window
|
||||
case 6: // Lower Terminal Window
|
||||
case 7: // Refresh/Redraw Terminal Window
|
||||
return;
|
||||
case 8: // Set Terminal Window Size (in characters)
|
||||
// This CSI contains 2 more parameters: width and height.
|
||||
if (_csi.params.length != 3) {
|
||||
return;
|
||||
}
|
||||
final rows = _csi.params[1];
|
||||
final cols = _csi.params[2];
|
||||
handler.resize(cols, rows);
|
||||
return;
|
||||
// Window handling is currently no in the scope of the package.
|
||||
case 9: // Maximize Terminal Window
|
||||
case 10: // Alias: Maximize Terminal Window
|
||||
case 11: // Report Terminal Window State
|
||||
case 13: // Report Terminal Window Position
|
||||
case 14: // Report Terminal Window Size in Pixels
|
||||
case 15: // Report Screen Size in Pixels
|
||||
case 16: // Report Cell Size in Pixels
|
||||
return;
|
||||
case 18: // Report Terminal Size (in characters)
|
||||
handler.sendSize();
|
||||
return;
|
||||
// Screen handling is currently no in the scope of the package.
|
||||
case 19: // Report Screen Size (in characters)
|
||||
// Disabled as these can a security risk.
|
||||
case 20: // Get Icon Title
|
||||
case 21: // Get Terminal Title
|
||||
// Not implemented.
|
||||
case 22: // Push Terminal Title
|
||||
case 23: // Pop Terminal Title
|
||||
return;
|
||||
// Unknown CSI.
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps A` Cursor Up (CUU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ca/
|
||||
void _csiHandleCursorUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps B` Cursor Down (CUD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cb/
|
||||
void _csiHandleCursorDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorY(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps C` Cursor Right (CUF)
|
||||
///
|
||||
/// Cursor Right (CUF)
|
||||
void _csiHandleCursorForward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps D` Cursor Left (CUB)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cd/
|
||||
void _csiHandleCursorBackward() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.moveCursorX(-amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps E` Cursor Next Line (CNL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ce/
|
||||
void _csiHandleCursorNextLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorNextLine(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps F` Cursor Previous Line (CPL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cf/
|
||||
void _csiHandleCursorPrecedingLine() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
if (amount == 0) amount = 1;
|
||||
}
|
||||
|
||||
handler.cursorPrecedingLine(amount);
|
||||
}
|
||||
|
||||
void _csiHandleCursorHorizontalAbsolute() {
|
||||
var x = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
x = _csi.params[0];
|
||||
if (x == 0) x = 1;
|
||||
}
|
||||
|
||||
handler.setCursorX(x - 1);
|
||||
}
|
||||
|
||||
/// ESC [ Ps J Erase Display [Dispatch] (ED)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cj/
|
||||
void _csiHandleEraseDisplay() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseDisplayBelow();
|
||||
case 1:
|
||||
return handler.eraseDisplayAbove();
|
||||
case 2:
|
||||
return handler.eraseDisplay();
|
||||
case 3:
|
||||
return handler.eraseScrollbackOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps K` Erase Line [Dispatch] (EL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ck/
|
||||
void _csiHandleEraseLine() {
|
||||
var cmd = 0;
|
||||
|
||||
if (_csi.params.length == 1) {
|
||||
cmd = _csi.params[0];
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 0:
|
||||
return handler.eraseLineRight();
|
||||
case 1:
|
||||
return handler.eraseLineLeft();
|
||||
case 2:
|
||||
return handler.eraseLine();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ESC [ Ps L` Insert Line (IL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cl/
|
||||
void _csiHandleInsertLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps M Delete Line (DL)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cm/
|
||||
void _csiHandleDeleteLines() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteLines(amount);
|
||||
}
|
||||
|
||||
/// ESC [ Ps P Delete Character (DCH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cp/
|
||||
void _csiHandleDelete() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.deleteChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps S` Scroll Up (SU)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cs/
|
||||
void _csiHandleScrollUp() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollUp(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps T `Scroll Down (SD)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_ct_1param/
|
||||
void _csiHandleScrollDown() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.scrollDown(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps X` Erase Character (ECH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_cx/
|
||||
void _csiHandleEraseCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.eraseChars(amount);
|
||||
}
|
||||
|
||||
/// `ESC [ Ps @` Insert Blanks (ICH)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_x40_at/
|
||||
///
|
||||
/// Inserts amount spaces at current cursor position moving existing cell
|
||||
/// contents to the right. The contents of the amount right-most columns in
|
||||
/// the scroll region are lost. The cursor position is not changed.
|
||||
void _csiHandleInsertBlankCharacters() {
|
||||
var amount = 1;
|
||||
|
||||
if (_csi.params.isNotEmpty) {
|
||||
amount = _csi.params[0];
|
||||
}
|
||||
|
||||
handler.insertBlankChars(amount);
|
||||
}
|
||||
|
||||
void _setMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 4:
|
||||
return handler.setInsertMode(enabled);
|
||||
case 20:
|
||||
return handler.setLineFeedMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
void _setDecMode(int mode, bool enabled) {
|
||||
switch (mode) {
|
||||
case 1:
|
||||
return handler.setCursorKeysMode(enabled);
|
||||
case 3:
|
||||
return handler.setColumnMode(enabled);
|
||||
case 5:
|
||||
return handler.setReverseDisplayMode(enabled);
|
||||
case 6:
|
||||
return handler.setOriginMode(enabled);
|
||||
case 7:
|
||||
return handler.setAutoWrapMode(enabled);
|
||||
case 9:
|
||||
return enabled ? handler.setMouseMode(MouseMode.clickOnly) : handler.setMouseMode(MouseMode.none);
|
||||
case 12:
|
||||
case 13:
|
||||
return handler.setCursorBlinkMode(enabled);
|
||||
case 25:
|
||||
return handler.setCursorVisibleMode(enabled);
|
||||
case 47:
|
||||
if (enabled) {
|
||||
return handler.useAltBuffer();
|
||||
} else {
|
||||
return handler.useMainBuffer();
|
||||
}
|
||||
case 66:
|
||||
return handler.setAppKeypadMode(enabled);
|
||||
case 1000:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1001:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScroll) : handler.setMouseMode(MouseMode.none);
|
||||
case 1002:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollDrag) : handler.setMouseMode(MouseMode.none);
|
||||
case 1003:
|
||||
return enabled ? handler.setMouseMode(MouseMode.upDownScrollMove) : handler.setMouseMode(MouseMode.none);
|
||||
case 1004:
|
||||
return handler.setReportFocusMode(enabled);
|
||||
case 1005:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.utf) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1006:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.sgr) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1007:
|
||||
return handler.setAltBufferMouseScrollMode(enabled);
|
||||
case 1015:
|
||||
return enabled ? handler.setMouseReportMode(MouseReportMode.urxvt) : handler.setMouseReportMode(MouseReportMode.normal);
|
||||
case 1047:
|
||||
if (enabled) {
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.clearAltBuffer();
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 1048:
|
||||
if (enabled) {
|
||||
return handler.saveCursor();
|
||||
} else {
|
||||
return handler.restoreCursor();
|
||||
}
|
||||
case 1049:
|
||||
if (enabled) {
|
||||
handler.saveCursor();
|
||||
handler.clearAltBuffer();
|
||||
handler.useAltBuffer();
|
||||
} else {
|
||||
handler.useMainBuffer();
|
||||
}
|
||||
return;
|
||||
case 2004:
|
||||
return handler.setBracketedPasteMode(enabled);
|
||||
default:
|
||||
return handler.setUnknownDecMode(mode, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a OSC sequence from the queue. Returns true if a sequence was
|
||||
/// found and handled.
|
||||
bool _escHandleOSC() {
|
||||
final consumed = _consumeOsc();
|
||||
if (!consumed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_osc.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Common OSCs
|
||||
if (_osc.length >= 2) {
|
||||
final ps = _osc[0];
|
||||
final pt = _osc[1];
|
||||
|
||||
switch (ps) {
|
||||
case '0':
|
||||
handler.setTitle(pt);
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '1':
|
||||
handler.setIconName(pt);
|
||||
return true;
|
||||
case '2':
|
||||
handler.setTitle(pt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Private extensions
|
||||
handler.unknownOSC(_osc[0], _osc.sublist(1));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
final _osc = <String>[];
|
||||
|
||||
bool _consumeOsc() {
|
||||
_osc.clear();
|
||||
final param = StringBuffer();
|
||||
|
||||
while (true) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final char = _queue.consume();
|
||||
|
||||
// OSC terminates with BEL
|
||||
if (char == Ascii.BEL) {
|
||||
_osc.add(param.toString());
|
||||
return true;
|
||||
}
|
||||
|
||||
/// OSC terminates with ST
|
||||
if (char == Ascii.ESC) {
|
||||
if (_queue.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_queue.consume() == Ascii.backslash) {
|
||||
_osc.add(param.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Parse next parameter
|
||||
if (char == Ascii.semicolon) {
|
||||
_osc.add(param.toString());
|
||||
param.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
param.writeCharCode(char);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Csi {
|
||||
_Csi({
|
||||
required this.params,
|
||||
required this.finalByte,
|
||||
// required this.intermediates,
|
||||
});
|
||||
_Csi({required this.params, required this.finalByte});
|
||||
|
||||
int? prefix;
|
||||
|
||||
List<int> params;
|
||||
|
||||
/// Parallel to [params]: true when that parameter was attached to its
|
||||
/// predecessor with a colon (ECMA-48 sub-parameter, ITU T.416 — T-369).
|
||||
final List<bool> subParam = [];
|
||||
|
||||
int finalByte;
|
||||
// final List<int> intermediates;
|
||||
|
||||
/// Intermediate bytes (0x20–0x2f) between the parameters and the final
|
||||
/// byte — `SP` in `CSI Ps SP q` (DECSCUSR), `!` in `CSI ! p` (DECSTR).
|
||||
/// They change the meaning of the final byte, so dispatch must not fall
|
||||
/// through to the bare-final handler when any are present.
|
||||
final List<int> intermediates = [];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
// SGR (Select Graphic Rendition) handling, including the guarded
|
||||
// extended-color (38/48) path with ITU T.416 colon sub-parameters
|
||||
// (T-369). Split out of parser.dart (T-123).
|
||||
|
||||
part of 'parser.dart';
|
||||
|
||||
mixin _SgrHandlers on _EscapeParserBase {
|
||||
/// `ESC [ [ Ps ] m` Select Graphic Rendition (SGR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sm/
|
||||
void _csiHandleSgr() {
|
||||
final params = _csi.params;
|
||||
|
||||
if (params.isEmpty) {
|
||||
return handler.resetCursorStyle();
|
||||
}
|
||||
|
||||
for (var i = 0; i < _csi.params.length; i++) {
|
||||
final param = params[i];
|
||||
switch (param) {
|
||||
case 0:
|
||||
handler.resetCursorStyle();
|
||||
continue;
|
||||
case 1:
|
||||
handler.setCursorBold();
|
||||
continue;
|
||||
case 2:
|
||||
handler.setCursorFaint();
|
||||
continue;
|
||||
case 3:
|
||||
handler.setCursorItalic();
|
||||
continue;
|
||||
case 4:
|
||||
handler.setCursorUnderline();
|
||||
continue;
|
||||
case 5:
|
||||
handler.setCursorBlink();
|
||||
continue;
|
||||
case 7:
|
||||
handler.setCursorInverse();
|
||||
continue;
|
||||
case 8:
|
||||
handler.setCursorInvisible();
|
||||
continue;
|
||||
case 9:
|
||||
handler.setCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 21:
|
||||
handler.unsetCursorBold();
|
||||
continue;
|
||||
case 22:
|
||||
handler.unsetCursorFaint();
|
||||
continue;
|
||||
case 23:
|
||||
handler.unsetCursorItalic();
|
||||
continue;
|
||||
case 24:
|
||||
handler.unsetCursorUnderline();
|
||||
continue;
|
||||
case 25:
|
||||
handler.unsetCursorBlink();
|
||||
continue;
|
||||
case 27:
|
||||
handler.unsetCursorInverse();
|
||||
continue;
|
||||
case 28:
|
||||
handler.unsetCursorInvisible();
|
||||
continue;
|
||||
case 29:
|
||||
handler.unsetCursorStrikethrough();
|
||||
continue;
|
||||
|
||||
case 30:
|
||||
handler.setForegroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 31:
|
||||
handler.setForegroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 32:
|
||||
handler.setForegroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 33:
|
||||
handler.setForegroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 34:
|
||||
handler.setForegroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 35:
|
||||
handler.setForegroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 36:
|
||||
handler.setForegroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 37:
|
||||
handler.setForegroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 38:
|
||||
i = _csiHandleExtendedColor(i, foreground: true);
|
||||
continue;
|
||||
case 39:
|
||||
handler.resetForeground();
|
||||
continue;
|
||||
|
||||
case 40:
|
||||
handler.setBackgroundColor16(NamedColor.black);
|
||||
continue;
|
||||
case 41:
|
||||
handler.setBackgroundColor16(NamedColor.red);
|
||||
continue;
|
||||
case 42:
|
||||
handler.setBackgroundColor16(NamedColor.green);
|
||||
continue;
|
||||
case 43:
|
||||
handler.setBackgroundColor16(NamedColor.yellow);
|
||||
continue;
|
||||
case 44:
|
||||
handler.setBackgroundColor16(NamedColor.blue);
|
||||
continue;
|
||||
case 45:
|
||||
handler.setBackgroundColor16(NamedColor.magenta);
|
||||
continue;
|
||||
case 46:
|
||||
handler.setBackgroundColor16(NamedColor.cyan);
|
||||
continue;
|
||||
case 47:
|
||||
handler.setBackgroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 48:
|
||||
i = _csiHandleExtendedColor(i, foreground: false);
|
||||
continue;
|
||||
case 49:
|
||||
handler.resetBackground();
|
||||
continue;
|
||||
|
||||
case 90:
|
||||
handler.setForegroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 91:
|
||||
handler.setForegroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 92:
|
||||
handler.setForegroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 93:
|
||||
handler.setForegroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 94:
|
||||
handler.setForegroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 95:
|
||||
handler.setForegroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 96:
|
||||
handler.setForegroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 97:
|
||||
handler.setForegroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
case 100:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlack);
|
||||
continue;
|
||||
case 101:
|
||||
handler.setBackgroundColor16(NamedColor.brightRed);
|
||||
continue;
|
||||
case 102:
|
||||
handler.setBackgroundColor16(NamedColor.brightGreen);
|
||||
continue;
|
||||
case 103:
|
||||
handler.setBackgroundColor16(NamedColor.brightYellow);
|
||||
continue;
|
||||
case 104:
|
||||
handler.setBackgroundColor16(NamedColor.brightBlue);
|
||||
continue;
|
||||
case 105:
|
||||
handler.setBackgroundColor16(NamedColor.brightMagenta);
|
||||
continue;
|
||||
case 106:
|
||||
handler.setBackgroundColor16(NamedColor.brightCyan);
|
||||
continue;
|
||||
case 107:
|
||||
handler.setBackgroundColor16(NamedColor.brightWhite);
|
||||
continue;
|
||||
|
||||
default:
|
||||
handler.unsupportedStyle(param);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extended fg/bg color (SGR 38/48), semicolon or colon form.
|
||||
///
|
||||
/// Returns the index of the last parameter consumed. Never reads past the
|
||||
/// end of the parameter list — a truncated sequence (`ESC [38m`,
|
||||
/// `ESC [38;2;255m`) is ignored instead of throwing; an emulator must never
|
||||
/// throw on hostile bytes (T-369). Colon-form sub-parameters per ITU T.416
|
||||
/// (`38:2:r:g:b`, `38:2:<colorspace>:r:g:b`, `38:5:n`) are treated as one
|
||||
/// logical group: parsed equivalently to the semicolon form, and dropped
|
||||
/// whole when malformed so they never spill into neighbouring parameters.
|
||||
int _csiHandleExtendedColor(int i, {required bool foreground}) {
|
||||
final params = _csi.params;
|
||||
final sub = _csi.subParam;
|
||||
|
||||
// End of the colon-linked group starting at params[i] (exclusive).
|
||||
var end = i + 1;
|
||||
while (end < params.length && sub[end]) {
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end > i + 1) {
|
||||
// Colon form. Group is params[i..end-1]; n includes the 38/48 itself.
|
||||
final n = end - i;
|
||||
final mode = params[i + 1];
|
||||
if (mode == 5 && n >= 3) {
|
||||
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
|
||||
} else if (mode == 2) {
|
||||
// A 6+ element group carries the T.416 colorspace id slot — skip it.
|
||||
final base = n >= 6 ? i + 3 : i + 2;
|
||||
if (base + 2 < end) {
|
||||
foreground
|
||||
? handler.setForegroundColorRgb(params[base], params[base + 1], params[base + 2])
|
||||
: handler.setBackgroundColorRgb(params[base], params[base + 1], params[base + 2]);
|
||||
}
|
||||
}
|
||||
return end - 1;
|
||||
}
|
||||
|
||||
// Semicolon form (legacy).
|
||||
if (i + 1 >= params.length) return i; // bare 38/48 — ignore
|
||||
switch (params[i + 1]) {
|
||||
case 2:
|
||||
if (i + 4 >= params.length) return params.length - 1; // truncated — ignore
|
||||
foreground
|
||||
? handler.setForegroundColorRgb(params[i + 2], params[i + 3], params[i + 4])
|
||||
: handler.setBackgroundColorRgb(params[i + 2], params[i + 3], params[i + 4]);
|
||||
return i + 4;
|
||||
case 5:
|
||||
if (i + 2 >= params.length) return params.length - 1; // truncated — ignore
|
||||
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
|
||||
return i + 2;
|
||||
}
|
||||
// Unknown mode — consume it so it isn't re-interpreted as an SGR code.
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Based on xterm.dart v4.0.0 by xuty (MIT). See LICENSE in this directory.
|
||||
|
||||
import 'dart:convert' show ByteConversionSink, Utf8Decoder;
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:clide/src/terminal/src/base/observable.dart';
|
||||
@@ -215,11 +216,28 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
|
||||
/// Writes the data from the underlying program to the terminal. Calling this
|
||||
/// updates the states of the terminal and emits events such as [onBell] or
|
||||
/// [onTitleChange] when the escape sequences in [data] request it.
|
||||
///
|
||||
/// Byte-stream consumers (PTY output, file tails) should use [writeBytes]
|
||||
/// instead — decoding per-chunk corrupts a multi-byte rune split across
|
||||
/// reads (T-373). This String entry point stays for tests and
|
||||
/// programmatic writes.
|
||||
void write(String data) {
|
||||
_parser.write(data);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Persistent chunked UTF-8 decoder feeding [write] — carries partial
|
||||
/// rune state across [writeBytes] calls so a glyph split across two PTY
|
||||
/// reads still renders as one glyph (T-373).
|
||||
late final ByteConversionSink _byteSink = const Utf8Decoder(allowMalformed: true).startChunkedConversion(_WriteSink(this));
|
||||
|
||||
/// Byte-stream twin of [write]: decodes UTF-8 with state retained across
|
||||
/// calls, so chunk boundaries can never split a rune into U+FFFD garbage.
|
||||
void writeBytes(List<int> bytes) {
|
||||
if (bytes.isEmpty) return;
|
||||
_byteSink.add(bytes);
|
||||
}
|
||||
|
||||
/// Sends a key event to the underlying program.
|
||||
///
|
||||
/// See also:
|
||||
@@ -863,3 +881,15 @@ class Terminal with Observable implements TerminalState, EscapeHandler {
|
||||
onPrivateOSC?.call(ps, pt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes the chunked UTF-8 decoder's output into [Terminal.write] (T-373).
|
||||
class _WriteSink implements Sink<String> {
|
||||
_WriteSink(this._terminal);
|
||||
final Terminal _terminal;
|
||||
|
||||
@override
|
||||
void add(String data) => _terminal.write(data);
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Replay-latest broadcast value holder (T-386).
|
||||
///
|
||||
/// Broadcast streams drop the current value for late subscribers — the
|
||||
/// recurring bug factory behind T-274 (status bar blank because the
|
||||
/// `system/init` event fired before the pane subscribed) and the
|
||||
/// per-site `initialData` workarounds. A [ValueStream] carries STATE,
|
||||
/// not events: every new subscriber immediately receives the latest
|
||||
/// value (when one exists), then live updates.
|
||||
///
|
||||
/// Pure Dart — usable from the IPC/daemon layer and under `dart test`.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
class ValueStream<T> {
|
||||
ValueStream();
|
||||
|
||||
ValueStream.seeded(T value) : _value = value, _hasValue = true;
|
||||
|
||||
final StreamController<T> _ctl = StreamController<T>.broadcast();
|
||||
T? _value;
|
||||
bool _hasValue = false;
|
||||
|
||||
/// Whether a value has been added (or seeded) yet. A fresh, unseeded
|
||||
/// holder replays nothing — subscribers wait for the first [add].
|
||||
bool get hasValue => _hasValue;
|
||||
|
||||
/// The latest value, or null before the first [add]. For a nullable
|
||||
/// [T], disambiguate with [hasValue].
|
||||
T? get valueOrNull => _value;
|
||||
|
||||
/// The latest value. Throws [StateError] before the first [add] —
|
||||
/// callers that can race the first value should use [valueOrNull].
|
||||
T get value {
|
||||
if (!_hasValue) throw StateError('ValueStream has no value yet');
|
||||
return _value as T;
|
||||
}
|
||||
|
||||
void add(T value) {
|
||||
_value = value;
|
||||
_hasValue = true;
|
||||
if (!_ctl.isClosed) _ctl.add(value);
|
||||
}
|
||||
|
||||
/// A stream that replays the latest value (if any) to its subscriber,
|
||||
/// then follows live updates. Each access returns a fresh
|
||||
/// single-subscription stream, so every listener gets its own replay.
|
||||
Stream<T> get stream {
|
||||
late StreamController<T> out;
|
||||
StreamSubscription<T>? sub;
|
||||
out = StreamController<T>(
|
||||
onListen: () {
|
||||
if (_hasValue) out.add(_value as T);
|
||||
if (_ctl.isClosed) {
|
||||
out.close();
|
||||
return;
|
||||
}
|
||||
sub = _ctl.stream.listen(out.add, onError: out.addError, onDone: out.close);
|
||||
},
|
||||
onPause: () => sub?.pause(),
|
||||
onResume: () => sub?.resume(),
|
||||
onCancel: () => sub?.cancel(),
|
||||
);
|
||||
return out.stream;
|
||||
}
|
||||
|
||||
bool get isClosed => _ctl.isClosed;
|
||||
|
||||
Future<void> close() => _ctl.close();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// Shared window-chrome metrics.
|
||||
///
|
||||
/// `hatHeight` used to live in clide_column_hat.dart; the per-column
|
||||
/// `ColumnHat` widget there was dead (duplicated by the hat bar in
|
||||
/// app.dart, kept alive only by a zero-coverage test) and was removed
|
||||
/// in the T-385 sweep — the constant is the part the live chrome
|
||||
/// (app.dart hat bar, menu bar) actually consumes (D-57).
|
||||
library;
|
||||
|
||||
/// Height of the per-column 24px window hats (D-57).
|
||||
const double hatHeight = 24;
|
||||
@@ -88,16 +88,24 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: kClideCardGap),
|
||||
child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens),
|
||||
);
|
||||
}
|
||||
|
||||
/// Summarized button semantics for the toggle. Scoped to the HEADER only —
|
||||
/// wrapping the whole card excluded every expanded child from the a11y
|
||||
/// tree, so a screen-reader user could expand a run and hear nothing
|
||||
/// inside it (T-370). Collapsed, the header summary IS the whole card.
|
||||
Widget _headerSemantics({required Widget child}) {
|
||||
final semanticCount = widget.counter == null ? '' : ', ${widget.counter}';
|
||||
return Semantics(
|
||||
button: true,
|
||||
expanded: _expanded,
|
||||
label: '${widget.label}$semanticCount, ${_expanded ? 'expanded' : 'collapsed'}',
|
||||
excludeSemantics: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: kClideCardGap),
|
||||
child: _expanded ? _expandedFrame(tokens) : _tickerRow(tokens),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,18 +154,20 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
||||
}
|
||||
|
||||
/// Collapsed: the ticker row IS the toggle, focusable for keyboard/AT.
|
||||
Widget _tickerRow(SurfaceTokens tokens) => ClideTappable(
|
||||
focusNode: _controlFocus,
|
||||
onTap: _toggle,
|
||||
tooltip: 'Expand',
|
||||
builder: (context, hovered, focused) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
||||
decoration: BoxDecoration(
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
||||
border: Border.all(color: widget.color ?? tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(kClideCardRadius),
|
||||
Widget _tickerRow(SurfaceTokens tokens) => _headerSemantics(
|
||||
child: ClideTappable(
|
||||
focusNode: _controlFocus,
|
||||
onTap: _toggle,
|
||||
tooltip: 'Expand',
|
||||
builder: (context, hovered, focused) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: kClideCardHeaderPadH, vertical: kClideCardHeaderPadV),
|
||||
decoration: BoxDecoration(
|
||||
color: (hovered || focused) ? tokens.listItemHoverBackground : tokens.listItemBackground,
|
||||
border: Border.all(color: widget.color ?? tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(kClideCardRadius),
|
||||
),
|
||||
child: _headerContent(tokens, expanded: false),
|
||||
),
|
||||
child: _headerContent(tokens, expanded: false),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -172,17 +182,19 @@ class _ClideCollapserCardState extends State<ClideCollapserCard> {
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background toggle: behind the items, not a whole-card overlay, so
|
||||
// item taps are never intercepted. Excluded from focus traversal —
|
||||
// the header caret is the single keyboard stop.
|
||||
// item taps are never intercepted. Excluded from focus traversal AND
|
||||
// semantics — the header caret is the single keyboard/AT stop.
|
||||
Positioned.fill(
|
||||
child: ExcludeFocus(
|
||||
child: ClideTappable(onTap: _toggle, tooltip: 'Collapse', builder: (_, _, _) => const SizedBox.expand()),
|
||||
child: ExcludeSemantics(
|
||||
child: ClideTappable(onTap: _toggle, tooltip: 'Collapse', builder: (_, _, _) => const SizedBox.expand()),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_headerRow(tokens),
|
||||
_headerSemantics(child: _headerRow(tokens)),
|
||||
// Even padding around the inner item canvas (T-305): the sides +
|
||||
// top match, and each inner item carries a matching bottom margin
|
||||
// (so the last item's margin is the bottom inset and items in a
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart' show clideName;
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/kernel/src/window_controls.dart';
|
||||
import 'package:clide/widgets/src/clide_icon.dart';
|
||||
import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:clide/widgets/src/clide_text.dart';
|
||||
import 'package:clide/widgets/src/icons/phosphor.dart';
|
||||
import 'package:clide/widgets/src/typography.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const double hatHeight = 24;
|
||||
|
||||
class ColumnHat extends StatelessWidget {
|
||||
const ColumnHat._({required this.position, required this.windowControls, this.projectLabel, this.branchLabel});
|
||||
|
||||
final HatPosition position;
|
||||
final WindowControls windowControls;
|
||||
final String? projectLabel;
|
||||
final String? branchLabel;
|
||||
|
||||
factory ColumnHat.left({required WindowControls windowControls}) => ColumnHat._(position: HatPosition.left, windowControls: windowControls);
|
||||
|
||||
factory ColumnHat.center({required WindowControls windowControls, String? project, String? branch}) =>
|
||||
ColumnHat._(position: HatPosition.center, windowControls: windowControls, projectLabel: project, branchLabel: branch);
|
||||
|
||||
factory ColumnHat.right({required WindowControls windowControls}) => ColumnHat._(position: HatPosition.right, windowControls: windowControls);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => windowControls.startDrag(),
|
||||
child: Container(
|
||||
height: hatHeight,
|
||||
color: tokens.panelHeader,
|
||||
child: switch (position) {
|
||||
HatPosition.left => _LeftContent(tokens: tokens, wc: windowControls),
|
||||
HatPosition.center => _CenterContent(tokens: tokens, project: projectLabel, branch: branchLabel),
|
||||
HatPosition.right => _RightContent(tokens: tokens, wc: windowControls),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum HatPosition { left, center, right }
|
||||
|
||||
class _LeftContent extends StatelessWidget {
|
||||
const _LeftContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// On macOS the native titlebar draws traffic lights; skip duplicates.
|
||||
return const SizedBox.expand();
|
||||
}
|
||||
}
|
||||
|
||||
class _CenterContent extends StatelessWidget {
|
||||
const _CenterContent({required this.tokens, this.project, this.branch});
|
||||
final SurfaceTokens tokens;
|
||||
final String? project;
|
||||
final String? branch;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parts = <String>[];
|
||||
if (project != null) parts.add(project!);
|
||||
if (branch != null) parts.add(branch!);
|
||||
final label = parts.isEmpty ? clideName : parts.join(' > ');
|
||||
return Center(
|
||||
child: ClideText(label, fontSize: 12, color: tokens.globalTextMuted, fontFamily: clideMonoFamily),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RightContent extends StatelessWidget {
|
||||
const _RightContent({required this.tokens, required this.wc});
|
||||
final SurfaceTokens tokens;
|
||||
final WindowControls wc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kIsWeb) return const SizedBox.expand();
|
||||
final isMac = !kIsWeb && Platform.isMacOS;
|
||||
if (isMac) return const SizedBox.expand();
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
_WinButton(icon: const PhosphorIconPainter(0xe32a), onTap: wc.minimize, tokens: tokens),
|
||||
_WinButton(icon: const PhosphorIconPainter(0xe45e), onTap: wc.toggleMaximize, tokens: tokens),
|
||||
_WinButton(icon: PhosphorIcons.byName('x'), onTap: wc.close, tokens: tokens, isClose: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WinButton extends StatelessWidget {
|
||||
const _WinButton({required this.icon, required this.onTap, required this.tokens, this.isClose = false});
|
||||
final ClideIconPainter icon;
|
||||
final VoidCallback onTap;
|
||||
final SurfaceTokens tokens;
|
||||
final bool isClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hoverBg = isClose ? tokens.windowControlCloseHoverBackground : tokens.listItemHoverBackground;
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
width: 36,
|
||||
height: hatHeight,
|
||||
color: hovered ? hoverBg : null,
|
||||
alignment: Alignment.center,
|
||||
child: ClideIcon(icon, size: 14, color: hovered && isClose ? tokens.windowControlCloseHoverForeground : tokens.globalTextMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,16 @@ import 'package:clide/widgets/src/clide_tappable.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ClideIconRailItem {
|
||||
const ClideIconRailItem({required this.id, required this.icon, required this.tooltip});
|
||||
const ClideIconRailItem({required this.id, required this.icon, required this.tooltip, this.iconColor});
|
||||
|
||||
final String id;
|
||||
final ClideIconPainter icon;
|
||||
final String tooltip;
|
||||
|
||||
/// Brand/identity tint for this tab's icon (e.g. the Claude accent on the
|
||||
/// Claude tab, T-418). Shown full-strength when active/hovered and slightly
|
||||
/// dimmed when idle; null keeps the normal state colours.
|
||||
final Color? iconColor;
|
||||
}
|
||||
|
||||
class ClideIconRail extends StatelessWidget {
|
||||
@@ -60,7 +65,10 @@ class _RailButton extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
tooltip: item.tooltip,
|
||||
builder: (ctx, hovered, _) {
|
||||
final color = active
|
||||
final tint = item.iconColor;
|
||||
final color = tint != null
|
||||
? (active || hovered ? tint : tint.withValues(alpha: 0.7))
|
||||
: active
|
||||
? tokens.globalForeground
|
||||
: hovered
|
||||
? tokens.sidebarForeground
|
||||
|
||||
@@ -405,6 +405,22 @@ class ClideMarkdown extends StatelessWidget {
|
||||
text: _unescapeHtml(el.textContent),
|
||||
style: TextStyle(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted),
|
||||
);
|
||||
case 'br':
|
||||
// A hard break has no textContent — the default branch rendered it
|
||||
// as an empty span and glued the surrounding words together (T-379).
|
||||
return const TextSpan(text: '\n');
|
||||
case 'img':
|
||||
// No inline image loading (network fetch in a text span is not the
|
||||
// owned-renderer way; live-pane images go through `clide image
|
||||
// show`) — render a visible alt-text placeholder instead of
|
||||
// disappearing (T-379).
|
||||
final alt = _unescapeHtml(el.attributes['alt'] ?? '');
|
||||
final src = el.attributes['src'] ?? '';
|
||||
final label = alt.isNotEmpty ? alt : src;
|
||||
return TextSpan(
|
||||
text: label.isEmpty ? '[image]' : '[image: $label]',
|
||||
style: TextStyle(color: tokens.globalTextMuted, fontStyle: FontStyle.italic),
|
||||
);
|
||||
default:
|
||||
return TextSpan(text: _unescapeHtml(el.textContent));
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
/// Flutter chrome widgets directly.
|
||||
library;
|
||||
|
||||
export 'src/chrome_metrics.dart';
|
||||
export 'src/clide_accordion.dart';
|
||||
export 'src/clide_anchored.dart';
|
||||
export 'src/clide_button.dart';
|
||||
export 'src/clide_card_metrics.dart';
|
||||
export 'src/clide_collapser_card.dart';
|
||||
export 'src/clide_column_hat.dart';
|
||||
export 'src/clide_code_block.dart';
|
||||
export 'src/clide_divider.dart';
|
||||
export 'src/clide_file_image.dart';
|
||||
|
||||
Reference in New Issue
Block a user