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:
2026-06-14 18:21:41 +02:00
co-authored by Claude Opus 4.8
152 changed files with 13684 additions and 4364 deletions
+11 -1151
View File
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);
+15 -23
View File
@@ -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
+288 -16
View File
@@ -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,
+38
View File
@@ -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;
}
+179 -4
View File
@@ -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,
),
],
);
+37 -17
View File
@@ -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();
+110 -2
View File
@@ -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
+278 -21
View File
@@ -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();
}
}
+28 -6
View File
@@ -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,
),
);
}
+234
View File
@@ -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();
+8
View File
@@ -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) {
+101 -23
View File
@@ -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: [
-113
View File
@@ -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),
],
),
),
);
}
}
+22 -18
View File
@@ -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
+13 -9
View File
@@ -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)),
],
);
}
+2
View File
@@ -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
+4
View File
@@ -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;
+2
View File
@@ -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';
+43
View File
@@ -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) {
+3 -3
View File
@@ -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
+53 -46
View File
@@ -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();
+67
View File
@@ -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(),
+19 -2
View File
@@ -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
+46 -23
View File
@@ -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;
}
}
+122
View File
@@ -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);
}
}
+25
View File
@@ -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();
}
+5
View File
@@ -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;
+33 -1
View File
@@ -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;
+42 -7
View File
@@ -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()}"');
}
-47
View File
@@ -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;
}
}
+64
View File
@@ -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
View File
@@ -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
+19 -1
View File
@@ -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});
}
+9
View File
@@ -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.
+6 -2
View File
@@ -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.
+11 -5
View File
@@ -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
View File
@@ -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);
}
}
+52 -1
View File
@@ -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
View File
@@ -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 {
+73
View File
@@ -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
View File
@@ -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);
}
}
+5
View File
@@ -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);
+8 -5
View File
@@ -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'(?:.*/)?');
+6
View File
@@ -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);
}
+101
View File
@@ -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),
),
);
}
}
+270
View File
@@ -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),
],
),
);
},
);
}
}
+245
View File
@@ -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),
],
),
),
);
}
}
+306
View File
@@ -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());
},
);
}
}
+372
View File
@@ -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);
}
}
}
+72 -827
View File
@@ -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 (0x200x2f) 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;
}
}
+30
View File
@@ -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() {}
}
+70
View File
@@ -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();
}
+11
View File
@@ -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;
+31 -19
View File
@@ -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
-124
View File
@@ -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),
),
);
}
}
+10 -2
View File
@@ -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
+16
View File
@@ -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));
}
+1 -1
View File
@@ -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';