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
@@ -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)),
],
);
}