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:
@@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
|
||||
// breaks the cluster at every level, including L3, so parallel agents
|
||||
// never merge into one Activity card.
|
||||
if (isAgentTool(name)) return false;
|
||||
// A Workflow run is a first-class orchestration card too (T-416): it owns
|
||||
// the live agent fan-out, so it never folds into a generic Activity card.
|
||||
if (name == 'Workflow') return false;
|
||||
// The Edit/Write call stays first-class with its diff at L1/L2.
|
||||
if (level == FoldLevel.everything) return true;
|
||||
return !isDiffTool(name);
|
||||
|
||||
@@ -145,29 +145,14 @@ typedef ClaudeInitProbe = Future<String?> Function();
|
||||
/// Returns a change stream for [dir] (fires on any file event under it).
|
||||
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
|
||||
|
||||
/// Modest version-agnostic fallback used when the probe is unavailable, so
|
||||
/// the typeahead still offers the common built-ins.
|
||||
const List<String> kFallbackSlashCommands = [
|
||||
'add-dir',
|
||||
'agents',
|
||||
'clear',
|
||||
'compact',
|
||||
'config',
|
||||
'context',
|
||||
'cost',
|
||||
'doctor',
|
||||
'exit',
|
||||
'help',
|
||||
'init',
|
||||
'mcp',
|
||||
'memory',
|
||||
'model',
|
||||
'permissions',
|
||||
'resume',
|
||||
'review',
|
||||
'status',
|
||||
'usage',
|
||||
];
|
||||
/// Fallback used when the probe is unavailable. Mirrors the builtins a real
|
||||
/// CLI advertises in its stream-json `initialize` handshake (probed against
|
||||
/// 2.1.175) — i.e. the ones that genuinely work headless. It deliberately
|
||||
/// does NOT list TUI-only commands (config, permissions, status, doctor, …):
|
||||
/// this list doubles as the router's "advertised" set (T-411), and a TUI-only
|
||||
/// token here would be forwarded to the CLI and error. The composer unions
|
||||
/// [kClideOwnedCommands] on top for the typeahead (T-162).
|
||||
const List<String> kFallbackSlashCommands = ['clear', 'compact', 'context', 'init', 'review', 'security-review', 'usage'];
|
||||
|
||||
class ClaudeConfig extends ChangeNotifier {
|
||||
ClaudeConfig({
|
||||
@@ -253,6 +238,7 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
_version = _parseVersion(await _guard(_versionRunner));
|
||||
await _readProbeCache();
|
||||
await _loadDiskConfig();
|
||||
if (_disposed) return; // activation fired-and-forgot; teardown won
|
||||
_startWatchers();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -296,8 +282,14 @@ class ClaudeConfig extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set when [dispose] runs. The fire-and-forget [load] from extension
|
||||
/// activation checks this so a teardown racing an in-flight load can't
|
||||
/// notify (or start watchers on) a disposed notifier.
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_stopWatching();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import 'clipboard_paste.dart';
|
||||
import 'activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey;
|
||||
import 'conversation_controller.dart';
|
||||
import 'conversation_view.dart';
|
||||
import 'model_picker_card.dart';
|
||||
import 'permission_mode_control.dart';
|
||||
import 'prompt_card.dart';
|
||||
import 'session_index.dart';
|
||||
@@ -24,6 +25,7 @@ import 'slash_commands.dart';
|
||||
import 'stream_json_session.dart';
|
||||
import 'task_list.dart';
|
||||
import 'transcript_reader.dart';
|
||||
import 'workflow_run.dart';
|
||||
|
||||
/// The Claude conversation pane. Drives `claude` over the stream-json control
|
||||
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
|
||||
@@ -71,7 +73,11 @@ class ClaudePane extends StatefulWidget {
|
||||
|
||||
class _ClaudePaneState extends State<ClaudePane> {
|
||||
StreamSubscription<SessionStatus>? _statusSub;
|
||||
StreamSubscription<SessionEnd>? _endSub;
|
||||
StreamSubscription<ProjectOpened>? _projectSub;
|
||||
StreamSubscription<Message>? _commandSub;
|
||||
StreamSubscription<String>? _modelErrorSub;
|
||||
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
|
||||
ConversationController? _conversation;
|
||||
StreamJsonSession? _session;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
@@ -80,6 +86,21 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
String? _error;
|
||||
String _statusLine = 'starting…';
|
||||
|
||||
/// One-shot fork source: seeds the first bind, then cleared so /clear,
|
||||
/// /resume, and respawns operate on this pane's own session (T-375).
|
||||
late String? _forkSource = widget.forkSourceId;
|
||||
|
||||
/// Whether a bare `/model` opened the picker in the interaction zone
|
||||
/// (T-408). An open prompt takes precedence; the picker shows once it
|
||||
/// resolves.
|
||||
bool _modelPickerOpen = false;
|
||||
bool _effortPickerOpen = false;
|
||||
bool _permissionPickerOpen = false;
|
||||
|
||||
/// Effort level this pane's session runs at (`--effort`, T-412). Null =
|
||||
/// the CLI default. Set by /effort; carried by every respawn.
|
||||
String? _effort;
|
||||
|
||||
bool _spawned = false;
|
||||
|
||||
/// Per-session composer draft (text + caret), held here so an unsent
|
||||
@@ -141,6 +162,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Cache the kernel for dispose() — ancestor lookups there are illegal,
|
||||
// and the old lookup-and-swallow leaked the settings listener on every
|
||||
// disposed pane (T-366).
|
||||
_kernel = ClideKernel.of(context);
|
||||
// Spawn once, after the kernel is available.
|
||||
if (!_spawned) {
|
||||
_spawned = true;
|
||||
@@ -155,6 +180,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
// GlobalKey and spawns once, so without this it would keep the previous
|
||||
// repo's session after a switch (T-269).
|
||||
_projectSub = ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
|
||||
// Sidebar controls (and any future surface) drive this pane's session by
|
||||
// publishing slash-command text on builtin.claude/command (T-414) —
|
||||
// executed through the exact _send routing the composer uses, so the
|
||||
// control and the typed command are one code path (D-6). Only the
|
||||
// primary pane listens: the controls target the primary session, and a
|
||||
// second listener would double-execute.
|
||||
if (widget.isPrimary) {
|
||||
_commandSub = ClideKernel.of(context).messages.subscribe(publisher: 'builtin.claude', channel: 'command').listen((msg) {
|
||||
final text = msg.data['text'] as String?;
|
||||
if (text != null && text.isNotEmpty) _send(text);
|
||||
});
|
||||
}
|
||||
// Re-fold the conversation when the activity fold-level setting changes
|
||||
// (claude.activity.fold-level command, T-235).
|
||||
ClideKernel.of(context).settings.addListener(_onSettingsChanged);
|
||||
@@ -168,11 +205,18 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
@override
|
||||
void dispose() {
|
||||
activeClaudeConfig?.removeListener(_onConfigChanged);
|
||||
_kernel()?.settings.removeListener(_onSettingsChanged);
|
||||
_kernel?.settings.removeListener(_onSettingsChanged);
|
||||
_projectSub?.cancel();
|
||||
_commandSub?.cancel();
|
||||
_projectSub = null;
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
// The orchestrator owns the session, so disposing this pane does NOT kill
|
||||
// it — that's what lets a hidden/kept-alive pane keep its session (T-169).
|
||||
// A secondary tab being *closed* is a real teardown, so close its session;
|
||||
@@ -224,6 +268,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
Future<void> _rebindToActiveProject() async {
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
_modelPickerOpen = false;
|
||||
_effortPickerOpen = false;
|
||||
_permissionPickerOpen = false;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old repo's session
|
||||
_conversation = null;
|
||||
_session = null;
|
||||
@@ -263,7 +316,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
}
|
||||
|
||||
final ManagedSession managed;
|
||||
final forkSource = widget.forkSourceId;
|
||||
final forkSource = _forkSource;
|
||||
if (forkSource != null) {
|
||||
// Fork pane: branch source session into a new clide-managed session.
|
||||
// The clide-internal id is a fresh UUID; the real claude session id is
|
||||
@@ -271,12 +324,23 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
_sessionId ??= freshSessionId();
|
||||
try {
|
||||
managed = await orch.spawn(
|
||||
SpawnSpec(id: _orchId, role: 'fork ${widget.secondaryIndex}', sessionId: _sessionId!, cwd: repoRoot, forkSourceSessionId: forkSource),
|
||||
SpawnSpec(
|
||||
id: _orchId,
|
||||
role: 'fork ${widget.secondaryIndex}',
|
||||
sessionId: _sessionId!,
|
||||
cwd: repoRoot,
|
||||
forkSourceSessionId: forkSource,
|
||||
effort: _effort,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Could not start fork: $e');
|
||||
return;
|
||||
}
|
||||
// One-shot: the fork source seeds only the FIRST bind. Leaving it set
|
||||
// made /clear re-fork the original conversation instead of clearing —
|
||||
// every later respawn must operate on this pane's own session (T-375).
|
||||
_forkSource = null;
|
||||
if (!mounted) return;
|
||||
setState(() => _statusLine = 'fork of $forkSource');
|
||||
} else {
|
||||
@@ -298,6 +362,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
cwd: repoRoot,
|
||||
resume: resume,
|
||||
transcriptPath: resume ? transcriptFile : null,
|
||||
effort: _effort,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -310,11 +375,14 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
_session = managed.session;
|
||||
_conversation = managed.conversation;
|
||||
// The wire never reports effort — record what this session was spawned
|
||||
// with so the status line / sidebar can show it (T-412).
|
||||
if (_effort != null) managed.session.noteEffort(_effort!);
|
||||
// Diagnostic (T-274 follow-up): record how this pane bound its session —
|
||||
// a fresh spawn vs connecting to existing on-disk history (the seed read
|
||||
// from the transcript/sidecar). Surfaces the resume path in `make run`.
|
||||
final seeded = _conversation?.items.length ?? 0;
|
||||
_kernel()?.log.info(
|
||||
_kernel?.log.info(
|
||||
'claude',
|
||||
'pane $_orchId bound session ${_sessionId ?? '?'} in $repoRoot — '
|
||||
'${seeded > 0 ? 'connected to history ($seeded seeded item(s))' : 'fresh session (no history)'}',
|
||||
@@ -323,6 +391,36 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
if (!mounted) return;
|
||||
setState(() => _status = s);
|
||||
});
|
||||
// Workflow runs arrive on out-of-band system events that add no
|
||||
// conversation item, so the view won't rebuild on its own — drive a
|
||||
// rebuild as the run map changes so the workflow card updates live (T-416).
|
||||
_workflowsSub = managed.session.workflowsStream.listen((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
});
|
||||
// A rejected /model change (unknown name) rolls back silently in the
|
||||
// status — say why out loud (T-408).
|
||||
_modelErrorSub = managed.session.modelErrors.listen((msg) {
|
||||
_kernel?.notify.warn(msg, title: 'model');
|
||||
});
|
||||
// Surface a dead process instead of letting it look thoughtful (T-361):
|
||||
// late binders read the replayed end; live sessions stream it.
|
||||
final alreadyEnded = managed.session.end;
|
||||
if (alreadyEnded != null) {
|
||||
_onSessionEnd(alreadyEnded);
|
||||
} else {
|
||||
_endSub = managed.session.endedStream.listen(_onSessionEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/// The claude process exited under this pane's live session. Stop looking
|
||||
/// busy, say so in the status line, and log the drained stderr tail —
|
||||
/// the diagnostics that used to vanish (T-361).
|
||||
void _onSessionEnd(SessionEnd end) {
|
||||
if (!mounted) return;
|
||||
final tail = end.stderrTail.isEmpty ? '' : '; stderr tail:\n${end.stderrTail.join('\n')}';
|
||||
_kernel?.log.warn('claude', 'session $_orchId exited (code ${end.exitCode})$tail');
|
||||
setState(() => _statusLine = 'claude exited (code ${end.exitCode}) — /clear to restart');
|
||||
}
|
||||
|
||||
// Send composed text to Claude over the stream-json channel. Commands clide
|
||||
@@ -342,10 +440,152 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
case 'fork':
|
||||
_forkSession();
|
||||
return;
|
||||
case 'model':
|
||||
_modelCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'effort':
|
||||
_effortCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'permissions':
|
||||
_permissionsCommand(slashCommandArg(text) ?? '');
|
||||
return;
|
||||
case 'status':
|
||||
_openMetaTab('activity');
|
||||
return;
|
||||
case 'config':
|
||||
case 'mcp':
|
||||
case 'agents':
|
||||
case 'hooks':
|
||||
_openMetaTab('config');
|
||||
return;
|
||||
case 'memory':
|
||||
_openMemory();
|
||||
return;
|
||||
case 'help':
|
||||
_helpCommand();
|
||||
return;
|
||||
}
|
||||
// Route the rest (T-411): a known TUI-only builtin never reaches the
|
||||
// session — forwarded it would error (or, un-advertised, bracket-paste to
|
||||
// the model as literal text, burning a turn). It becomes a local notice
|
||||
// card pointing at the clide-native way instead.
|
||||
final advertised = activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands;
|
||||
if (routeSlashCommand(text, advertised: advertised) == SlashRoute.unavailable) {
|
||||
_session?.addLocalNotice(tuiOnlyNotice(slashCommandToken(text)!));
|
||||
return;
|
||||
}
|
||||
_session?.send(text);
|
||||
}
|
||||
|
||||
/// clide-owned `/model` (T-408): with an argument, set the model directly;
|
||||
/// bare, open the picker in the interaction zone (D-78).
|
||||
void _modelCommand(String arg) {
|
||||
if (_session == null) return;
|
||||
if (arg.isNotEmpty) {
|
||||
_session!.setModel(arg);
|
||||
return;
|
||||
}
|
||||
setState(() => _modelPickerOpen = true);
|
||||
}
|
||||
|
||||
void _pickModel(String value) {
|
||||
_session?.setModel(value);
|
||||
_closeModelPicker();
|
||||
}
|
||||
|
||||
void _closeModelPicker() {
|
||||
setState(() => _modelPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
/// clide-owned `/effort` (T-412): with a level, respawn-with-resume carrying
|
||||
/// `--effort`; bare, open the picker. No set_effort control subtype exists
|
||||
/// (probed 2.1.175), so the respawn IS the mechanism — resume keeps the
|
||||
/// conversation, only the process restarts.
|
||||
void _effortCommand(String arg) {
|
||||
if (_session == null) return;
|
||||
if (arg.isEmpty) {
|
||||
setState(() => _effortPickerOpen = true);
|
||||
return;
|
||||
}
|
||||
if (!kEffortLevels.any((l) => l.value == arg)) {
|
||||
_session!.addLocalNotice('unknown effort "$arg" — levels: ${kEffortLevels.map((l) => l.value).join(', ')}');
|
||||
return;
|
||||
}
|
||||
_setEffort(arg);
|
||||
}
|
||||
|
||||
void _pickEffort(String value) {
|
||||
_closeEffortPicker();
|
||||
_setEffort(value);
|
||||
}
|
||||
|
||||
void _closeEffortPicker() {
|
||||
setState(() => _effortPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
void _setEffort(String level) {
|
||||
final sid = _sessionId;
|
||||
if (sid == null) return;
|
||||
_effort = level;
|
||||
_kernel?.notify.info('effort $level — restarting the session to apply', title: 'effort');
|
||||
unawaited(_respawnWithSession(sid));
|
||||
}
|
||||
|
||||
/// clide-owned `/permissions` (T-413): with a mode, set it directly over
|
||||
/// set_permission_mode; bare, open a picker — the same interaction-zone
|
||||
/// pattern as /model and /effort.
|
||||
void _permissionsCommand(String arg) {
|
||||
final s = _session;
|
||||
if (s == null) return;
|
||||
if (arg.isEmpty) {
|
||||
setState(() => _permissionPickerOpen = true);
|
||||
return;
|
||||
}
|
||||
if (!kPermissionModes.any((m) => m.value == arg)) {
|
||||
s.addLocalNotice('unknown permission mode "$arg" — modes: ${kPermissionModes.map((m) => m.value).join(', ')}');
|
||||
return;
|
||||
}
|
||||
s.setPermissionMode(arg);
|
||||
}
|
||||
|
||||
void _pickPermissionMode(String value) {
|
||||
_closePermissionPicker();
|
||||
_session?.setPermissionMode(value);
|
||||
}
|
||||
|
||||
void _closePermissionPicker() {
|
||||
setState(() => _permissionPickerOpen = false);
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
/// Navigate to the Claude sidebar and select a sub-tab (T-413): the
|
||||
/// /status//config//mcp//agents//hooks commands land here.
|
||||
void _openMetaTab(String tab) {
|
||||
final k = _kernel;
|
||||
if (k == null) return;
|
||||
k.panels.activateTab(Slots.sidebar, 'claude.meta');
|
||||
k.messages.publish('builtin.claude', 'meta.tab', {'tab': tab});
|
||||
}
|
||||
|
||||
/// clide-owned `/memory` (T-413): open the workspace CLAUDE.md in the editor.
|
||||
void _openMemory() {
|
||||
final root = _repoRoot;
|
||||
if (root == null) return;
|
||||
unawaited(_ipc()?.request('editor.open', args: {'path': '$root/CLAUDE.md'}));
|
||||
}
|
||||
|
||||
/// clide-owned `/help` (T-413): a local summary card — never the CLI's TUI
|
||||
/// help, which doesn't exist headless.
|
||||
void _helpCommand() {
|
||||
final advertised = (activeClaudeConfig?.slashCommands ?? kFallbackSlashCommands).where((c) => !kClideOwnedCommands.contains(c)).toList()..sort();
|
||||
_session?.addLocalNotice(
|
||||
'clide commands: ${(kClideOwnedCommands.toList()..sort()).map((c) => '/$c').join(' ')}\n'
|
||||
'claude commands & skills: ${advertised.map((c) => '/$c').join(' ')}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Record a submitted prompt in the active session's history (T-163),
|
||||
/// de-duping immediate repeats. Empty/whitespace prompts are skipped.
|
||||
void _appendHistory(String text) {
|
||||
@@ -370,7 +610,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
/// background tap must never pull focus from (or resurrect) the composer
|
||||
/// over an open prompt.
|
||||
void _focusComposerOnTap() {
|
||||
if (_session?.pendingPrompt != null) return;
|
||||
if (_session?.pendingPrompt != null || _modelPickerOpen || _effortPickerOpen || _permissionPickerOpen) return;
|
||||
_composerFocus.requestFocus();
|
||||
}
|
||||
|
||||
@@ -422,7 +662,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
/// re-bind the pane to it.
|
||||
Future<void> _resumeFlow() async {
|
||||
final root = _repoRoot;
|
||||
final dialog = _kernel()?.dialog;
|
||||
final dialog = _kernel?.dialog;
|
||||
if (root == null || dialog == null) return;
|
||||
final dir = Directory(claudeProjectDir(root));
|
||||
final sessions = await listSessions(dir);
|
||||
@@ -440,6 +680,15 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
Future<void> _respawnWithSession(String sessionId, {bool clearTranscript = false}) async {
|
||||
_statusSub?.cancel();
|
||||
_statusSub = null;
|
||||
_endSub?.cancel();
|
||||
_endSub = null;
|
||||
_modelErrorSub?.cancel();
|
||||
_modelErrorSub = null;
|
||||
_workflowsSub?.cancel();
|
||||
_workflowsSub = null;
|
||||
_modelPickerOpen = false;
|
||||
_effortPickerOpen = false;
|
||||
_permissionPickerOpen = false;
|
||||
await activeSessionOrchestrator?.close(_orchId); // kills the old session
|
||||
// Erase only after the process is dead, so claude isn't mid-write.
|
||||
final root = _repoRoot;
|
||||
@@ -455,15 +704,10 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
|
||||
// -- helpers --------------------------------------------------------------
|
||||
|
||||
DaemonClient? _ipc() => _kernel()?.ipc;
|
||||
DaemonClient? _ipc() => _kernel?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
try {
|
||||
return ClideKernel.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/// Cached in didChangeDependencies (T-366); see note there.
|
||||
KernelServices? _kernel;
|
||||
|
||||
// -- build ----------------------------------------------------------------
|
||||
|
||||
@@ -496,10 +740,11 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
onTap: _focusComposerOnTap,
|
||||
child: ConversationView(
|
||||
controller: _conversation!,
|
||||
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
|
||||
foldLevel: foldLevelFromName(_kernel?.settings.get<String>(kActivityFoldLevelKey)),
|
||||
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
||||
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
||||
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
|
||||
emptyState: ClaudeBanner(
|
||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||
workspace: _repoRoot,
|
||||
@@ -516,9 +761,36 @@ class _ClaudePaneState extends State<ClaudePane> {
|
||||
),
|
||||
// An open prompt takes the composer's space and hides the text
|
||||
// input until it's answered, so interaction stays out of the
|
||||
// conversation stream (D-78).
|
||||
// conversation stream (D-78). The /model picker uses the same
|
||||
// slot; a prompt outranks it (T-408).
|
||||
if (prompt != null && _session != null)
|
||||
ToolPromptCard(prompt: prompt, onResolve: _session!.resolvePrompt)
|
||||
else if (_modelPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
models: _session!.availableModels.isEmpty ? kFallbackModels : _session!.availableModels,
|
||||
currentModel: _status.model,
|
||||
onPick: _pickModel,
|
||||
onCancel: _closeModelPicker,
|
||||
)
|
||||
else if (_effortPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
title: 'effort',
|
||||
models: kEffortLevels,
|
||||
currentModel: _status.effort,
|
||||
// Exact match — containment would mark `high` inside `xhigh`.
|
||||
isCurrent: (o, c) => c != null && o.value == c,
|
||||
onPick: _pickEffort,
|
||||
onCancel: _closeEffortPicker,
|
||||
)
|
||||
else if (_permissionPickerOpen && _session != null)
|
||||
ModelPickerCard(
|
||||
title: 'permissions',
|
||||
models: kPermissionModes,
|
||||
currentModel: _status.permissionMode,
|
||||
isCurrent: (o, c) => c != null && o.value == c,
|
||||
onPick: _pickPermissionMode,
|
||||
onCancel: _closePermissionPicker,
|
||||
)
|
||||
else
|
||||
StreamBuilder<bool>(
|
||||
stream: _session?.busyStream,
|
||||
|
||||
@@ -92,3 +92,41 @@ String formatTokenCount(int n) {
|
||||
if (n >= 1000) return '${(n / 1000).round()}k';
|
||||
return '$n';
|
||||
}
|
||||
|
||||
/// Parsed `/usage` output (T-415). The CLI answers a forwarded `/usage`
|
||||
/// headless and free (probed 2.1.175, num_turns 0) with plain text:
|
||||
///
|
||||
/// Current session: 15% used · resets Jun 12, 3:39pm (Europe/Amsterdam)
|
||||
/// Current week (all models): 53% used · resets Jun 15, 6:59pm (…)
|
||||
/// Current week (Sonnet only): 0% used
|
||||
class ClaudeUsage {
|
||||
const ClaudeUsage({this.session, this.week, this.weekSonnet});
|
||||
|
||||
/// The value text per line (e.g. `15% used · resets Jun 12, 3:39pm`),
|
||||
/// timezone parenthetical stripped. Null when the line wasn't present.
|
||||
final String? session;
|
||||
final String? week;
|
||||
final String? weekSonnet;
|
||||
|
||||
bool get isEmpty => session == null && week == null && weekSonnet == null;
|
||||
}
|
||||
|
||||
/// Parse `/usage` response text into a [ClaudeUsage], or null when [text]
|
||||
/// isn't usage output. Tolerant of label drift: any `Current …: …% used`
|
||||
/// line is matched by its key phrase.
|
||||
ClaudeUsage? parseUsageText(String text) {
|
||||
if (!text.contains('% used')) return null;
|
||||
String? valueOf(String keyPhrase) {
|
||||
for (final line in text.split('\n')) {
|
||||
if (!line.contains(keyPhrase)) continue;
|
||||
final colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
// Strip the trailing timezone parenthetical — noise at sidebar width.
|
||||
return line.substring(colon + 1).replaceAll(RegExp(r'\s*\([^)]*\)\s*$'), '').trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final usage = ClaudeUsage(session: valueOf('Current session'), week: valueOf('(all models)'), weekSonnet: valueOf('(Sonnet only)'));
|
||||
return usage.isEmpty ? null : usage;
|
||||
}
|
||||
|
||||
@@ -15,13 +15,17 @@ import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart';
|
||||
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/conversation_card.dart';
|
||||
import 'package:clide/builtin/claude/src/conversation_controller.dart';
|
||||
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
|
||||
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
|
||||
import 'package:clide/builtin/claude/src/prompt_card.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/src/facade.dart';
|
||||
import 'package:clide/kernel/src/keymap/intents.dart';
|
||||
import 'package:clide/kernel/src/keymap/pane_key_nav.dart';
|
||||
import 'package:clide/kernel/src/syntax/language_map.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
@@ -38,11 +42,18 @@ class ConversationView extends StatefulWidget {
|
||||
this.hiddenToolUseIds = const <String>{},
|
||||
this.toolUseOutcomes = const <String, bool>{},
|
||||
this.quietErrorToolUseIds = const <String>{},
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
this.foldLevel = FoldLevel.tools,
|
||||
});
|
||||
|
||||
final ConversationController controller;
|
||||
|
||||
/// Live Workflow runs keyed by their launching `Workflow` tool-use id
|
||||
/// (T-416). A `Workflow` tool-use card with a matching run renders the
|
||||
/// dedicated run card (phases, agent rows, status) instead of the generic
|
||||
/// tool card; absent (pre-progress, or on reload) it falls back to generic.
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
/// How aggressively consecutive meta items (tool calls/results, thinking)
|
||||
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
|
||||
final FoldLevel foldLevel;
|
||||
@@ -268,9 +279,13 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
void _onChanged() {
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
// Follow the tail — jump to the bottom after the new item lays out.
|
||||
// Follow the tail — but only when already pinned to it. New items arrive
|
||||
// on every streamed token; jumping unconditionally yanks a reader who
|
||||
// scrolled up back to the bottom for the whole reply (T-368, twin of the
|
||||
// T-297 resize gate).
|
||||
if (!_atBottom) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scroll.hasClients) {
|
||||
if (_scroll.hasClients && _atBottom) {
|
||||
_scroll.jumpTo(_scroll.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
@@ -328,6 +343,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
workflows: widget.workflows,
|
||||
),
|
||||
FoldedCluster(:final items) => _ActivityCard(
|
||||
key: ValueKey('cluster.${items.first.uuid}'),
|
||||
@@ -339,6 +355,7 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: fold.promptsByToolUseId,
|
||||
runByToolUseId: fold.runByToolUseId,
|
||||
workflows: widget.workflows,
|
||||
),
|
||||
EditRun(:final edits) => _EditRunCard(
|
||||
key: ValueKey('edits.${edits.first.uuid}'),
|
||||
@@ -372,10 +389,48 @@ class _ConversationViewState extends State<ConversationView> {
|
||||
return list;
|
||||
},
|
||||
);
|
||||
return ColoredBox(
|
||||
final body = ColoredBox(
|
||||
color: tokens.panelBackground,
|
||||
child: widget.wrapInSelectionArea ? ClideSelectionArea(child: sized) : sized,
|
||||
);
|
||||
// Vim nav scrolls the conversation while this region holds focus under the
|
||||
// vim preset (T-406): j/k by a line, ctrl+d/u by half a viewport, gg/G to
|
||||
// the ends — G also re-arms follow-tail so new output keeps it pinned.
|
||||
return PaneKeyNav(onNav: _onNav, child: body);
|
||||
}
|
||||
|
||||
/// One "line" of scroll for j/k — a few text rows' worth.
|
||||
static const double _lineScroll = 48;
|
||||
|
||||
void _onNav(NavIntent intent, int count) {
|
||||
if (!_scroll.hasClients) return;
|
||||
final p = _scroll.position;
|
||||
final half = p.viewportDimension / 2;
|
||||
switch (intent) {
|
||||
case NavDownIntent():
|
||||
_scrollBy(_lineScroll * count);
|
||||
case NavUpIntent():
|
||||
_scrollBy(-_lineScroll * count);
|
||||
case NavPageDownIntent():
|
||||
_scrollBy(half);
|
||||
case NavPageUpIntent():
|
||||
_scrollBy(-half);
|
||||
case NavTopIntent():
|
||||
_scroll.jumpTo(0);
|
||||
_atBottom = false;
|
||||
case NavBottomIntent():
|
||||
_scroll.jumpTo(p.maxScrollExtent);
|
||||
_atBottom = true; // re-arm follow-tail (T-297)
|
||||
case NavExpandOrRightIntent() || NavCollapseOrLeftIntent() || NavActivateIntent():
|
||||
break; // a reader pane has no expand/activate semantics
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollBy(double delta) {
|
||||
final p = _scroll.position;
|
||||
final target = (p.pixels + delta).clamp(0.0, p.maxScrollExtent);
|
||||
_scroll.jumpTo(target);
|
||||
_atBottom = (p.maxScrollExtent - target) <= _bottomEpsilon;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +514,9 @@ class _BashLiveTailState extends State<_BashLiveTail> {
|
||||
if (source == null) return; // no file-backed source → muted note in build
|
||||
final term = Terminal(maxLines: 1000);
|
||||
_terminal = term;
|
||||
_follower = FileTailFollower(source, onData: (bytes) => term.write(utf8.decode(bytes, allowMalformed: true)));
|
||||
// writeBytes: the follower's chunk boundaries are arbitrary (it can even
|
||||
// start mid-rune by construction) — keep decode state across reads (T-373).
|
||||
_follower = FileTailFollower(source, onData: term.writeBytes);
|
||||
unawaited(_follower!.start());
|
||||
}
|
||||
|
||||
@@ -497,6 +554,7 @@ class _ConversationTurn extends StatelessWidget {
|
||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||
this.runByToolUseId = const <String, List<ConversationItem>>{},
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final ConversationItem item;
|
||||
@@ -532,6 +590,9 @@ class _ConversationTurn extends StatelessWidget {
|
||||
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
|
||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||
|
||||
/// Live Workflow runs keyed by launching tool-use id (T-416).
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i = item;
|
||||
@@ -571,6 +632,17 @@ class _ConversationTurn extends StatelessWidget {
|
||||
onOpenFile: (path, line) => _openFile(context, path, line),
|
||||
),
|
||||
),
|
||||
// CLI-local output (model "<synthetic>": a forwarded local command's
|
||||
// response or a clide-injected notice, T-411) is not Claude speaking —
|
||||
// framed + muted like the context card (T-306), attributed to clide.
|
||||
AssistantTextMessage() when i.synthetic => ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: tokens.globalTextMuted,
|
||||
label: 'clide',
|
||||
copyText: i.text,
|
||||
margin: _childMargin,
|
||||
body: ClideText(i.text, muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
// Sub-agent (sidechain) prose is NOT the main Claude — attribute it to the
|
||||
// agent with a muted accent, never the coral "claude" brand (T-265). The
|
||||
// coral claudeAccent is reserved for the real main-thread Claude.
|
||||
@@ -680,6 +752,13 @@ class _ConversationTurn extends StatelessWidget {
|
||||
/// and its own per-item mark. An Agent/Task call also nests its visible
|
||||
/// sub-agent run in a second collapser below (T-264).
|
||||
Widget _toolUseCollapser(AssistantToolUse t) {
|
||||
// A Workflow tool-use with a live run (T-416) renders the dedicated run
|
||||
// card — phases, agent rows, status — instead of the generic tool card. No
|
||||
// run yet (pre-progress, or on reload where the system events are gone)
|
||||
// falls through to the generic collapser below.
|
||||
if (t.name == 'Workflow' && workflows[t.toolUseId] != null) {
|
||||
return _workflowCard(t, workflows[t.toolUseId]!);
|
||||
}
|
||||
final outcome = toolUseOutcomes[t.toolUseId];
|
||||
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
|
||||
final collapser = ClideCollapserCard(
|
||||
@@ -723,6 +802,99 @@ class _ConversationTurn extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// A dedicated card for a Workflow run (T-416): the harness's multi-agent
|
||||
/// orchestration. The collapser header carries the run's live status (spinner
|
||||
/// while running, check when done) and a `done/total agents` counter; the body
|
||||
/// lists each fanned-out agent — grouped under phase headers when the workflow
|
||||
/// declared phases — plus the run's usage and the orchestration script.
|
||||
Widget _workflowCard(AssistantToolUse t, WorkflowRun run) {
|
||||
final title = run.name ?? 'workflow';
|
||||
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
|
||||
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
|
||||
final detail = run.done ? (run.summary ?? run.description) : run.description;
|
||||
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
|
||||
return ClideCollapserCard(
|
||||
label: 'workflow',
|
||||
color: color,
|
||||
collapsedSummary: collapsedSummary,
|
||||
counter: counter,
|
||||
status: run.done ? ClideRunStatus.success : ClideRunStatus.running,
|
||||
children: [_workflowBody(t, run)],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _workflowBody(AssistantToolUse t, WorkflowRun run) {
|
||||
final agents = run.orderedAgents;
|
||||
final phases = run.orderedPhases;
|
||||
final rows = <Widget>[];
|
||||
if (phases.isEmpty) {
|
||||
rows.addAll(agents.map(_workflowAgentRow));
|
||||
} else {
|
||||
for (final p in phases) {
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 2),
|
||||
child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow));
|
||||
}
|
||||
// Agents the deltas never tagged with a phase still render, after the
|
||||
// phased groups, so nothing fanned out is silently dropped.
|
||||
rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow));
|
||||
}
|
||||
if (rows.isEmpty) {
|
||||
rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta));
|
||||
}
|
||||
|
||||
final script = t.input['script'];
|
||||
return ConversationCard(
|
||||
variant: ConversationCardVariant.bordered,
|
||||
accent: run.done ? tokens.statusSuccess : tokens.globalFocus,
|
||||
label: run.name ?? 'workflow',
|
||||
copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input),
|
||||
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows),
|
||||
extraSegments: [
|
||||
if (run.totalTokens != null && run.totalTokens! > 0)
|
||||
CardSegment(
|
||||
label: 'usage',
|
||||
child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta),
|
||||
),
|
||||
if (script is String)
|
||||
CardSegment(
|
||||
label: 'script',
|
||||
child: ClideCodeBlock(source: script, language: 'javascript'),
|
||||
),
|
||||
],
|
||||
margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH),
|
||||
);
|
||||
}
|
||||
|
||||
/// One agent row in a workflow card: a state glyph (spinner while running, a
|
||||
/// muted check once done), the agent's label, and its model (T-416).
|
||||
Widget _workflowAgentRow(WorkflowAgent a) {
|
||||
final done = a.state == WorkflowAgentState.done;
|
||||
final Widget glyph = done
|
||||
? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess)
|
||||
: ClideSpinner(size: 12, color: tokens.globalTextMuted);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 16, child: Center(child: glyph)),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (a.model != null && a.model!.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The inner content card for a tool use (T-305): the call body + folded
|
||||
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
|
||||
/// collapse caret — the enclosing collapser owns collapse. Used both as a
|
||||
@@ -890,6 +1062,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
required this.resultByToolUseId,
|
||||
required this.promptsByToolUseId,
|
||||
required this.runByToolUseId,
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final List<ConversationItem> items;
|
||||
@@ -900,6 +1073,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||
final Map<String, List<ConversationItem>> runByToolUseId;
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -921,6 +1095,7 @@ class _ActivityCard extends StatelessWidget {
|
||||
resultByToolUseId: resultByToolUseId,
|
||||
promptsByToolUseId: promptsByToolUseId,
|
||||
runByToolUseId: runByToolUseId,
|
||||
workflows: workflows,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:clide/clide.dart';
|
||||
import 'package:clide/builtin/claude/src/activity_cluster.dart' show foldLevelFromName, kActivityFoldLevelKey, nextFoldLevel;
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show nextSafePermissionMode;
|
||||
import 'package:clide/builtin/claude/src/conversation_view.dart' show claudeAccent;
|
||||
import 'package:clide/builtin/claude/src/claude_session_host.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/pane_context_status.dart';
|
||||
@@ -21,6 +22,18 @@ import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// D-6 contract (T-391): a failed command returns an ERROR envelope (non-zero
|
||||
/// CLI exit), never `ok` with an `error` field a script can't detect.
|
||||
IpcResponse _userErr(String msg, {String? hint}) => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: msg, hint: hint),
|
||||
);
|
||||
|
||||
IpcResponse _notFound(String msg) => IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.notFound, kind: IpcErrorKind.notFound, message: msg),
|
||||
);
|
||||
|
||||
class ClaudeExtension extends ClideExtension {
|
||||
@override
|
||||
String get id => 'builtin.claude';
|
||||
@@ -95,7 +108,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: show an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.show(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'shown'});
|
||||
},
|
||||
@@ -106,7 +119,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: hide an agent session pane',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.hide(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'hidden'});
|
||||
},
|
||||
@@ -117,7 +130,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: close (kill) an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
await _orchestrator?.close(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'closed'});
|
||||
},
|
||||
@@ -128,7 +141,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: mute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.mute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'muted'});
|
||||
},
|
||||
@@ -139,7 +152,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: unmute broker delivery to an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
_orchestrator?.unmute(id);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'unmuted'});
|
||||
},
|
||||
@@ -151,9 +164,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: inject a text turn into an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
final text = args.skip(1).join(' ');
|
||||
if (text.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'missing message text'});
|
||||
if (text.isEmpty) return _userErr('missing message text');
|
||||
_orchestrator?.injectMessage(id, text);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'status': 'injected'});
|
||||
},
|
||||
@@ -169,12 +182,12 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: set permission mode for an agent session',
|
||||
run: (args) async {
|
||||
final id = args.firstOrNull;
|
||||
if (id == null) return IpcResponse.ok(id: '', data: const {'error': 'missing session id'});
|
||||
if (id == null) return _userErr('missing session id');
|
||||
final mode = args.length >= 2 ? args[1] : null;
|
||||
if (mode == null) return IpcResponse.ok(id: '', data: const {'error': 'missing mode (default|acceptEdits|plan|bypassPermissions)'});
|
||||
if (mode == null) return _userErr('missing mode (default|acceptEdits|plan|bypassPermissions)');
|
||||
const valid = {'default', 'acceptEdits', 'plan', 'bypassPermissions'};
|
||||
if (!valid.contains(mode)) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown mode "$mode"; use one of: ${valid.join(', ')}'});
|
||||
return _userErr('unknown mode "$mode"; use one of: ${valid.join(', ')}');
|
||||
}
|
||||
_orchestrator?.byId(id)?.session.setPermissionMode(mode);
|
||||
return IpcResponse.ok(id: '', data: {'id': id, 'mode': mode, 'status': 'sent'});
|
||||
@@ -188,7 +201,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
title: 'Claude: Cycle permission mode',
|
||||
run: (_) async {
|
||||
final managed = _orchestrator?.byId('primary');
|
||||
if (managed == null) return IpcResponse.ok(id: '', data: const {'error': 'no primary session'});
|
||||
if (managed == null) return _notFound('no primary session');
|
||||
final next = nextSafePermissionMode(managed.session.status.permissionMode ?? 'default');
|
||||
managed.session.setPermissionMode(next);
|
||||
return IpcResponse.ok(id: '', data: {'mode': next, 'status': 'sent'});
|
||||
@@ -200,11 +213,12 @@ class ClaudeExtension extends ClideExtension {
|
||||
command: 'claude.task.reassign',
|
||||
title: 'Claude: reassign a shared task to an agent',
|
||||
run: (args) async {
|
||||
if (args.length < 2) return IpcResponse.ok(id: '', data: const {'error': 'usage: <taskId> <sessionId>'});
|
||||
if (args.length < 2) return _userErr('usage: <taskId> <sessionId>');
|
||||
final taskId = args[0];
|
||||
final toId = args[1];
|
||||
final ok = _orchestrator?.broker.reassignTask(taskId, toId) ?? false;
|
||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': ok});
|
||||
if (!ok) return _notFound('could not reassign task "$taskId" to "$toId"');
|
||||
return IpcResponse.ok(id: '', data: {'taskId': taskId, 'toId': toId, 'ok': true});
|
||||
},
|
||||
),
|
||||
// T-180: full team chat pane opened as a workspace tab.
|
||||
@@ -241,7 +255,7 @@ class ClaudeExtension extends ClideExtension {
|
||||
command: 'claude.team-chat.post',
|
||||
title: 'Claude: post a message into the team channel as the user',
|
||||
run: (args) async {
|
||||
if (args.isEmpty) return IpcResponse.ok(id: '', data: const {'error': 'usage: [@name] <text>'});
|
||||
if (args.isEmpty) return _userErr('usage: [@name] <text>');
|
||||
final raw = args.join(' ');
|
||||
String? recipient;
|
||||
String body = raw;
|
||||
@@ -269,15 +283,18 @@ class ClaudeExtension extends ClideExtension {
|
||||
run: (args) async {
|
||||
final sourceId = args.firstOrNull;
|
||||
if (sourceId == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'usage: claude.agent.fork <sourceSessionId> [<cwd>]'});
|
||||
return _userErr('usage: claude.agent.fork <sourceSessionId> [<cwd>]');
|
||||
}
|
||||
final orch = _orchestrator;
|
||||
if (orch == null) {
|
||||
return IpcResponse.ok(id: '', data: const {'error': 'orchestrator unavailable'});
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'orchestrator unavailable'),
|
||||
);
|
||||
}
|
||||
final source = orch.byId(sourceId);
|
||||
if (source == null) {
|
||||
return IpcResponse.ok(id: '', data: {'error': 'unknown session "$sourceId"'});
|
||||
return _notFound('unknown session "$sourceId"');
|
||||
}
|
||||
final cwd = args.length >= 2 ? args[1] : source.cwd;
|
||||
final forkId = 'fork:$sourceId-${DateTime.now().millisecondsSinceEpoch}';
|
||||
@@ -292,6 +309,9 @@ class ClaudeExtension extends ClideExtension {
|
||||
slot: Slots.sidebar,
|
||||
title: 'Activity',
|
||||
icon: PhosphorIcons.byName('robot'),
|
||||
// Claude's accent marks Claude's own panel in the rail (T-418) —
|
||||
// nominative use per the licenses.yaml trademark note.
|
||||
iconColor: claudeAccent,
|
||||
priority: 60,
|
||||
build: (_) => const ClaudeMetaSidebar(),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/// The Activity tab: session controls, usage, stats (stats-cache.json), and
|
||||
/// the primary session's live runtime row. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395); session controls + the usage block are
|
||||
/// the power-panel additions (T-415).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_stats.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ActivityTabView extends StatelessWidget {
|
||||
const ActivityTabView({
|
||||
super.key,
|
||||
required this.stats,
|
||||
required this.primaryStatus,
|
||||
required this.config,
|
||||
this.usage,
|
||||
this.workflows = const <String, WorkflowRun>{},
|
||||
});
|
||||
|
||||
final ClaudeStats stats;
|
||||
final SessionStatus? primaryStatus;
|
||||
final ClaudeConfig? config;
|
||||
|
||||
/// Parsed `/usage` output for the usage block, refreshed via the refresh
|
||||
/// control (T-415). Null until the first refresh.
|
||||
final ClaudeUsage? usage;
|
||||
|
||||
/// Live Workflow runs in the primary session, keyed by launching tool-use id
|
||||
/// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with
|
||||
/// its done/total agent count and running/done state.
|
||||
final Map<String, WorkflowRun> workflows;
|
||||
|
||||
/// Publish a slash command for the primary pane to execute — the session
|
||||
/// controls are the same code path as typing the command (D-6).
|
||||
void _command(BuildContext context, String text) {
|
||||
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': text});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final latest = stats.latest;
|
||||
final u = usage;
|
||||
final sections = <MetaSection>[
|
||||
..._workflowSection(tokens),
|
||||
if (u != null)
|
||||
MetaSection('USAGE', [
|
||||
if (u.session != null) MetaRow('session', u.session!),
|
||||
if (u.week != null) MetaRow('week (all)', u.week!),
|
||||
if (u.weekSonnet != null) MetaRow('week (sonnet)', u.weekSonnet!),
|
||||
]),
|
||||
if (latest != null)
|
||||
MetaSection('TODAY', [
|
||||
MetaRow('messages', '${latest.messageCount}'),
|
||||
MetaRow('sessions', '${latest.sessionCount}'),
|
||||
MetaRow('tool calls', '${latest.toolCallCount}'),
|
||||
]),
|
||||
if (latest != null) MetaSection('LIFETIME', [MetaRow('messages', '${stats.lifetimeMessages}'), MetaRow('sessions', '${stats.lifetimeSessions}')]),
|
||||
..._runtimeSection(tokens),
|
||||
];
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
// SESSION control strip (T-415): drives the primary session through
|
||||
// the builtin.claude/command bus — identical to typing the command.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText('SESSION', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_control(context, tokens, 'clear', 'trash', '/clear'),
|
||||
_control(context, tokens, 'compact', 'arrows-in-simple', '/compact'),
|
||||
_control(context, tokens, 'fork', 'git-branch', '/fork'),
|
||||
_control(context, tokens, 'resume', 'clock-counter-clockwise', '/resume'),
|
||||
const Spacer(),
|
||||
_control(context, tokens, 'refresh usage', 'arrow-clockwise', '/usage'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (sections.isEmpty) metaPlaceholder('No activity recorded yet.') else ...metaTableChildren(tokens, sections),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _control(BuildContext context, SurfaceTokens tokens, String label, String glyph, String command) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '$label session',
|
||||
excludeSemantics: true,
|
||||
onTap: () => _command(context, command),
|
||||
child: ClideTappable(
|
||||
tooltip: '$label · $command',
|
||||
onTap: () => _command(context, command),
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: ClideIcon(PhosphorIcons.byName(glyph), size: 15, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// An aggregate WORKFLOWS section while one or more workflow runs exist this
|
||||
/// session (T-416): a row per run — its name and `done/total agents`, tinted
|
||||
/// focus while running and success once complete.
|
||||
List<MetaSection> _workflowSection(SurfaceTokens tokens) {
|
||||
final runs = workflows.values.toList();
|
||||
if (runs.isEmpty) return const [];
|
||||
return [
|
||||
MetaSection('WORKFLOWS', [
|
||||
for (final r in runs)
|
||||
MetaRow(
|
||||
r.name ?? r.taskId ?? 'workflow',
|
||||
r.agentCount == 0 ? (r.done ? 'done' : 'starting') : '${r.doneCount}/${r.agentCount} agents${r.done ? ' ✓' : ''}',
|
||||
valueColor: r.done ? tokens.statusSuccess : tokens.globalFocus,
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
|
||||
final st = primaryStatus;
|
||||
final skills = config?.skills.length;
|
||||
final rows = <MetaRow>[
|
||||
if (st?.model != null) MetaRow('model', shortModelLabel(st!.model!), valueColor: tokens.globalFocus),
|
||||
if (st?.effort != null) MetaRow('effort', st!.effort!),
|
||||
if (st?.contextTokens != null) MetaRow('context', '${formatTokenCount(st!.contextTokens!)} ctx'),
|
||||
if (st?.permissionMode != null) MetaRow('mode', permissionModeLabel(st!.permissionMode!)),
|
||||
if (skills != null) MetaRow('skills', '$skills'),
|
||||
];
|
||||
return rows.isEmpty ? const [] : [MetaSection('RUNTIME · primary', rows)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/// The Config tab (T-183): the settings table over [ClaudeConfig] plus the
|
||||
/// skills/agents/commands/hooks/permissions/MCP accordion. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395). The accordion's expansion state lives in
|
||||
/// the parent (it survives tab switches) and arrives as a prop + toggle
|
||||
/// callback.
|
||||
///
|
||||
/// T-414 makes the settings table a control panel: model / effort /
|
||||
/// permission-mode rows are live popover controls. Picking an option
|
||||
/// publishes the explicit slash command (`/model sonnet`) on the
|
||||
/// `builtin.claude`/`command` channel; the primary Claude pane executes it
|
||||
/// through the same `_send` routing the composer uses — one implementation,
|
||||
/// two surfaces (D-6).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_config.dart';
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart' show ModelOption, kEffortLevels, kFallbackModels, kPermissionModes;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ConfigTabView extends StatelessWidget {
|
||||
const ConfigTabView({super.key, required this.config, required this.expanded, required this.onToggleSection, this.status, this.models});
|
||||
|
||||
final ClaudeConfig? config;
|
||||
|
||||
/// The primary session's live status — drives the control rows' current
|
||||
/// values. Null before the session reports (controls fall back to the
|
||||
/// probe/settings values).
|
||||
final SessionStatus? status;
|
||||
|
||||
/// Models selectable for the primary session (from its `initialize`
|
||||
/// response); falls back to [kFallbackModels].
|
||||
final List<ModelOption>? models;
|
||||
|
||||
/// Sections currently expanded — owned by the parent state.
|
||||
final Set<ConfigSection> expanded;
|
||||
final void Function(ConfigSection section) onToggleSection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final cfg = config;
|
||||
if (cfg == null) {
|
||||
return metaPlaceholder('Claude environment not loaded.');
|
||||
}
|
||||
final settings = cfg.settings;
|
||||
final model = status?.model ?? cfg.probe?.model ?? settings['model']?.toString() ?? 'default';
|
||||
final outputStyle = settings['outputStyle']?.toString() ?? 'default';
|
||||
final mode = status?.permissionMode ?? cfg.probe?.permissionMode ?? settings['permissionMode']?.toString() ?? 'default';
|
||||
final effort = status?.effort ?? settings['effortLevel']?.toString() ?? 'default';
|
||||
|
||||
final children = <Widget>[
|
||||
// Pinned SETTINGS control panel — not collapsible.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: ClideText('SETTINGS', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
SettingControlRow(
|
||||
label: 'model',
|
||||
value: model,
|
||||
valueColor: tokens.globalFocus,
|
||||
options: (models == null || models!.isEmpty) ? kFallbackModels : models!,
|
||||
isActive: (o) => o.value == model || model.toLowerCase().contains(o.value.toLowerCase()),
|
||||
command: 'model',
|
||||
),
|
||||
SettingControlRow(label: 'effort', value: effort, options: kEffortLevels, isActive: (o) => o.value == effort, command: 'effort'),
|
||||
SettingControlRow(
|
||||
label: 'permission mode',
|
||||
value: permissionModeLabel(mode),
|
||||
options: kPermissionModes,
|
||||
isActive: (o) => o.value == mode,
|
||||
command: 'permissions',
|
||||
),
|
||||
_configRow(tokens, 'output style', outputStyle),
|
||||
_configRow(tokens, 'source', '~/.claude + .claude'),
|
||||
|
||||
// ---- Accordion sections ----
|
||||
for (final section in ConfigSection.values) _accordion(context, tokens, cfg, section),
|
||||
|
||||
// Footer hint.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: ClideText('expand a list to see all · click a skill/agent/command → opens its .md', muted: true, fontSize: clideFontSmall),
|
||||
),
|
||||
];
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
/// One read-only key→value row in the pinned SETTINGS table.
|
||||
Widget _configRow(SurfaceTokens tokens, String label, String value, {Color? valueColor}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(value, fontSize: kMetaFont, color: valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _sectionLabel(ConfigSection section) => switch (section) {
|
||||
ConfigSection.skills => 'SKILLS',
|
||||
ConfigSection.agents => 'AGENTS',
|
||||
ConfigSection.commands => 'COMMANDS',
|
||||
ConfigSection.hooks => 'HOOKS',
|
||||
ConfigSection.permissions => 'PERMISSIONS',
|
||||
ConfigSection.mcpServers => 'MCP SERVERS',
|
||||
};
|
||||
|
||||
int _sectionCount(ClaudeConfig config, ConfigSection section) => switch (section) {
|
||||
ConfigSection.skills => config.skills.length,
|
||||
ConfigSection.agents => config.agents.length,
|
||||
ConfigSection.commands => config.commands.length,
|
||||
ConfigSection.hooks => config.hooks.length,
|
||||
ConfigSection.permissions => config.permissions.allow.length + config.permissions.deny.length + config.permissions.ask.length,
|
||||
ConfigSection.mcpServers => config.mcpServers.length,
|
||||
};
|
||||
|
||||
Widget _accordion(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||
final isExpanded = expanded.contains(section);
|
||||
final children = isExpanded ? _sectionChildren(context, tokens, config, section) : const <Widget>[];
|
||||
return ClideAccordion(
|
||||
label: _sectionLabel(section),
|
||||
count: _sectionCount(config, section),
|
||||
expanded: isExpanded,
|
||||
onToggle: () => onToggleSection(section),
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _sectionChildren(BuildContext context, SurfaceTokens tokens, ClaudeConfig config, ConfigSection section) {
|
||||
switch (section) {
|
||||
case ConfigSection.skills:
|
||||
return [for (final skill in config.skills) _fileRow(context, tokens, skill.name, skill.path)];
|
||||
case ConfigSection.agents:
|
||||
return [for (final agent in config.agents) _fileRow(context, tokens, agent.name, agent.path)];
|
||||
case ConfigSection.commands:
|
||||
return [for (final cmd in config.commands) _fileRow(context, tokens, cmd.name, cmd.path)];
|
||||
case ConfigSection.hooks:
|
||||
return [
|
||||
for (final hook in config.hooks)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(hook.event, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
for (final cmd in hook.commands)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, top: 1),
|
||||
child: ClideText(cmd, fontSize: clideFontSmall, muted: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
case ConfigSection.permissions:
|
||||
return _permissionRows(tokens, config.permissions);
|
||||
case ConfigSection.mcpServers:
|
||||
return [
|
||||
for (final srv in config.mcpServers)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(srv.name, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row for file-backed items (skills, agents, commands).
|
||||
/// All config items are .md files — opens in the markdown reader panel
|
||||
/// via the kernel MessageBus (D-6, T-183).
|
||||
Widget _fileRow(BuildContext context, SurfaceTokens tokens, String name, String? path) {
|
||||
final row = Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: path != null ? tokens.globalFocus : tokens.globalForeground),
|
||||
);
|
||||
if (path == null) return row;
|
||||
void openMarkdown() => ClideKernel.of(context).messages.publish('builtin.markdown', 'selection', {'path': path});
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: name,
|
||||
excludeSemantics: true,
|
||||
onTap: openMarkdown,
|
||||
child: ClideTappable(
|
||||
tooltip: path,
|
||||
onTap: openMarkdown,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 2, bottom: 2),
|
||||
child: ClideText(name, fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders grouped allow/ask/deny permission rows, colour-coded by kind.
|
||||
List<Widget> _permissionRows(SurfaceTokens tokens, ClaudePermissions perms) {
|
||||
// allow → statusSuccess, ask → statusWarning, deny → statusError
|
||||
Color kindColor(ConfigPermKind k) => switch (k) {
|
||||
ConfigPermKind.allow => tokens.statusSuccess,
|
||||
ConfigPermKind.ask => tokens.statusWarning,
|
||||
ConfigPermKind.deny => tokens.statusError,
|
||||
};
|
||||
|
||||
String kindLabel(ConfigPermKind k) => switch (k) {
|
||||
ConfigPermKind.allow => 'allow',
|
||||
ConfigPermKind.ask => 'ask',
|
||||
ConfigPermKind.deny => 'deny',
|
||||
};
|
||||
|
||||
final groups = [(ConfigPermKind.allow, perms.allow), (ConfigPermKind.ask, perms.ask), (ConfigPermKind.deny, perms.deny)];
|
||||
|
||||
final rows = <Widget>[];
|
||||
for (final (kind, rules) in groups) {
|
||||
if (rules.isEmpty) continue;
|
||||
final color = kindColor(kind);
|
||||
rows.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
child: ClideText(kindLabel(kind), fontSize: clideFontSmall, color: color),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final rule in rules)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 1),
|
||||
child: ClideText(rule, fontSize: clideFontSmall, color: tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/// One live setting row (T-414): label + current value as a popover control on
|
||||
/// the owned anchored-menu primitive. Picking an option publishes the explicit
|
||||
/// slash command on `builtin.claude`/`command`; the primary Claude pane
|
||||
/// executes it through its normal `_send` routing — so the sidebar control and
|
||||
/// the typed command are literally the same code path (D-6).
|
||||
class SettingControlRow extends StatefulWidget {
|
||||
const SettingControlRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.isActive,
|
||||
required this.command,
|
||||
this.valueColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
||||
/// Current value, displayed on the trigger.
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
|
||||
final List<ModelOption> options;
|
||||
final bool Function(ModelOption option) isActive;
|
||||
|
||||
/// The slash-command token this control drives (`model`, `effort`,
|
||||
/// `permissions`); a pick publishes `/<command> <option.value>`.
|
||||
final String command;
|
||||
|
||||
@override
|
||||
State<SettingControlRow> createState() => _SettingControlRowState();
|
||||
}
|
||||
|
||||
class _SettingControlRowState extends State<SettingControlRow> {
|
||||
final ClideOverlayController _overlay = ClideOverlayController();
|
||||
|
||||
void _pick(String value) {
|
||||
ClideKernel.of(context).messages.publish('builtin.claude', 'command', {'text': '/${widget.command} $value'});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(widget.label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideAnchoredOverlay(
|
||||
controller: _overlay,
|
||||
align: ClideAnchorAlign.start,
|
||||
overlayBuilder: (ctx, c) => ClideMenu(
|
||||
onClose: c.close,
|
||||
entries: [
|
||||
for (final o in widget.options)
|
||||
ClideMenuItem(
|
||||
label: o.description.isEmpty ? o.displayName : '${o.displayName} — ${o.description}',
|
||||
active: widget.isActive(o),
|
||||
semanticLabel: '${widget.label}: ${o.displayName}',
|
||||
onSelect: () => _pick(o.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
anchor: Semantics(
|
||||
button: true,
|
||||
label: '${widget.label}: ${widget.value}. Click to change.',
|
||||
excludeSemantics: true,
|
||||
onTap: _overlay.toggle,
|
||||
child: ClideTappable(
|
||||
tooltip: 'change ${widget.label}',
|
||||
onTap: _overlay.toggle,
|
||||
builder: (ctx, hovered, _) => DecoratedBox(
|
||||
decoration: BoxDecoration(color: hovered ? tokens.listItemHoverBackground : null, borderRadius: BorderRadius.circular(4)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ClideText(widget.value, fontSize: kMetaFont, color: widget.valueColor ?? tokens.globalForeground, maxLines: 1),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// A single icon-button used by the roster row controls + task rows.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395). Promote to
|
||||
/// lib/widgets/ only when a second consumer appears.
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MetaIconButton extends StatelessWidget {
|
||||
const MetaIconButton({super.key, required this.painter, required this.tooltip, required this.color, required this.onTap});
|
||||
|
||||
final ClideIconPainter painter;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Icon-only button: expose the tooltip text as the Semantics button label
|
||||
// so AT (and widget tests) can find and activate it by name.
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
excludeSemantics: true,
|
||||
onTap: onTap,
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: onTap,
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideIcon(painter, size: 12, color: hovered ? ClideTheme.of(ctx).surface.globalForeground : color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// Inline text input for injecting a message into a session (T-171).
|
||||
/// Submits on Enter; Cancel is handled by the parent's icon button.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class InjectTextField extends StatelessWidget {
|
||||
const InjectTextField({super.key, required this.controller, required this.tokens, required this.onSubmit});
|
||||
|
||||
final TextEditingController controller;
|
||||
final SurfaceTokens tokens;
|
||||
final void Function(String text) onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border.all(color: tokens.panelBorder),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: EditableText(
|
||||
controller: controller,
|
||||
focusNode: FocusNode(debugLabel: 'inject-${controller.hashCode}')..requestFocus(),
|
||||
style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: clideFontSmall, color: tokens.globalForeground, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
onSubmitted: onSubmit,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/// Shared models + table geometry for the Claude meta sidebar's tabs.
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The shared label-column width + row pitch the Activity and Config tables
|
||||
/// both use, so toggling between tabs keeps every value at the same x and y.
|
||||
const double kMetaLabelColumnWidth = 110;
|
||||
const double kMetaRowPitch = 6;
|
||||
|
||||
/// Type scale for the sidebar tables (T-414 styling pass): labels/values read
|
||||
/// at meta size (13) — the old 12px-everything read as bland and cramped.
|
||||
const double kMetaFont = clideFontMeta;
|
||||
|
||||
/// The sidebar's sub-tabs.
|
||||
enum SidebarTab { activity, team, config }
|
||||
|
||||
// T-183: accordion sections for the Config tab.
|
||||
enum ConfigSection { skills, agents, commands, hooks, permissions, mcpServers }
|
||||
|
||||
/// Permission kind for colour-coding in the Config tab (T-183).
|
||||
enum ConfigPermKind { allow, ask, deny }
|
||||
|
||||
class MetaSection {
|
||||
const MetaSection(this.header, this.rows);
|
||||
final String header;
|
||||
final List<MetaRow> rows;
|
||||
}
|
||||
|
||||
class MetaRow {
|
||||
const MetaRow(this.label, this.value, {this.valueColor});
|
||||
final String label;
|
||||
final String value;
|
||||
final Color? valueColor;
|
||||
}
|
||||
|
||||
/// The muted empty-state body shared by every tab.
|
||||
Widget metaPlaceholder(String text) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: ClideText(text, muted: true, fontSize: kMetaFont),
|
||||
);
|
||||
|
||||
/// Key→value sections on the shared table geometry (Activity + Config).
|
||||
Widget buildMetaTable(SurfaceTokens tokens, List<MetaSection> sections) =>
|
||||
ListView(padding: const EdgeInsets.all(12), children: metaTableChildren(tokens, sections));
|
||||
|
||||
/// The table rows without the enclosing ListView, for tabs that compose extra
|
||||
/// widgets around the sections (the Activity tab's control strip, T-415).
|
||||
List<Widget> metaTableChildren(SurfaceTokens tokens, List<MetaSection> sections) {
|
||||
final children = <Widget>[];
|
||||
for (var i = 0; i < sections.length; i++) {
|
||||
final s = sections[i];
|
||||
children.add(
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: i == 0 ? 0 : 18, bottom: 8),
|
||||
child: ClideText(s.header, fontSize: clideFontSmall, color: tokens.sidebarSectionHeader),
|
||||
),
|
||||
);
|
||||
for (final r in s.rows) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: kMetaRowPitch),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: kMetaLabelColumnWidth,
|
||||
child: ClideText(r.label, muted: true, fontSize: kMetaFont),
|
||||
),
|
||||
Expanded(
|
||||
child: ClideText(r.value, fontSize: kMetaFont, color: r.valueColor ?? tokens.globalForeground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// Clickable permission-mode badge shown in each roster row (T-181).
|
||||
/// Split out of claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show permissionModeLabel;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Maps a permission-mode string to a single-letter badge label.
|
||||
String permissionModeBadgeLabel(String mode) => switch (mode) {
|
||||
'acceptEdits' => 'A',
|
||||
'plan' => 'P',
|
||||
'bypassPermissions' => 'B',
|
||||
_ => 'D', // default
|
||||
};
|
||||
|
||||
/// - Plain click → cycles the safe trio: default → acceptEdits → plan → default.
|
||||
/// - Shift-click → shows the bypass confirm inline in the parent row.
|
||||
///
|
||||
/// The badge reflects the LIVE mode from `SessionStatus.permissionMode`
|
||||
/// (T-157). It is a custom painted label (no Material), consistent with the
|
||||
/// rendering stack rules (D-7, CLAUDE.md guardrails).
|
||||
class PermissionModeBadge extends StatelessWidget {
|
||||
const PermissionModeBadge({super.key, required this.mode, required this.tokens, required this.onCycle, required this.onBypass});
|
||||
|
||||
final String mode;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
/// Called on a plain click — the parent cycles to the next safe mode.
|
||||
final VoidCallback onCycle;
|
||||
|
||||
/// Called on a shift-click — the parent shows the bypass confirm.
|
||||
final VoidCallback onBypass;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = permissionModeBadgeLabel(mode);
|
||||
final isBypass = mode == 'bypassPermissions';
|
||||
final badgeColor = isBypass ? const Color(0xFFF06C6F) : tokens.globalFocus;
|
||||
|
||||
final tooltip =
|
||||
'Permission mode: ${permissionModeLabel(mode)}. '
|
||||
'Click to cycle default/acceptEdits/plan; Shift-click for bypassPermissions.';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Permission mode: $label',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: tooltip,
|
||||
onTap: () {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
onBypass();
|
||||
} else {
|
||||
onCycle();
|
||||
}
|
||||
},
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
width: 16,
|
||||
height: 14,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withAlpha(hovered ? 51 : 26),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: badgeColor.withAlpha(hovered ? 180 : 100), width: 1),
|
||||
),
|
||||
child: ClideText(label, fontSize: 9, color: badgeColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/// A single agent roster row: color dot + name + status sub-text +
|
||||
/// controls (T-171). Split out of claude_meta_sidebar.dart (T-395).
|
||||
///
|
||||
/// Controls (trailing region):
|
||||
/// - permission-mode badge (T-181) — D/A/P cycles the safe trio; shift-click
|
||||
/// reaches bypassPermissions behind a confirm
|
||||
/// - eye / eye-slash — show / hide the session pane
|
||||
/// - speaker / speaker-slash — mute / unmute broker delivery
|
||||
/// - inject (chat icon) — expand the inline message input
|
||||
/// - fork (git-branch icon) — open a new pane branching from this session (T-172)
|
||||
/// - close (×) — kill the session
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_status.dart' show formatTokenCount, permissionModeLabel, shortModelLabel;
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/inject_field.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/permission_badge.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/team_panel_host.dart' show teamColor;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AgentRosterRow extends StatefulWidget {
|
||||
const AgentRosterRow({
|
||||
super.key,
|
||||
required this.member,
|
||||
required this.status,
|
||||
required this.orchestrator,
|
||||
required this.injectingAgentId,
|
||||
required this.injectController,
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
required this.onFork,
|
||||
});
|
||||
|
||||
final TeamMemberJoined member;
|
||||
final SessionStatus? status;
|
||||
final ClaudeSessionOrchestrator? orchestrator;
|
||||
|
||||
/// The member name currently in inject mode (null = none).
|
||||
final String? injectingAgentId;
|
||||
|
||||
/// Shared text controller for the inject field (cleared on submit/cancel).
|
||||
final TextEditingController injectController;
|
||||
|
||||
final void Function(String memberName) onToggleInject;
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
|
||||
/// Called when the badge cycles to a new [mode] string for this member.
|
||||
/// Handles both safe-trio clicks and confirmed bypass. The parent sends
|
||||
/// the mode to the session via `StreamJsonSession.setPermissionMode`.
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
|
||||
/// Called when the fork button is tapped (T-172). The session id of the
|
||||
/// member's managed session is passed so the host can open a fork pane.
|
||||
final void Function(String memberName) onFork;
|
||||
|
||||
@override
|
||||
State<AgentRosterRow> createState() => _AgentRosterRowState();
|
||||
}
|
||||
|
||||
class _AgentRosterRowState extends State<AgentRosterRow> {
|
||||
/// Whether the bypass-confirm inline prompt is showing.
|
||||
bool _confirmingBypass = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final managed = widget.orchestrator?.byMemberName(widget.member.name);
|
||||
final color = teamColor(widget.member.color, fallback: tokens.globalForeground);
|
||||
final st = widget.status;
|
||||
final model = st?.model ?? widget.member.model;
|
||||
final sub = [
|
||||
widget.member.agentType,
|
||||
if (model != null) shortModelLabel(model),
|
||||
if (st?.permissionMode != null) permissionModeLabel(st!.permissionMode!),
|
||||
if (st?.contextTokens != null) '${formatTokenCount(st!.contextTokens!)} ctx',
|
||||
].join(' · ');
|
||||
|
||||
final isVisible = managed?.visible ?? true;
|
||||
final isMuted = managed?.muted ?? false;
|
||||
final isInjecting = widget.injectingAgentId == widget.member.name;
|
||||
final currentMode = st?.permissionMode ?? 'default';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Color dot
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 3),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Name + status
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText(widget.member.name, fontSize: clideFontSmall, color: tokens.globalForeground, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (sub.isNotEmpty) ClideText(sub, muted: true, fontSize: clideFontSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
// T-181: permission-mode badge (inline below the status sub-text).
|
||||
if (managed != null)
|
||||
PermissionModeBadge(
|
||||
mode: currentMode,
|
||||
tokens: tokens,
|
||||
onCycle: () {
|
||||
final next = _nextSafeMode(currentMode);
|
||||
widget.onSetPermissionMode(widget.member.name, next);
|
||||
},
|
||||
onBypass: () => setState(() => _confirmingBypass = true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Trailing controls (T-171).
|
||||
// T-172 seam: append a fork icon button to this row.
|
||||
if (managed != null) _buildControls(context, tokens, managed, isVisible, isMuted, isInjecting),
|
||||
],
|
||||
),
|
||||
// Bypass confirm: replaces inject field area when active.
|
||||
if (_confirmingBypass) _buildBypassConfirm(tokens),
|
||||
// Inline inject-message field — visible only when toggled.
|
||||
if (isInjecting && !_confirmingBypass) _buildInjectField(context, tokens),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Safe-mode cycle: default → acceptEdits → plan → default (T-181).
|
||||
static String _nextSafeMode(String current) {
|
||||
const cycle = ['default', 'acceptEdits', 'plan'];
|
||||
final idx = cycle.indexOf(current);
|
||||
return cycle[(idx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
Widget _buildBypassConfirm(SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClideText('Enable bypassPermissions? All tool calls will be auto-allowed.', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Confirm
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Confirm bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
child: ClideTappable(
|
||||
tooltip: 'Confirm',
|
||||
onTap: () {
|
||||
setState(() => _confirmingBypass = false);
|
||||
widget.onSetPermissionMode(widget.member.name, 'bypassPermissions');
|
||||
},
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('OK', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalFocus),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Cancel
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Cancel bypass',
|
||||
excludeSemantics: true,
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
child: ClideTappable(
|
||||
tooltip: 'Cancel',
|
||||
onTap: () => setState(() => _confirmingBypass = false),
|
||||
builder: (ctx, hovered, _) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: ClideText('Cancel', fontSize: clideFontSmall, color: hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(BuildContext context, SurfaceTokens tokens, ManagedSession managed, bool isVisible, bool isMuted, bool isInjecting) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Show / hide
|
||||
MetaIconButton(
|
||||
painter: isVisible ? PhosphorIcons.byName('eye') : PhosphorIcons.byName('eye-slash'),
|
||||
tooltip: isVisible ? 'Hide pane' : 'Show pane',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => isVisible ? widget.orchestrator!.hide(managed.id) : widget.orchestrator!.show(managed.id),
|
||||
),
|
||||
// Mute / unmute
|
||||
MetaIconButton(
|
||||
painter: isMuted ? PhosphorIcons.byName('eye-slash') : PhosphorIcons.byName('eye'),
|
||||
// NOTE: We use eye/eyeSlash as stand-ins until a dedicated speaker
|
||||
// icon is added to PhosphorIcons (no speaker codepoint yet).
|
||||
// The semantic tooltip still says mute/unmute so AT users are clear.
|
||||
tooltip: isMuted ? 'Unmute messages' : 'Mute messages',
|
||||
color: isMuted ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => isMuted ? widget.orchestrator!.unmute(managed.id) : widget.orchestrator!.mute(managed.id),
|
||||
),
|
||||
// Inject message
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('chat-circle'),
|
||||
tooltip: 'Inject message',
|
||||
color: isInjecting ? tokens.globalFocus : tokens.globalTextMuted,
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
// Fork session (T-172): branch into a new pane without touching the original.
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('git-branch'),
|
||||
tooltip: 'Fork session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onFork(widget.member.name),
|
||||
),
|
||||
// Close session
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('x'),
|
||||
tooltip: 'Close session',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onClose(widget.member.name),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInjectField(BuildContext context, SurfaceTokens tokens) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: InjectTextField(
|
||||
controller: widget.injectController,
|
||||
tokens: tokens,
|
||||
onSubmit: (text) {
|
||||
if (text.trim().isNotEmpty) widget.onInjectSubmit(widget.member.name, text.trim());
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('x'),
|
||||
tooltip: 'Cancel',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => widget.onToggleInject(widget.member.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/// The Activity / Team / Config sub-tab strip — same interaction as the pql
|
||||
/// panel's view tabs, with an underline under the active tab. Split out of
|
||||
/// claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class SidebarTabStrip extends StatelessWidget {
|
||||
const SidebarTabStrip({super.key, required this.current, required this.memberCount, required this.onPick});
|
||||
final SidebarTab current;
|
||||
final int memberCount;
|
||||
final ValueChanged<SidebarTab> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens.panelBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final t in SidebarTab.values)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: t == current,
|
||||
label: _label(t),
|
||||
excludeSemantics: true,
|
||||
onTap: () => onPick(t),
|
||||
child: ClideTappable(
|
||||
onTap: () => onPick(t),
|
||||
builder: (ctx, hovered, _) => Container(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: t == current ? tokens.globalFocus : const Color(0x00000000), width: 2)),
|
||||
),
|
||||
child: ClideText(_label(t), fontSize: clideFontSmall, color: t == current || hovered ? tokens.globalForeground : tokens.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _label(SidebarTab t) => switch (t) {
|
||||
SidebarTab.activity => 'Activity',
|
||||
SidebarTab.team => memberCount == 0 ? 'Team' : 'Team · $memberCount',
|
||||
SidebarTab.config => 'Config',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/// One row in the Team tab's TASKS section: status marker + title +
|
||||
/// owner + reassign control (T-171). Split out of
|
||||
/// claude_meta_sidebar.dart (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/icon_button.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, TeamTask;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TaskRow extends StatelessWidget {
|
||||
const TaskRow({super.key, required this.task, required this.members, required this.broker});
|
||||
|
||||
final TeamTask task;
|
||||
final List<TeamMemberJoined> members;
|
||||
final TeamBroker? broker;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final marker = switch (task.status) {
|
||||
'done' => '✓',
|
||||
'claimed' => '◈',
|
||||
_ => '○',
|
||||
};
|
||||
final markerColor = switch (task.status) {
|
||||
'done' => tokens.globalTextMuted,
|
||||
'claimed' => tokens.globalFocus,
|
||||
_ => tokens.globalForeground,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
ClideText(marker, fontSize: clideFontSmall, color: markerColor),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ClideText(
|
||||
task.title,
|
||||
fontSize: clideFontSmall,
|
||||
color: task.status == 'done' ? tokens.globalTextMuted : tokens.globalForeground,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (task.owner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: ClideText(task.owner!, fontSize: clideFontSmall, color: tokens.globalFocus),
|
||||
),
|
||||
// Reassign: cycle to the next roster member.
|
||||
if (broker != null && broker!.members.length > 1)
|
||||
MetaIconButton(
|
||||
painter: PhosphorIcons.byName('arrow-clockwise'),
|
||||
tooltip: 'Reassign task',
|
||||
color: tokens.globalTextMuted,
|
||||
onTap: () => _reassign(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reassign(BuildContext context) {
|
||||
final b = broker;
|
||||
if (b == null || members.isEmpty) return;
|
||||
final brokerMembers = b.members;
|
||||
if (brokerMembers.isEmpty) return;
|
||||
// Cycle to the next member after the current owner.
|
||||
final currentIndex = brokerMembers.indexWhere((m) => m.name == task.owner);
|
||||
final nextIndex = (currentIndex + 1) % brokerMembers.length;
|
||||
b.reassignTask(task.id, brokerMembers[nextIndex].id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/// The Team tab: the roster cockpit (T-171) — per-member rows with
|
||||
/// controls, the TASKS section, and the MESSAGES chat feed (T-180).
|
||||
/// Stateless and props-driven; the parent owns the member list, inject
|
||||
/// state, and orchestrator wiring. Split out of claude_meta_sidebar.dart
|
||||
/// (T-395).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/roster_row.dart';
|
||||
import 'package:clide/builtin/claude/src/meta_sidebar/task_row.dart';
|
||||
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart' show TeamTask;
|
||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart' show TeamChatSidebar;
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TeamTabView extends StatelessWidget {
|
||||
const TeamTabView({
|
||||
super.key,
|
||||
required this.members,
|
||||
required this.memberStatus,
|
||||
required this.orchestrator,
|
||||
required this.tasks,
|
||||
required this.injectingAgentId,
|
||||
required this.injectController,
|
||||
required this.onToggleInject,
|
||||
required this.onInjectSubmit,
|
||||
required this.onClose,
|
||||
required this.onSetPermissionMode,
|
||||
required this.onFork,
|
||||
required this.onOpenChatPane,
|
||||
});
|
||||
|
||||
final List<TeamMemberJoined> members;
|
||||
final Map<String, SessionStatus> memberStatus;
|
||||
final ClaudeSessionOrchestrator? orchestrator;
|
||||
final List<TeamTask> tasks;
|
||||
final String? injectingAgentId;
|
||||
final TextEditingController injectController;
|
||||
final void Function(String memberName) onToggleInject;
|
||||
final void Function(String memberName, String text) onInjectSubmit;
|
||||
final void Function(String memberName) onClose;
|
||||
final void Function(String memberName, String mode) onSetPermissionMode;
|
||||
final void Function(String memberName) onFork;
|
||||
final VoidCallback onOpenChatPane;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (members.isEmpty) {
|
||||
return metaPlaceholder('No team active.');
|
||||
}
|
||||
final children = <Widget>[
|
||||
for (final m in members)
|
||||
AgentRosterRow(
|
||||
key: ValueKey(m.agentId),
|
||||
member: m,
|
||||
status: memberStatus[m.agentId],
|
||||
orchestrator: orchestrator,
|
||||
injectingAgentId: injectingAgentId,
|
||||
injectController: injectController,
|
||||
onToggleInject: onToggleInject,
|
||||
onInjectSubmit: onInjectSubmit,
|
||||
onClose: onClose,
|
||||
onSetPermissionMode: onSetPermissionMode,
|
||||
onFork: onFork,
|
||||
),
|
||||
];
|
||||
|
||||
if (tasks.isNotEmpty) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(_taskSection(tokens));
|
||||
}
|
||||
|
||||
// MESSAGES section (T-180): live broker chat feed + quick-post composer.
|
||||
final chatModel = orchestrator?.chatModel;
|
||||
final broker = orchestrator?.broker;
|
||||
if (chatModel != null && broker != null) {
|
||||
children.add(const SizedBox(height: 12));
|
||||
children.add(TeamChatSidebar(model: chatModel, broker: broker, onPopOut: onOpenChatPane));
|
||||
}
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(12), children: children);
|
||||
}
|
||||
|
||||
Widget _taskSection(SurfaceTokens tokens) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClideText('TASKS', fontSize: clideFontSmall, color: tokens.globalTextMuted),
|
||||
const SizedBox(height: 4),
|
||||
for (final t in tasks) TaskRow(task: t, members: members, broker: orchestrator?.broker),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/// The `/model` picker for the interaction zone (T-408, D-78): a bare
|
||||
/// `/model` swaps this card in for the composer; picking an entry sends
|
||||
/// `set_model` over the control channel and the composer returns. Esc
|
||||
/// cancels. Like [ToolPromptCard], it lives in the composer zone — never
|
||||
/// inline in the conversation.
|
||||
///
|
||||
/// Keyboard: number keys pick directly (CLI muscle memory, T-240), Up/Down
|
||||
/// move the highlight, Enter picks the highlighted entry, Esc cancels.
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/stream_json_session.dart';
|
||||
import 'package:clide/kernel/src/theme/controller.dart';
|
||||
import 'package:clide/kernel/src/theme/tokens.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Whether [option] is the session's current model. Options carry aliases
|
||||
/// (`sonnet`) or full ids while the status holds the full id
|
||||
/// (`claude-sonnet-4-6`), so match on equality or alias containment.
|
||||
bool modelOptionIsCurrent(ModelOption option, String? currentModel) {
|
||||
if (currentModel == null || option.value == 'default') return false;
|
||||
if (option.value == currentModel) return true;
|
||||
return currentModel.toLowerCase().contains(option.value.toLowerCase());
|
||||
}
|
||||
|
||||
class ModelPickerCard extends StatefulWidget {
|
||||
const ModelPickerCard({
|
||||
super.key,
|
||||
required this.models,
|
||||
this.currentModel,
|
||||
required this.onPick,
|
||||
required this.onCancel,
|
||||
this.title = 'model',
|
||||
this.isCurrent = modelOptionIsCurrent,
|
||||
});
|
||||
|
||||
/// Selectable entries, in display order. Callers pass [kFallbackModels]
|
||||
/// when the session hasn't reported its list yet.
|
||||
final List<ModelOption> models;
|
||||
|
||||
/// The session's current model (full id), to mark the active entry.
|
||||
final String? currentModel;
|
||||
|
||||
/// Called once with the picked [ModelOption.value].
|
||||
final void Function(String value) onPick;
|
||||
|
||||
/// Called when the user dismisses the picker without choosing.
|
||||
final VoidCallback onCancel;
|
||||
|
||||
/// Header label. The /effort picker reuses this card with its own title
|
||||
/// and an exact-match [isCurrent] (T-412).
|
||||
final String title;
|
||||
|
||||
/// Marks the active entry. The model default ([modelOptionIsCurrent]) also
|
||||
/// alias-matches (`sonnet` ⊂ `claude-sonnet-4-6`); effort needs exact match
|
||||
/// (`high` would falsely match inside `xhigh`).
|
||||
final bool Function(ModelOption option, String? current) isCurrent;
|
||||
|
||||
@override
|
||||
State<ModelPickerCard> createState() => _ModelPickerCardState();
|
||||
}
|
||||
|
||||
class _ModelPickerCardState extends State<ModelPickerCard> {
|
||||
late int _highlight = _initialHighlight();
|
||||
|
||||
int _initialHighlight() {
|
||||
for (var i = 0; i < widget.models.length; i++) {
|
||||
if (widget.isCurrent(widget.models[i], widget.currentModel)) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
|
||||
if (e is! KeyDownEvent || !node.hasPrimaryFocus) return KeyEventResult.ignored;
|
||||
final hw = HardwareKeyboard.instance;
|
||||
if (hw.isControlPressed || hw.isAltPressed || hw.isMetaPressed) return KeyEventResult.ignored;
|
||||
final key = e.logicalKey;
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
widget.onCancel();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
setState(() => _highlight = (_highlight + 1) % widget.models.length);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
setState(() => _highlight = (_highlight - 1 + widget.models.length) % widget.models.length);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
|
||||
widget.onPick(widget.models[_highlight].value);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final digit = _digitOf(key);
|
||||
if (digit != null && digit >= 1 && digit <= widget.models.length) {
|
||||
widget.onPick(widget.models[digit - 1].value);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
static int? _digitOf(LogicalKeyboardKey key) {
|
||||
const digits = [
|
||||
LogicalKeyboardKey.digit1,
|
||||
LogicalKeyboardKey.digit2,
|
||||
LogicalKeyboardKey.digit3,
|
||||
LogicalKeyboardKey.digit4,
|
||||
LogicalKeyboardKey.digit5,
|
||||
LogicalKeyboardKey.digit6,
|
||||
LogicalKeyboardKey.digit7,
|
||||
LogicalKeyboardKey.digit8,
|
||||
LogicalKeyboardKey.digit9,
|
||||
];
|
||||
const numpad = [
|
||||
LogicalKeyboardKey.numpad1,
|
||||
LogicalKeyboardKey.numpad2,
|
||||
LogicalKeyboardKey.numpad3,
|
||||
LogicalKeyboardKey.numpad4,
|
||||
LogicalKeyboardKey.numpad5,
|
||||
LogicalKeyboardKey.numpad6,
|
||||
LogicalKeyboardKey.numpad7,
|
||||
LogicalKeyboardKey.numpad8,
|
||||
LogicalKeyboardKey.numpad9,
|
||||
];
|
||||
var i = digits.indexOf(key);
|
||||
if (i < 0) i = numpad.indexOf(key);
|
||||
return i < 0 ? null : i + 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: _onKey,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: tokens.panelBackground,
|
||||
border: Border(top: BorderSide(color: tokens.statusInfo, width: 2)),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClideText(widget.title, fontSize: clideFontSmall, fontFamily: clideMonoFamily, color: tokens.statusInfo),
|
||||
const Spacer(),
|
||||
ClideText('↑↓ · 1-${widget.models.length} · Enter · Esc', fontSize: clideFontMeta, fontFamily: clideMonoFamily, color: tokens.globalTextMuted),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
for (var i = 0; i < widget.models.length; i++) _row(tokens, i),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
ClideButton(label: 'cancel', variant: ClideButtonVariant.subtle, onPressed: widget.onCancel),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(SurfaceTokens tokens, int i) {
|
||||
final m = widget.models[i];
|
||||
final current = widget.isCurrent(m, widget.currentModel);
|
||||
final highlighted = i == _highlight;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: ClideButton(
|
||||
label: '${i + 1}. ${current ? '●' : '○'} ${m.displayName}${m.description.isEmpty ? '' : ' — ${m.description}'}',
|
||||
variant: highlighted ? ClideButtonVariant.primary : ClideButtonVariant.subtle,
|
||||
onPressed: () => widget.onPick(m.value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ class SpawnSpec {
|
||||
this.team = false,
|
||||
this.memberName,
|
||||
this.forkSourceSessionId,
|
||||
this.effort,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -81,6 +82,12 @@ class SpawnSpec {
|
||||
/// Takes precedence over [resume]/[sessionId] for arg selection.
|
||||
final String? forkSourceSessionId;
|
||||
|
||||
/// Effort level passed to `claude --effort` (low/medium/high/xhigh/max,
|
||||
/// T-412). Null spawns without the flag — the CLI uses its configured
|
||||
/// default (settings.json `effortLevel`). No set_effort control subtype
|
||||
/// exists, so changing effort means respawn-with-resume carrying this.
|
||||
final String? effort;
|
||||
|
||||
/// Whether this spec spawns a forked session.
|
||||
bool get isFork => forkSourceSessionId != null;
|
||||
}
|
||||
@@ -188,7 +195,28 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
/// (T-269): the existing session belongs to the old repo, so it is torn down
|
||||
/// and a fresh one spawned for the new repo — a pane must never inherit
|
||||
/// another workspace's conversation.
|
||||
Future<ManagedSession> spawn(SpawnSpec spec) async {
|
||||
Future<ManagedSession> spawn(SpawnSpec spec) {
|
||||
// Serialize concurrent spawns per id (T-374): the body check-then-acts
|
||||
// on _sessions across two awaits, so two racing callers would both
|
||||
// pass the check and the loser's live claude process would be orphaned.
|
||||
// The first caller installs the future synchronously; the rest await
|
||||
// it. (A racing different-cwd spawn for the same id also coalesces —
|
||||
// the workspace-switch flow is sequential, so that pair never races.)
|
||||
final inFlight = _spawning[spec.id];
|
||||
if (inFlight != null) return inFlight;
|
||||
final f = _spawn(spec);
|
||||
_spawning[spec.id] = f;
|
||||
unawaited(
|
||||
f.then<void>((_) {}, onError: (Object _) {}).whenComplete(() {
|
||||
if (identical(_spawning[spec.id], f)) _spawning.remove(spec.id);
|
||||
}),
|
||||
);
|
||||
return f;
|
||||
}
|
||||
|
||||
final Map<String, Future<ManagedSession>> _spawning = {};
|
||||
|
||||
Future<ManagedSession> _spawn(SpawnSpec spec) async {
|
||||
final existing = _sessions[spec.id];
|
||||
if (existing != null) {
|
||||
if (existing.cwd == spec.cwd) return existing;
|
||||
@@ -217,7 +245,13 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
|
||||
preambles.add(_teamSystemPrompt(name, spec.role));
|
||||
}
|
||||
final bootstrap = agentBootstrap(spec.cwd, base: spec.env);
|
||||
sessionArgs = ['--append-system-prompt', preambles.join('\n\n'), ...bootstrap.extraArgs, ...sessionArgs];
|
||||
sessionArgs = [
|
||||
'--append-system-prompt',
|
||||
preambles.join('\n\n'),
|
||||
...bootstrap.extraArgs,
|
||||
if (spec.effort != null) ...['--effort', spec.effort!],
|
||||
...sessionArgs,
|
||||
];
|
||||
|
||||
final proc = await _factory(sessionArgs: sessionArgs, cwd: spec.cwd, env: bootstrap.envDelta);
|
||||
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
|
||||
|
||||
@@ -30,8 +30,29 @@ bool isKnownSlashCommand(String text, Iterable<String> known) {
|
||||
/// Slash commands clide handles itself instead of forwarding to Claude:
|
||||
/// Claude Code's own handling forks the session to a new id that clide's
|
||||
/// transcript reader can't follow, so clide owns the semantics (T-156).
|
||||
/// `/fork` branches the current session into a new pane (T-172).
|
||||
const Set<String> kClideOwnedCommands = {'clear', 'resume', 'fork'};
|
||||
/// `/fork` branches the current session into a new pane (T-172). `/model`
|
||||
/// is interactive in the CLI's TUI only — forwarded it does nothing — so
|
||||
/// clide owns it as a set_model control request / picker (T-408). `/effort`
|
||||
/// has no control subtype, so clide owns it as a respawn-with-resume
|
||||
/// carrying `--effort` (T-412). `/permissions` is a picker over
|
||||
/// set_permission_mode; the rest navigate to clide surfaces (T-413):
|
||||
/// /status//config//mcp//agents//hooks → the Claude sidebar tabs,
|
||||
/// /memory → CLAUDE.md in the editor, /help → a local command summary.
|
||||
const Set<String> kClideOwnedCommands = {
|
||||
'clear',
|
||||
'resume',
|
||||
'fork',
|
||||
'model',
|
||||
'effort',
|
||||
'permissions',
|
||||
'status',
|
||||
'config',
|
||||
'mcp',
|
||||
'agents',
|
||||
'hooks',
|
||||
'memory',
|
||||
'help',
|
||||
};
|
||||
|
||||
/// The clide-owned command in [text] (a single-line leading-slash token in
|
||||
/// [kClideOwnedCommands]), or null.
|
||||
@@ -40,6 +61,93 @@ String? clideOwnedCommand(String text) {
|
||||
return token != null && kClideOwnedCommands.contains(token) ? token : null;
|
||||
}
|
||||
|
||||
/// Where slash input goes (T-411). One source of truth so a TUI-only command
|
||||
/// neither errors raw from the CLI nor bracket-pastes to the model as text
|
||||
/// (burning a real turn — observed with /effort on claude 2.1.175).
|
||||
enum SlashRoute {
|
||||
/// clide implements it natively ([kClideOwnedCommands]).
|
||||
owned,
|
||||
|
||||
/// The CLI handles it headless — advertised in the `initialize` handshake's
|
||||
/// `slash_commands` (skills + the headless builtins: compact, context, …).
|
||||
forward,
|
||||
|
||||
/// A known TUI-only builtin: never forwarded; clide shows a local notice
|
||||
/// with the clide-native way ([kTuiOnlyCommands]).
|
||||
unavailable,
|
||||
}
|
||||
|
||||
/// Claude Code TUI-only builtins (probed against 2.1.175: not advertised in
|
||||
/// stream-json, and forwarding would either error "isn't available in this
|
||||
/// environment" or — worse, for un-advertised tokens — bracket-paste to the
|
||||
/// model as literal text). Value = the clide-native pointer shown in the
|
||||
/// notice card. Commands clide later implements move to [kClideOwnedCommands].
|
||||
const Map<String, String> kTuiOnlyCommands = {
|
||||
'effort': '', // owned (T-412) — only routes here if ever removed from owned
|
||||
'status': '', // owned (T-413)
|
||||
'cost': 'cost and context usage live in the Claude sidebar (Activity tab)',
|
||||
'context': '', // advertised on current CLIs — only routes here on older ones
|
||||
'help': '', // owned (T-413)
|
||||
'config': '', // owned (T-413)
|
||||
'permissions': '', // owned (T-413)
|
||||
'memory': '', // owned (T-413)
|
||||
'mcp': '', // owned (T-413)
|
||||
'agents': '', // owned (T-413)
|
||||
'hooks': '', // owned (T-413)
|
||||
'todos': "Claude's task list docks above the composer",
|
||||
'model': '', // owned (T-408) — only routes here if ever removed from owned
|
||||
'doctor': 'run `claude doctor` in a terminal',
|
||||
'login': 'run `claude` in a terminal and use /login there',
|
||||
'logout': 'run `claude` in a terminal and use /logout there',
|
||||
'exit': 'close the pane or switch sessions instead',
|
||||
'vim': 'clide ships its own editor vim mode',
|
||||
'add-dir': '',
|
||||
'bashes': '',
|
||||
'bug': '',
|
||||
'export': '',
|
||||
'fast': '',
|
||||
'ide': "you're already in one",
|
||||
'install-github-app': '',
|
||||
'migrate-installer': '',
|
||||
'output-style': '',
|
||||
'pr-comments': '',
|
||||
'privacy-settings': '',
|
||||
'release-notes': '',
|
||||
'rewind': '',
|
||||
'statusline': '',
|
||||
'terminal-setup': '',
|
||||
'upgrade': '',
|
||||
};
|
||||
|
||||
/// Route [text] (composer input). Null when it isn't slash-command input —
|
||||
/// send it as a normal message. Precedence: owned > advertised > TUI-only
|
||||
/// catalog > forward (unknown tokens stay literal text via bracketed paste).
|
||||
SlashRoute? routeSlashCommand(String text, {required Iterable<String> advertised}) {
|
||||
final token = slashCommandToken(text);
|
||||
if (token == null) return null;
|
||||
if (kClideOwnedCommands.contains(token)) return SlashRoute.owned;
|
||||
if (advertised.contains(token)) return SlashRoute.forward;
|
||||
if (kTuiOnlyCommands.containsKey(token)) return SlashRoute.unavailable;
|
||||
return SlashRoute.forward;
|
||||
}
|
||||
|
||||
/// The notice text for a TUI-only [token] — the CLI's own phrasing plus the
|
||||
/// clide-native pointer when the catalog has one.
|
||||
String tuiOnlyNotice(String token) {
|
||||
final hint = kTuiOnlyCommands[token] ?? '';
|
||||
final base = "/$token is a Claude Code TUI command — it isn't available in clide's conversation pane.";
|
||||
return hint.isEmpty ? base : '$base\n→ $hint';
|
||||
}
|
||||
|
||||
/// The argument text after the command token — `"/model sonnet"` → `"sonnet"`
|
||||
/// — trimmed; empty when there is none (`"/model"`). Null when [text] isn't
|
||||
/// single-line leading-slash input.
|
||||
String? slashCommandArg(String text) {
|
||||
if (slashCommandToken(text) == null) return null;
|
||||
final ws = text.indexOf(RegExp(r'\s'));
|
||||
return ws < 0 ? '' : text.substring(ws + 1).trim();
|
||||
}
|
||||
|
||||
bool _isWs(String c) => c == ' ' || c == '\t' || c == '\n';
|
||||
|
||||
/// An in-progress slash query at the cursor — the `/` position and the word
|
||||
|
||||
@@ -19,8 +19,12 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/builtin/claude/src/workflow_run.dart';
|
||||
import 'package:clide/src/util/value_stream.dart';
|
||||
|
||||
/// The claude subprocess, abstracted so tests drive it without spawning.
|
||||
/// Fakes `extend` this and override what they drive; the defaults below
|
||||
/// describe a process with no real child behind it.
|
||||
abstract class StreamJsonProcess {
|
||||
/// stdout, one JSON event per line.
|
||||
Stream<String> get lines;
|
||||
@@ -30,13 +34,43 @@ abstract class StreamJsonProcess {
|
||||
|
||||
/// Terminate the process.
|
||||
Future<void> kill();
|
||||
|
||||
/// The last lines of the child's stderr, drained continuously so the pipe
|
||||
/// can never fill and block the child mid-turn (T-361). Default: none.
|
||||
List<String> get stderrTail => const [];
|
||||
|
||||
/// Completes with the child's exit code, or null when there is no real
|
||||
/// process to watch (fakes that never "exit").
|
||||
Future<int>? get exitCode => null;
|
||||
}
|
||||
|
||||
/// A bounded FIFO of the most recent lines — the stderr tail kept for
|
||||
/// post-mortem diagnostics while the stream itself is drained and dropped.
|
||||
class BoundedLineBuffer {
|
||||
BoundedLineBuffer({this.cap = 100});
|
||||
|
||||
final int cap;
|
||||
final List<String> _lines = [];
|
||||
|
||||
void add(String line) {
|
||||
_lines.add(line);
|
||||
if (_lines.length > cap) _lines.removeAt(0);
|
||||
}
|
||||
|
||||
List<String> get lines => List.unmodifiable(_lines);
|
||||
}
|
||||
|
||||
/// Production [StreamJsonProcess] backed by a real `claude` process.
|
||||
class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc);
|
||||
class ClaudeStreamJsonProcess extends StreamJsonProcess {
|
||||
ClaudeStreamJsonProcess._(this._proc) {
|
||||
// Drain stderr from the moment the process exists — with --verbose the
|
||||
// CLI chats on stderr, and an undrained 64KB pipe blocks the child
|
||||
// mid-turn with zero diagnostics (T-361). Keep a tail for post-mortems.
|
||||
_proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(_stderr.add, onError: (Object _) {});
|
||||
}
|
||||
|
||||
final Process _proc;
|
||||
final BoundedLineBuffer _stderr = BoundedLineBuffer();
|
||||
|
||||
/// Spawn `claude` in stream-json mode. [sessionArgs] is `['--session-id', id]`
|
||||
/// for a new session or `['--resume', id]` to resume an existing one (T-161).
|
||||
@@ -75,6 +109,12 @@ class ClaudeStreamJsonProcess implements StreamJsonProcess {
|
||||
Future<void> kill() async {
|
||||
_proc.kill();
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get stderrTail => _stderr.lines;
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _proc.exitCode;
|
||||
}
|
||||
|
||||
/// An in-process MCP server clide hosts for a session, entirely over the
|
||||
@@ -108,6 +148,53 @@ abstract class McpServer {
|
||||
Future<Map<String, dynamic>> callTool(String name, Map<String, dynamic> arguments);
|
||||
}
|
||||
|
||||
/// A model selectable for a session, from the `initialize` control_response's
|
||||
/// `models[]` (T-408). Pure data, Flutter-free.
|
||||
class ModelOption {
|
||||
const ModelOption({required this.value, required this.displayName, this.description = ''});
|
||||
|
||||
/// The id/alias sent in `set_model` — e.g. `default`, `sonnet`, `opus`.
|
||||
final String value;
|
||||
|
||||
/// Human label, e.g. `Sonnet`.
|
||||
final String displayName;
|
||||
|
||||
/// One-line blurb shown muted next to the label.
|
||||
final String description;
|
||||
}
|
||||
|
||||
/// Effort levels `claude --effort` accepts (probed against 2.1.175). There is
|
||||
/// NO set_effort control subtype (probed: rejected), so changing effort
|
||||
/// respawns the session with the flag — resume keeps the conversation (T-412).
|
||||
/// Expressed as [ModelOption]s so the /effort picker reuses the /model card.
|
||||
const List<ModelOption> kEffortLevels = [
|
||||
ModelOption(value: 'low', displayName: 'low', description: 'fastest, minimal thinking'),
|
||||
ModelOption(value: 'medium', displayName: 'medium', description: 'balanced'),
|
||||
ModelOption(value: 'high', displayName: 'high', description: 'thorough'),
|
||||
ModelOption(value: 'xhigh', displayName: 'xhigh', description: 'deeper reasoning'),
|
||||
ModelOption(value: 'max', displayName: 'max', description: 'maximum thinking budget'),
|
||||
];
|
||||
|
||||
/// Permission modes for the /permissions picker (T-413), set over the
|
||||
/// set_permission_mode control request. Bypass is last and explicit — the
|
||||
/// footgun stays visible but never the default reach (T-181).
|
||||
const List<ModelOption> kPermissionModes = [
|
||||
ModelOption(value: 'default', displayName: 'default', description: 'ask before sensitive tools'),
|
||||
ModelOption(value: 'acceptEdits', displayName: 'acceptEdits', description: 'auto-approve file edits'),
|
||||
ModelOption(value: 'plan', displayName: 'plan', description: 'read-only planning mode'),
|
||||
ModelOption(value: 'bypassPermissions', displayName: 'bypassPermissions', description: 'no prompts at all — careful'),
|
||||
];
|
||||
|
||||
/// Fallback picker entries for when the `initialize` response hasn't arrived
|
||||
/// (or carried no models): the stable aliases every claude build accepts
|
||||
/// (T-408). `default` resets to the CLI's configured model.
|
||||
const List<ModelOption> kFallbackModels = [
|
||||
ModelOption(value: 'default', displayName: 'Default', description: 'recommended — the CLI\'s configured model'),
|
||||
ModelOption(value: 'sonnet', displayName: 'Sonnet', description: 'fast, great for everyday tasks'),
|
||||
ModelOption(value: 'opus', displayName: 'Opus', description: 'most capable'),
|
||||
ModelOption(value: 'haiku', displayName: 'Haiku', description: 'fastest, lightweight'),
|
||||
];
|
||||
|
||||
/// An interactive prompt Claude is blocked on, from the stream-json control
|
||||
/// channel (a `can_use_tool` control_request) — a tool needing permission, or
|
||||
/// an `AskUserQuestion`. Pure data; the decision goes back via
|
||||
@@ -194,6 +281,15 @@ final class DenyTool extends ToolDecision {
|
||||
|
||||
/// Parses a [StreamJsonProcess]'s events into conversation items + status,
|
||||
/// answers control-channel prompts, and sends user messages.
|
||||
/// Terminal session end: the claude process exited (crash or otherwise).
|
||||
/// Carries the exit code and the drained stderr tail for diagnostics.
|
||||
class SessionEnd {
|
||||
const SessionEnd({required this.exitCode, required this.stderrTail});
|
||||
|
||||
final int exitCode;
|
||||
final List<String> stderrTail;
|
||||
}
|
||||
|
||||
class StreamJsonSession {
|
||||
StreamJsonSession(this._proc, {List<McpServer> mcpServers = const []}) : _mcpServers = mcpServers;
|
||||
|
||||
@@ -204,13 +300,36 @@ class StreamJsonSession {
|
||||
/// round-trips are answered by [_handleMcpMessage].
|
||||
final List<McpServer> _mcpServers;
|
||||
final _items = StreamController<ConversationItem>.broadcast();
|
||||
final _statusCtl = StreamController<SessionStatus>.broadcast();
|
||||
// State, not events — replay-latest so a subscriber that binds after the
|
||||
// init event still sees the current status (T-386; root cause of T-274).
|
||||
final _statusCtl = ValueStream<SessionStatus>();
|
||||
final _sessionIdCtl = StreamController<String>.broadcast();
|
||||
StreamSubscription<String>? _sub;
|
||||
SessionStatus _status = const SessionStatus();
|
||||
String? _claudeSessionId;
|
||||
int _localSeq = 0;
|
||||
|
||||
/// The `initialize` handshake's request id — its control_response carries
|
||||
/// the selectable `models[]` (T-408).
|
||||
String? _initRequestId;
|
||||
|
||||
/// In-flight `set_model` request ids → the model the status held before the
|
||||
/// optimistic merge, so an error response can roll it back (T-408).
|
||||
final _pendingSetModel = <String, String?>{};
|
||||
|
||||
List<ModelOption> _availableModels = const [];
|
||||
|
||||
/// Models selectable for this session, from the `initialize` response.
|
||||
/// Empty until that response arrives (callers fall back to
|
||||
/// [kFallbackModels]).
|
||||
List<ModelOption> get availableModels => _availableModels;
|
||||
|
||||
final _modelErrorCtl = StreamController<String>.broadcast();
|
||||
|
||||
/// Errors from rejected `set_model` requests (e.g. an unknown model name),
|
||||
/// for the pane to surface (T-408).
|
||||
Stream<String> get modelErrors => _modelErrorCtl.stream;
|
||||
|
||||
/// Token-by-token streaming state (T-168, wire shape verified by T-184).
|
||||
///
|
||||
/// With `--include-partial-messages`, claude emits the in-progress reply as
|
||||
@@ -237,7 +356,7 @@ class StreamJsonSession {
|
||||
/// Prompts awaiting a [resolvePrompt] decision, in arrival order. The head
|
||||
/// is the one currently shown in the composer zone.
|
||||
final _queue = <ToolPrompt>[];
|
||||
final _pendingCtl = StreamController<ToolPrompt?>.broadcast();
|
||||
final _pendingCtl = ValueStream<ToolPrompt?>.seeded(null);
|
||||
|
||||
/// tool_use_ids that surfaced as a prompt — the view hides their raw
|
||||
/// tool-use card while pending (it shows as a prompt) but keeps the result.
|
||||
@@ -259,10 +378,24 @@ class StreamJsonSession {
|
||||
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
||||
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
|
||||
|
||||
/// Live Workflow runs, keyed by their launching `Workflow` tool-use id
|
||||
/// (T-416). Accumulated from the out-of-band `system` task_* events the
|
||||
/// harness emits while a workflow runs in the background; the conversation
|
||||
/// card and the sidebar indicator both read this snapshot. Ephemeral — the
|
||||
/// events aren't in the resumed transcript, so this is empty on reload.
|
||||
final _workflows = <String, WorkflowRun>{};
|
||||
final _workflowsCtl = ValueStream<Map<String, WorkflowRun>>.seeded(const {});
|
||||
|
||||
/// The current workflow runs, keyed by launching tool-use id.
|
||||
Map<String, WorkflowRun> get workflows => Map.unmodifiable(_workflows);
|
||||
|
||||
/// Emits the workflow-run map whenever a `system` task event updates it.
|
||||
Stream<Map<String, WorkflowRun>> get workflowsStream => _workflowsCtl.stream;
|
||||
|
||||
/// Whether a turn is in flight (between a send and claude's `result`). Drives
|
||||
/// the composer's Stop affordance.
|
||||
bool _busy = false;
|
||||
final _busyCtl = StreamController<bool>.broadcast();
|
||||
final _busyCtl = ValueStream<bool>.seeded(false);
|
||||
bool get busy => _busy;
|
||||
Stream<bool> get busyStream => _busyCtl.stream;
|
||||
|
||||
@@ -299,25 +432,41 @@ class StreamJsonSession {
|
||||
/// The latest known status — the current value [statusStream] last emitted.
|
||||
SessionStatus get status => _status;
|
||||
|
||||
/// Non-null once the claude process has exited (T-361). Late binders read
|
||||
/// this; live listeners get [endedStream]. Never set by a deliberate
|
||||
/// [dispose] — only by the process dying underneath a live session.
|
||||
SessionEnd? get end => _end;
|
||||
SessionEnd? _end;
|
||||
final _endCtl = StreamController<SessionEnd>.broadcast();
|
||||
bool _disposed = false;
|
||||
|
||||
/// Fires once when the process exits while the session is still live —
|
||||
/// a crashed/dead session must not just look thoughtful (T-361).
|
||||
Stream<SessionEnd> get endedStream => _endCtl.stream;
|
||||
|
||||
/// Begin consuming the process's event stream.
|
||||
void start() {
|
||||
_sub = _proc.lines.listen(_onLine, onError: (Object _) {});
|
||||
// Declaring our in-process MCP servers in the `initialize` handshake is what
|
||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170). Only sent
|
||||
// when we actually host a server, so a plain session is unchanged.
|
||||
if (_mcpServers.isNotEmpty) {
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': 'init-${_localSeq++}',
|
||||
'request': {
|
||||
'subtype': 'initialize',
|
||||
'hooks': <String, dynamic>{},
|
||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Watch the process itself: stdout EOF alone is ambiguous, the exit
|
||||
// code is not (T-361).
|
||||
final exit = _proc.exitCode;
|
||||
if (exit != null) unawaited(exit.then(_onExit));
|
||||
// The `initialize` handshake is side-effect-free (verified in the protocol
|
||||
// spike) and does double duty: declaring our in-process MCP servers is what
|
||||
// makes claude drive their JSON-RPC over `mcp_message` (T-170), and the
|
||||
// response's `models[]` feeds the /model picker (T-408).
|
||||
_initRequestId = 'init-${_localSeq++}';
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': _initRequestId,
|
||||
'request': {
|
||||
'subtype': 'initialize',
|
||||
'hooks': <String, dynamic>{},
|
||||
'sdkMcpServers': [for (final s in _mcpServers) s.name],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _onLine(String line) {
|
||||
@@ -345,6 +494,12 @@ class StreamJsonSession {
|
||||
_onControlRequest(ev);
|
||||
return;
|
||||
}
|
||||
// Responses to OUR control requests: the initialize result (models) and
|
||||
// set_model acks/errors (T-408).
|
||||
if (ev['type'] == 'control_response') {
|
||||
_onControlResponse(ev);
|
||||
return;
|
||||
}
|
||||
// A `result` ends the turn — clear the busy/interruptible state and reset
|
||||
// streaming state so the next turn is fresh.
|
||||
if (ev['type'] == 'result') {
|
||||
@@ -361,6 +516,15 @@ class StreamJsonSession {
|
||||
return;
|
||||
}
|
||||
|
||||
// Workflow run progress (T-416): the harness reports a backgrounded Workflow
|
||||
// tool's fan-out on out-of-band `system` task_* events keyed by the
|
||||
// launching tool-use id. Fold them into the run snapshot and notify; they
|
||||
// carry no conversation item, so don't fall through to the parser.
|
||||
if (isWorkflowSystemEvent(ev)) {
|
||||
_onWorkflowEvent(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalise a streamed reply: when the real text `assistant` event for a
|
||||
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
|
||||
// so the controller replaces the placeholder in place rather than appending
|
||||
@@ -434,6 +598,15 @@ class StreamJsonSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold one workflow `system` task event into its run snapshot, keyed by the
|
||||
/// launching tool-use id, and publish the updated map (T-416).
|
||||
void _onWorkflowEvent(Map<String, dynamic> ev) {
|
||||
final id = ev['tool_use_id'] as String;
|
||||
final prior = _workflows[id] ?? WorkflowRun(toolUseId: id);
|
||||
_workflows[id] = prior.foldEvent(ev);
|
||||
_workflowsCtl.add(Map.unmodifiable(_workflows));
|
||||
}
|
||||
|
||||
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
|
||||
/// item the UI resolves; every other subtype is answered with an error so
|
||||
/// the turn never hangs waiting on us (D-78).
|
||||
@@ -676,6 +849,18 @@ class StreamJsonSession {
|
||||
_setBusy(true);
|
||||
}
|
||||
|
||||
/// Inject a clide-local notice card into the conversation — nothing is sent
|
||||
/// to claude. Used by the slash-command router for TUI-only commands
|
||||
/// (T-411); renders as the muted synthetic "clide" card.
|
||||
void addLocalNotice(String text) {
|
||||
_items.add(AssistantTextMessage(uuid: 'local-${_localSeq++}', timestamp: DateTime.now(), isSidechain: false, text: text, synthetic: true));
|
||||
}
|
||||
|
||||
/// Record the effort level this session was spawned with (`--effort`,
|
||||
/// T-412). The wire never reports effort, so the spawner tells the status
|
||||
/// what it set; the status line / sidebar read it from [SessionStatus].
|
||||
void noteEffort(String level) => _mergeStatus(SessionStatus(effort: level));
|
||||
|
||||
/// Interrupt the running turn (the escape hatch for a runaway — D-78). Sends
|
||||
/// the `interrupt` control_request; claude cancels the current turn and ends
|
||||
/// it with a `result`, which clears [busy]. Safe to call when idle.
|
||||
@@ -712,13 +897,85 @@ class StreamJsonSession {
|
||||
_mergeStatus(SessionStatus(permissionMode: mode));
|
||||
}
|
||||
|
||||
/// Set the model for subsequent turns (T-408). Sends a `set_model`
|
||||
/// control_request; [model] is an alias (`sonnet`, `opus`) or full id, and
|
||||
/// `default` resets to the CLI's configured model. The status merges
|
||||
/// optimistically (mirroring [setPermissionMode]); an error response rolls
|
||||
/// it back and surfaces on [modelErrors].
|
||||
void setModel(String model) {
|
||||
final rid = 'set-model-${_localSeq++}';
|
||||
_pendingSetModel[rid] = _status.model;
|
||||
_proc.writeLine(
|
||||
jsonEncode({
|
||||
'type': 'control_request',
|
||||
'request_id': rid,
|
||||
'request': {'subtype': 'set_model', 'model': model},
|
||||
}),
|
||||
);
|
||||
// `default` resolves to a model only the CLI knows — leave the status to
|
||||
// the next assistant event in that case.
|
||||
if (model != 'default') _mergeStatus(SessionStatus(model: model));
|
||||
}
|
||||
|
||||
/// A `control_response` to one of our requests: capture the initialize
|
||||
/// result's `models[]`, and roll back + surface a rejected set_model (T-408).
|
||||
void _onControlResponse(Map<String, dynamic> ev) {
|
||||
final resp = ev['response'];
|
||||
if (resp is! Map) return;
|
||||
final rid = resp['request_id'] as String?;
|
||||
if (rid == null) return;
|
||||
final isError = resp['subtype'] == 'error';
|
||||
if (rid == _initRequestId && !isError) {
|
||||
final result = resp['response'];
|
||||
final models = result is Map ? result['models'] : null;
|
||||
if (models is List) {
|
||||
_availableModels = List.unmodifiable([
|
||||
for (final m in models)
|
||||
if (m is Map && m['value'] is String)
|
||||
ModelOption(
|
||||
value: m['value'] as String,
|
||||
displayName: m['displayName'] as String? ?? m['value'] as String,
|
||||
description: m['description'] as String? ?? '',
|
||||
),
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_pendingSetModel.containsKey(rid)) {
|
||||
final previous = _pendingSetModel.remove(rid);
|
||||
if (isError) {
|
||||
if (previous != null) _mergeStatus(SessionStatus(model: previous));
|
||||
_modelErrorCtl.add(resp['error'] as String? ?? 'model change rejected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The process exited under a live session. Flip every "in flight"
|
||||
/// surface off so the pane reflects reality instead of spinning forever.
|
||||
void _onExit(int code) {
|
||||
if (_disposed || _end != null) return;
|
||||
_end = SessionEnd(exitCode: code, stderrTail: _proc.stderrTail);
|
||||
_setBusy(false);
|
||||
// A prompt pending against a dead process can never be answered —
|
||||
// clear it so the composer comes back.
|
||||
if (_queue.isNotEmpty) {
|
||||
_queue.clear();
|
||||
_pendingCtl.add(null);
|
||||
}
|
||||
_endCtl.add(_end!);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_disposed = true; // deliberate teardown — suppress the exit-watch path
|
||||
await _sub?.cancel();
|
||||
await _proc.kill();
|
||||
await _items.close();
|
||||
await _statusCtl.close();
|
||||
await _workflowsCtl.close();
|
||||
await _sessionIdCtl.close();
|
||||
await _pendingCtl.close();
|
||||
await _busyCtl.close();
|
||||
await _endCtl.close();
|
||||
await _modelErrorCtl.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
/// Bridges a [TranscriptReader] onto the kernel [MessageBus] (epic T-132,
|
||||
/// D-75).
|
||||
/// Bus addressing for Claude conversation content (epic T-132, D-75).
|
||||
///
|
||||
/// One reader tails a workspace transcript; this publisher republishes
|
||||
/// every [ConversationItem] as a bus [Message]. Any number of Claude
|
||||
/// panels can then subscribe to the same conversation via the bus instead
|
||||
/// of each owning its own reader — the decoupling the team panels
|
||||
/// (T-139/T-140) need, where a single observer feeds the lead tile plus a
|
||||
/// tile per teammate.
|
||||
/// The tmux-era `TranscriptPublisher` that used to live here (one reader
|
||||
/// tailing a transcript, republished onto the bus) had no production
|
||||
/// constructor calls after the stream-json pivot (D-77) and was removed
|
||||
/// in the T-385 dead-code sweep. The [ClaudeConversation] channel/key
|
||||
/// constants remain — the meta sidebar and team panel host still consume
|
||||
/// them for member-status messages.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clide/builtin/claude/src/transcript_reader.dart';
|
||||
import 'package:clide/kernel/src/events/message_bus.dart';
|
||||
|
||||
/// Bus addressing for Claude conversation content.
|
||||
abstract final class ClaudeConversation {
|
||||
@@ -29,7 +25,7 @@ abstract final class ClaudeConversation {
|
||||
/// Channel for a teammate's conversation (team work, T-139/T-140).
|
||||
static String teammateChannel(String agentId) => 'conversation/$agentId';
|
||||
|
||||
/// Key under which the [ConversationItem] travels in a [Message]'s data.
|
||||
/// Key under which the [ConversationItem] travels in a bus message's data.
|
||||
static const itemKey = 'item';
|
||||
|
||||
/// Shared channel carrying each team member's live status (T-157). Every
|
||||
@@ -44,32 +40,3 @@ abstract final class ClaudeConversation {
|
||||
if (status.contextTokens != null) 'contextTokens': status.contextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
class TranscriptPublisher {
|
||||
/// Starts republishing [reader]'s items onto [messages] under
|
||||
/// [ClaudeConversation.publisher] / [channel]. The subscription is
|
||||
/// attached synchronously, so a controller that subscribes before the
|
||||
/// reader's first poll never misses the initial tail.
|
||||
TranscriptPublisher({required MessageBus messages, required TranscriptReader reader, this.channel = ClaudeConversation.leadChannel})
|
||||
: _messages = messages,
|
||||
_reader = reader {
|
||||
_sub = _reader.stream.listen((item) {
|
||||
_messages.publish(ClaudeConversation.publisher, channel, {ClaudeConversation.itemKey: item});
|
||||
});
|
||||
}
|
||||
|
||||
final MessageBus _messages;
|
||||
final TranscriptReader _reader;
|
||||
final String channel;
|
||||
late final StreamSubscription<ConversationItem> _sub;
|
||||
|
||||
/// Live session status (model / permission-mode / context) from the
|
||||
/// underlying reader — passed through for the status strip (T-145).
|
||||
Stream<SessionStatus> get statusStream => _reader.statusStream;
|
||||
|
||||
/// Stops publishing and tears down the underlying reader.
|
||||
Future<void> dispose() async {
|
||||
await _sub.cancel();
|
||||
await _reader.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +114,19 @@ final class AssistantTextMessage extends ConversationItem {
|
||||
super.parentUuid,
|
||||
super.parentToolUseId,
|
||||
required this.text,
|
||||
this.synthetic = false,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
/// CLI-local output, not the model: the wire marks it `model: "<synthetic>"`
|
||||
/// (a forwarded local command's response — /usage output, "/x isn't
|
||||
/// available in this environment", …). clide-injected notices use it too.
|
||||
/// Rendered as a muted "clide" card, never coral Claude prose (T-411).
|
||||
final bool synthetic;
|
||||
|
||||
@override
|
||||
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars)';
|
||||
String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars${synthetic ? ', synthetic' : ''})';
|
||||
}
|
||||
|
||||
/// Extended thinking block from an assistant turn.
|
||||
@@ -426,7 +433,7 @@ class TranscriptReader {
|
||||
/// (T-145, T-168). All fields nullable — a chunk only carries what it saw,
|
||||
/// and the reader [merge]s deltas into a running status.
|
||||
class SessionStatus {
|
||||
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo});
|
||||
const SessionStatus({this.model, this.permissionMode, this.contextTokens, this.cost, this.contextWindow, this.rateLimitInfo, this.effort});
|
||||
|
||||
/// Assistant `message.model`, e.g. `claude-opus-4-7`.
|
||||
final String? model;
|
||||
@@ -451,7 +458,13 @@ class SessionStatus {
|
||||
/// `"rate limited — resets 14:32"` (T-168). Null when not rate-limited.
|
||||
final String? rateLimitInfo;
|
||||
|
||||
bool get isEmpty => model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null;
|
||||
/// The session's effort level (`--effort`, T-412). The wire never reports
|
||||
/// it — clide records what it spawned with via [StreamJsonSession.noteEffort];
|
||||
/// null means the CLI default (settings.json `effortLevel`).
|
||||
final String? effort;
|
||||
|
||||
bool get isEmpty =>
|
||||
model == null && permissionMode == null && contextTokens == null && cost == null && contextWindow == null && rateLimitInfo == null && effort == null;
|
||||
|
||||
/// Overlay [other]'s non-null fields onto this one.
|
||||
SessionStatus merge(SessionStatus other) => SessionStatus(
|
||||
@@ -461,6 +474,7 @@ class SessionStatus {
|
||||
cost: other.cost ?? cost,
|
||||
contextWindow: other.contextWindow ?? contextWindow,
|
||||
rateLimitInfo: other.rateLimitInfo ?? rateLimitInfo,
|
||||
effort: other.effort ?? effort,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -471,10 +485,11 @@ class SessionStatus {
|
||||
other.contextTokens == contextTokens &&
|
||||
other.cost == cost &&
|
||||
other.contextWindow == contextWindow &&
|
||||
other.rateLimitInfo == rateLimitInfo;
|
||||
other.rateLimitInfo == rateLimitInfo &&
|
||||
other.effort == effort;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo);
|
||||
int get hashCode => Object.hash(model, permissionMode, contextTokens, cost, contextWindow, rateLimitInfo, effort);
|
||||
}
|
||||
|
||||
/// Result of [parseTranscriptChunk]: items, version-drift warnings, and
|
||||
@@ -582,7 +597,9 @@ void _extractAssistantStatus(Map<String, dynamic> envelope, _StatusAcc status) {
|
||||
final message = envelope['message'] as Map?;
|
||||
if (message == null) return;
|
||||
final model = message['model'] as String?;
|
||||
if (model != null && model.isNotEmpty) status.model = model;
|
||||
// "<synthetic>" marks CLI-local output (a forwarded local command's
|
||||
// response) — not a model switch; it must not clobber the tracked model.
|
||||
if (model != null && model.isNotEmpty && model != kSyntheticModel) status.model = model;
|
||||
final usage = message['usage'] as Map?;
|
||||
if (usage != null) {
|
||||
int n(String k) => (usage[k] as num?)?.toInt() ?? 0;
|
||||
@@ -656,6 +673,9 @@ void _parseUserInto(
|
||||
}
|
||||
}
|
||||
|
||||
/// The model marker on CLI-local output (forwarded local-command responses).
|
||||
const String kSyntheticModel = '<synthetic>';
|
||||
|
||||
void _parseAssistantInto(
|
||||
Map<String, dynamic> envelope,
|
||||
String uuid,
|
||||
@@ -669,6 +689,7 @@ void _parseAssistantInto(
|
||||
if (message == null) return;
|
||||
final content = message['content'];
|
||||
if (content is! List) return;
|
||||
final synthetic = (message['model'] as String?) == kSyntheticModel;
|
||||
|
||||
for (final item in content) {
|
||||
if (item is! Map) continue;
|
||||
@@ -684,6 +705,7 @@ void _parseAssistantInto(
|
||||
parentUuid: parentUuid,
|
||||
parentToolUseId: parentToolUseId,
|
||||
text: text,
|
||||
synthetic: synthetic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/// Live state of a Claude Code Workflow run (T-416).
|
||||
///
|
||||
/// A Workflow is the harness's multi-agent orchestration tool. The model calls
|
||||
/// it as an ordinary `tool_use` (`name: "Workflow"`, `input: {script}`); the
|
||||
/// tool returns immediately ("launched in background") and the run's real
|
||||
/// progress arrives out-of-band on stream-json `type: "system"` events keyed by
|
||||
/// the launching tool-use id. This file is the pure, Flutter-free model that
|
||||
/// folds those events into a snapshot the conversation/sidebar surfaces render.
|
||||
///
|
||||
/// Wire shape (captured by the T-416 spike, claude 2.1.175):
|
||||
/// - `task_started` — task_id, tool_use_id, description, workflow_name,
|
||||
/// prompt (script source)
|
||||
/// - `task_progress` — usage{total_tokens,tool_uses,duration_ms}, summary,
|
||||
/// and `workflow_progress[]`, a DELTA list mixing
|
||||
/// `{type:"workflow_phase", index, title}` and
|
||||
/// `{type:"workflow_agent", index, label, phaseIndex?,
|
||||
/// phaseTitle?, model, state(start|progress|done),
|
||||
/// agentId?}` — partial, merged by index.
|
||||
/// - `task_updated` — patch{status, end_time}
|
||||
/// - `task_notification` — terminal status:"completed", summary, usage
|
||||
///
|
||||
/// Limit: these events are ephemeral (not persisted to the resumed transcript
|
||||
/// JSONL), so live progress shows during the session; on reload only the tool
|
||||
/// card + its "launched in background" result survive.
|
||||
library;
|
||||
|
||||
/// Lifecycle of a single workflow agent, from its `state` field.
|
||||
enum WorkflowAgentState { start, progress, done, unknown }
|
||||
|
||||
WorkflowAgentState parseWorkflowAgentState(Object? raw) => switch (raw) {
|
||||
'start' || 'queued' || 'running' => WorkflowAgentState.start,
|
||||
'progress' => WorkflowAgentState.progress,
|
||||
'done' || 'complete' || 'completed' => WorkflowAgentState.done,
|
||||
_ => WorkflowAgentState.unknown,
|
||||
};
|
||||
|
||||
/// One phase declared by `meta.phases` / a `phase()` call.
|
||||
class WorkflowPhase {
|
||||
const WorkflowPhase({required this.index, required this.title});
|
||||
|
||||
final int index;
|
||||
final String title;
|
||||
}
|
||||
|
||||
/// One agent fanned out by the workflow. Fields accrete across `task_progress`
|
||||
/// deltas — a later delta fills in `agentId` / upgrades `model` / advances
|
||||
/// `state`, so [mergeDelta] overlays non-null fields onto the prior snapshot.
|
||||
class WorkflowAgent {
|
||||
const WorkflowAgent({
|
||||
required this.index,
|
||||
required this.label,
|
||||
this.model,
|
||||
this.state = WorkflowAgentState.start,
|
||||
this.agentId,
|
||||
this.phaseIndex,
|
||||
this.phaseTitle,
|
||||
});
|
||||
|
||||
final int index;
|
||||
final String label;
|
||||
final String? model;
|
||||
final WorkflowAgentState state;
|
||||
final String? agentId;
|
||||
final int? phaseIndex;
|
||||
final String? phaseTitle;
|
||||
|
||||
/// Fold a raw `workflow_agent` delta entry onto this snapshot, keeping prior
|
||||
/// values where the delta omits a field.
|
||||
WorkflowAgent mergeDelta(Map<String, dynamic> e) => WorkflowAgent(
|
||||
index: index,
|
||||
label: (e['label'] as String?)?.isNotEmpty == true ? e['label'] as String : label,
|
||||
model: (e['model'] as String?) ?? model,
|
||||
state: e.containsKey('state') ? parseWorkflowAgentState(e['state']) : state,
|
||||
agentId: (e['agentId'] as String?) ?? agentId,
|
||||
phaseIndex: (e['phaseIndex'] as num?)?.toInt() ?? phaseIndex,
|
||||
phaseTitle: (e['phaseTitle'] as String?) ?? phaseTitle,
|
||||
);
|
||||
|
||||
static WorkflowAgent fromDelta(Map<String, dynamic> e) => WorkflowAgent(
|
||||
index: (e['index'] as num).toInt(),
|
||||
label: (e['label'] as String?) ?? '',
|
||||
model: e['model'] as String?,
|
||||
state: parseWorkflowAgentState(e['state']),
|
||||
agentId: e['agentId'] as String?,
|
||||
phaseIndex: (e['phaseIndex'] as num?)?.toInt(),
|
||||
phaseTitle: e['phaseTitle'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// An immutable snapshot of one workflow run. [foldEvent] returns a new snapshot
|
||||
/// with a single `system` task event applied (the session keeps one per
|
||||
/// launching tool-use id and replaces it as events arrive).
|
||||
class WorkflowRun {
|
||||
const WorkflowRun({
|
||||
required this.toolUseId,
|
||||
this.taskId,
|
||||
this.name,
|
||||
this.description,
|
||||
this.summary,
|
||||
this.done = false,
|
||||
this.totalTokens,
|
||||
this.toolUses,
|
||||
this.durationMs,
|
||||
this.phases = const {},
|
||||
this.agents = const {},
|
||||
});
|
||||
|
||||
/// The launching `Workflow` tool-use id — the join key to the conversation
|
||||
/// card and across all of this run's system events.
|
||||
final String toolUseId;
|
||||
|
||||
/// The harness task id (e.g. `wy01fihjt`), assigned at `task_started`.
|
||||
final String? taskId;
|
||||
|
||||
/// `workflow_name` from `meta.name`.
|
||||
final String? name;
|
||||
final String? description;
|
||||
final String? summary;
|
||||
|
||||
/// True once a `task_updated{status:completed}` or `task_notification`
|
||||
/// terminal event lands.
|
||||
final bool done;
|
||||
|
||||
final int? totalTokens;
|
||||
final int? toolUses;
|
||||
final int? durationMs;
|
||||
|
||||
/// Phase index → phase. Empty for a phase-less workflow.
|
||||
final Map<int, WorkflowPhase> phases;
|
||||
|
||||
/// Agent index → agent snapshot.
|
||||
final Map<int, WorkflowAgent> agents;
|
||||
|
||||
bool get running => !done;
|
||||
int get agentCount => agents.length;
|
||||
int get doneCount => agents.values.where((a) => a.state == WorkflowAgentState.done).length;
|
||||
|
||||
/// Agents in index order — the order the script fanned them out.
|
||||
List<WorkflowAgent> get orderedAgents {
|
||||
final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Phases in index order.
|
||||
List<WorkflowPhase> get orderedPhases {
|
||||
final list = phases.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||
return list;
|
||||
}
|
||||
|
||||
WorkflowRun _copyWith({
|
||||
String? taskId,
|
||||
String? name,
|
||||
String? description,
|
||||
String? summary,
|
||||
bool? done,
|
||||
int? totalTokens,
|
||||
int? toolUses,
|
||||
int? durationMs,
|
||||
Map<int, WorkflowPhase>? phases,
|
||||
Map<int, WorkflowAgent>? agents,
|
||||
}) => WorkflowRun(
|
||||
toolUseId: toolUseId,
|
||||
taskId: taskId ?? this.taskId,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
summary: summary ?? this.summary,
|
||||
done: done ?? this.done,
|
||||
totalTokens: totalTokens ?? this.totalTokens,
|
||||
toolUses: toolUses ?? this.toolUses,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
phases: phases ?? this.phases,
|
||||
agents: agents ?? this.agents,
|
||||
);
|
||||
|
||||
/// Apply one `system` task event ([ev]) and return the updated snapshot.
|
||||
/// [ev] must already be the decoded envelope; unknown subtypes return `this`.
|
||||
WorkflowRun foldEvent(Map<String, dynamic> ev) {
|
||||
switch (ev['subtype']) {
|
||||
case 'task_started':
|
||||
return _copyWith(taskId: ev['task_id'] as String?, name: ev['workflow_name'] as String?, description: ev['description'] as String?);
|
||||
case 'task_progress':
|
||||
return _foldProgress(ev);
|
||||
case 'task_updated':
|
||||
final patch = ev['patch'];
|
||||
final status = patch is Map ? patch['status'] as String? : null;
|
||||
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null);
|
||||
case 'task_notification':
|
||||
final status = ev['status'] as String?;
|
||||
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null, summary: ev['summary'] as String?)._foldUsage(ev['usage']);
|
||||
default:
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
WorkflowRun _foldProgress(Map<String, dynamic> ev) {
|
||||
final phases = Map<int, WorkflowPhase>.from(this.phases);
|
||||
final agents = Map<int, WorkflowAgent>.from(this.agents);
|
||||
final progress = ev['workflow_progress'];
|
||||
if (progress is List) {
|
||||
for (final raw in progress) {
|
||||
if (raw is! Map) continue;
|
||||
final e = raw.cast<String, dynamic>();
|
||||
final idx = (e['index'] as num?)?.toInt();
|
||||
if (idx == null) continue;
|
||||
switch (e['type']) {
|
||||
case 'workflow_phase':
|
||||
phases[idx] = WorkflowPhase(index: idx, title: (e['title'] as String?) ?? 'phase $idx');
|
||||
case 'workflow_agent':
|
||||
final prior = agents[idx];
|
||||
agents[idx] = prior != null ? prior.mergeDelta(e) : WorkflowAgent.fromDelta(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
return _copyWith(summary: ev['summary'] as String?, phases: phases, agents: agents)._foldUsage(ev['usage']);
|
||||
}
|
||||
|
||||
WorkflowRun _foldUsage(Object? usage) {
|
||||
if (usage is! Map) return this;
|
||||
return _copyWith(
|
||||
totalTokens: (usage['total_tokens'] as num?)?.toInt(),
|
||||
toolUses: (usage['tool_uses'] as num?)?.toInt(),
|
||||
durationMs: (usage['duration_ms'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `system` subtypes that carry workflow run progress (T-416). Other system
|
||||
/// subtypes (`init`, `hook_*`, `thinking_tokens`) are unrelated and left alone.
|
||||
const Set<String> kWorkflowSystemSubtypes = {'task_started', 'task_progress', 'task_updated', 'task_notification'};
|
||||
|
||||
/// True when [ev] is a `system` event carrying workflow run progress that names
|
||||
/// a launching tool-use id we can key on.
|
||||
bool isWorkflowSystemEvent(Map<String, dynamic> ev) =>
|
||||
ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true;
|
||||
@@ -53,6 +53,23 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
// Editor split (D-049, D-054)
|
||||
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor),
|
||||
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor),
|
||||
// Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every
|
||||
// preset; the vim preset additionally binds gt/gT to these (T-405 part 2,
|
||||
// once a global multi-chord matcher lands — see T-404).
|
||||
CommandContribution(
|
||||
id: 'workspace.tab.next',
|
||||
command: 'workspace.tab.next',
|
||||
title: 'Next Workspace Tab',
|
||||
defaultBinding: 'ctrl+pagedown',
|
||||
run: _nextWorkspaceTab,
|
||||
),
|
||||
CommandContribution(
|
||||
id: 'workspace.tab.previous',
|
||||
command: 'workspace.tab.previous',
|
||||
title: 'Previous Workspace Tab',
|
||||
defaultBinding: 'ctrl+pageup',
|
||||
run: _prevWorkspaceTab,
|
||||
),
|
||||
// Sidebar section switching (D-054): alt+1 through alt+5
|
||||
for (var i = 0; i < 5; i++)
|
||||
CommandContribution(
|
||||
@@ -195,6 +212,27 @@ class DefaultLayoutExtension extends ClideExtension {
|
||||
return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _nextWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: true);
|
||||
Future<IpcResponse> _prevWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: false);
|
||||
|
||||
/// Cycle the workspace tab strip with wraparound (T-405). A no-op when there
|
||||
/// are fewer than two tabs. Activating a tab also focuses the workspace slot
|
||||
/// so the newly-shown pane takes keyboard focus.
|
||||
Future<IpcResponse> _cycleWorkspaceTab({required bool forward}) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
final tabs = ctx.panels.tabsFor(Slots.workspace);
|
||||
if (tabs.length < 2) return IpcResponse.ok(id: '', data: const {'cycled': false});
|
||||
final active = ctx.panels.activeTabIn(Slots.workspace);
|
||||
final cur = tabs.indexWhere((t) => t.id == active);
|
||||
final start = cur < 0 ? 0 : cur;
|
||||
final next = (start + (forward ? 1 : -1) + tabs.length) % tabs.length;
|
||||
final nextId = tabs[next].id;
|
||||
ctx.panels.activateTab(Slots.workspace, nextId);
|
||||
ctx.focus.setActive(slot: Slots.workspace, contributionId: nextId);
|
||||
return IpcResponse.ok(id: '', data: {'active': nextId});
|
||||
}
|
||||
|
||||
Future<IpcResponse> _focusRight(List<String> args) async {
|
||||
final ctx = _ctx;
|
||||
if (ctx == null) return _notActivated();
|
||||
|
||||
@@ -54,10 +54,16 @@ class _EditorViewState extends State<EditorView> {
|
||||
super.initState();
|
||||
_text = SyntaxTextController(syntax: _syntax);
|
||||
_focus = FocusNode();
|
||||
_focus.addListener(_onFocusChanged);
|
||||
_text.addListener(_onTextChanged);
|
||||
_tabs.addListener(_onTabsChanged);
|
||||
}
|
||||
|
||||
/// Publish `editor.focused` so non-editor panes can guard their vim nav
|
||||
/// bindings (`!editor.focused`) — when the editor holds focus, j/k/h/l/gg/G
|
||||
/// stay buffer motions; when a pane holds focus they become nav (T-406).
|
||||
void _onFocusChanged() => _keymap?.setScopeFlag('editor.focused', _focus.hasFocus);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -75,12 +81,14 @@ class _EditorViewState extends State<EditorView> {
|
||||
void dispose() {
|
||||
_text.removeListener(_onTextChanged);
|
||||
_text.dispose();
|
||||
_focus.removeListener(_onFocusChanged);
|
||||
_focus.dispose();
|
||||
_tabs.removeListener(_onTabsChanged);
|
||||
_tabs.dispose();
|
||||
_controller?.removeListener(_onControllerChanged);
|
||||
_controller?.dispose();
|
||||
_keymap?.removeListener(_onModeChanged);
|
||||
_keymap?.clearScopeFlag('editor.focused');
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,26 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// One row in the flattened, currently-visible tree (T-406). The visible set is
|
||||
/// a pre-order walk of the root plus the children of every expanded directory —
|
||||
/// the same order the tree renders — so a selection cursor can move over it with
|
||||
/// j/k.
|
||||
@immutable
|
||||
class TreeNode {
|
||||
const TreeNode({required this.path, required this.name, required this.isDirectory, required this.depth});
|
||||
|
||||
final String path;
|
||||
final String name;
|
||||
final bool isDirectory;
|
||||
final int depth;
|
||||
}
|
||||
|
||||
class FileTreeController extends ChangeNotifier {
|
||||
FileTreeController({required this.ipc, required this.events}) {
|
||||
_eventSub = events.on<DaemonEvent>().listen(_onEvent);
|
||||
@@ -38,6 +53,105 @@ class FileTreeController extends ChangeNotifier {
|
||||
final Map<String, List<FileEntry>> _entries = {};
|
||||
List<FileEntry>? entriesFor(String path) => _entries[path];
|
||||
|
||||
/// Display name of the workspace root row ('' path).
|
||||
String get rootName => _rootPath?.split(Platform.pathSeparator).last ?? '';
|
||||
|
||||
// -- Keyboard selection cursor (T-406) -------------------------------------
|
||||
|
||||
/// The path of the currently selected row, or null when nothing is selected.
|
||||
/// '' is the workspace-root row.
|
||||
String? _selectedPath;
|
||||
String? get selectedPath => _selectedPath;
|
||||
|
||||
/// The flattened, currently-visible rows in render order: the root, then the
|
||||
/// children of every expanded directory, depth-first.
|
||||
List<TreeNode> visibleNodes() {
|
||||
final out = <TreeNode>[];
|
||||
if (_rootPath == null) return out;
|
||||
out.add(TreeNode(path: '', name: rootName, isDirectory: true, depth: 0));
|
||||
if (isExpanded('')) _appendChildren('', 1, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
void _appendChildren(String path, int depth, List<TreeNode> out) {
|
||||
final entries = _entries[path];
|
||||
if (entries == null) return;
|
||||
for (final e in entries) {
|
||||
out.add(TreeNode(path: e.path, name: e.name, isDirectory: e.isDirectory, depth: depth));
|
||||
if (e.isDirectory && _expanded.contains(e.path)) _appendChildren(e.path, depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
TreeNode? _selectedNode([List<TreeNode>? nodes]) {
|
||||
final list = nodes ?? visibleNodes();
|
||||
for (final n in list) {
|
||||
if (n.path == _selectedPath) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Move the selection cursor [delta] rows (negative = up), clamped to the
|
||||
/// visible list. A first move with nothing selected lands on the first row
|
||||
/// (down) or last row (up).
|
||||
void moveSelection(int delta) {
|
||||
final nodes = visibleNodes();
|
||||
if (nodes.isEmpty) return;
|
||||
final cur = nodes.indexWhere((n) => n.path == _selectedPath);
|
||||
final next = cur < 0 ? (delta > 0 ? 0 : nodes.length - 1) : (cur + delta).clamp(0, nodes.length - 1);
|
||||
if (nodes[next].path == _selectedPath) return;
|
||||
_selectedPath = nodes[next].path;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Select the first ([top]) or last visible row — vim gg / G.
|
||||
void selectEdge({required bool top}) {
|
||||
final nodes = visibleNodes();
|
||||
if (nodes.isEmpty) return;
|
||||
final path = (top ? nodes.first : nodes.last).path;
|
||||
if (path == _selectedPath) return;
|
||||
_selectedPath = path;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Collapse the selected directory, or — if it's already collapsed (or a
|
||||
/// file) — step the selection out to its parent row (vim `h`).
|
||||
Future<void> collapseOrOut() async {
|
||||
final node = _selectedNode();
|
||||
if (node == null) return;
|
||||
if (node.isDirectory && node.path != '' && _expanded.contains(node.path)) {
|
||||
await toggle(node.path); // collapse in place; selection stays on the dir
|
||||
return;
|
||||
}
|
||||
if (node.path == '') return; // already at root
|
||||
_selectedPath = _parentOf(node.path);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Expand the selected directory, or — if it's already expanded — step the
|
||||
/// selection into its first child (vim `l`). A file is a no-op.
|
||||
Future<void> expandOrInto() async {
|
||||
final node = _selectedNode();
|
||||
if (node == null || !node.isDirectory) return;
|
||||
if (!_expanded.contains(node.path)) {
|
||||
await toggle(node.path); // expand
|
||||
return;
|
||||
}
|
||||
final children = _entries[node.path];
|
||||
if (children != null && children.isNotEmpty) {
|
||||
_selectedPath = children.first.path;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the selected row to an action target for the view: a directory to
|
||||
/// toggle, or a file path to open (vim `o` / `enter`). Returns null when
|
||||
/// nothing is selected.
|
||||
({bool isDirectory, String path})? activateTarget() {
|
||||
final node = _selectedNode();
|
||||
if (node == null) return null;
|
||||
return (isDirectory: node.isDirectory, path: node.path);
|
||||
}
|
||||
|
||||
List<FileEntry> allLoadedEntries() {
|
||||
final out = <FileEntry>[];
|
||||
for (final list in _entries.values) {
|
||||
|
||||
@@ -26,6 +26,14 @@ class FileTreeView extends StatefulWidget {
|
||||
class _FileTreeViewState extends State<FileTreeView> {
|
||||
FileTreeController? _controller;
|
||||
String _filter = '';
|
||||
final ScrollController _scroll = ScrollController();
|
||||
|
||||
/// Key on the currently-selected row, so a keyboard move can scroll it into
|
||||
/// view (T-406).
|
||||
final GlobalKey _selectedKey = GlobalKey();
|
||||
|
||||
/// Half-page step for ctrl+d / ctrl+u over the flattened tree.
|
||||
static const int _pageStep = 10;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
@@ -39,9 +47,52 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onNav(NavIntent intent, int count, FileTreeController c) {
|
||||
switch (intent) {
|
||||
case NavDownIntent():
|
||||
c.moveSelection(count);
|
||||
case NavUpIntent():
|
||||
c.moveSelection(-count);
|
||||
case NavPageDownIntent():
|
||||
c.moveSelection(_pageStep);
|
||||
case NavPageUpIntent():
|
||||
c.moveSelection(-_pageStep);
|
||||
case NavTopIntent():
|
||||
c.selectEdge(top: true);
|
||||
case NavBottomIntent():
|
||||
c.selectEdge(top: false);
|
||||
case NavExpandOrRightIntent():
|
||||
unawaited(c.expandOrInto());
|
||||
case NavCollapseOrLeftIntent():
|
||||
unawaited(c.collapseOrOut());
|
||||
case NavActivateIntent():
|
||||
_activateSelected(c);
|
||||
}
|
||||
}
|
||||
|
||||
void _activateSelected(FileTreeController c) {
|
||||
final t = c.activateTarget();
|
||||
if (t == null) return;
|
||||
if (t.isDirectory) {
|
||||
unawaited(c.toggle(t.path));
|
||||
} else {
|
||||
openWorkspaceFile(ClideKernel.of(context), t.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the selected row into view after the frame it's laid out in.
|
||||
void _ensureSelectedVisible() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final ctx = _selectedKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
Scrollable.ensureVisible(ctx, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: const Duration(milliseconds: 80));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = _controller;
|
||||
@@ -57,6 +108,23 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('Loading…', muted: true));
|
||||
}
|
||||
final rootName = root.split(Platform.pathSeparator).last;
|
||||
final selected = c.selectedPath;
|
||||
if (_filter.isEmpty && selected != null) _ensureSelectedVisible();
|
||||
final scroller = SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0, selectedPath: selected, selectedKey: _selectedKey),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1, selectedPath: selected, selectedKey: _selectedKey),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
ClideFilterBox(address: 'files.tree', hint: 'Filter files…', onChanged: (v) => setState(() => _filter = v)),
|
||||
@@ -65,20 +133,10 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
label: 'file tree — $rootName',
|
||||
container: true,
|
||||
explicitChildNodes: true,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_filter.isEmpty) ...[
|
||||
_DirRow(name: rootName, path: '', controller: c, depth: 0),
|
||||
if (c.isExpanded('')) _Children(path: '', controller: c, depth: 1),
|
||||
] else
|
||||
..._filteredEntries(c),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Vim nav (j/k/h/l/gg/G/o) drives a selection cursor while this
|
||||
// region holds focus under the vim preset (T-406). The filter
|
||||
// box sits outside it, so typing a filter is never intercepted.
|
||||
child: _filter.isEmpty ? PaneKeyNav(onNav: (intent, count) => _onNav(intent, count, c), child: scroller) : scroller,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -97,11 +155,13 @@ class _FileTreeViewState extends State<FileTreeView> {
|
||||
}
|
||||
|
||||
class _Children extends StatelessWidget {
|
||||
const _Children({required this.path, required this.controller, required this.depth});
|
||||
const _Children({required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -117,58 +177,67 @@ class _Children extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth),
|
||||
if (controller.isExpanded(e.path)) _Children(path: e.path, controller: controller, depth: depth + 1),
|
||||
_DirRow(name: e.name, path: e.path, controller: controller, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
if (controller.isExpanded(e.path))
|
||||
_Children(path: e.path, controller: controller, depth: depth + 1, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
],
|
||||
)
|
||||
else
|
||||
_FileRow(name: e.name, path: e.path, depth: depth),
|
||||
_FileRow(name: e.name, path: e.path, depth: depth, selectedPath: selectedPath, selectedKey: selectedKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DirRow extends StatelessWidget {
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth});
|
||||
const _DirRow({required this.name, required this.path, required this.controller, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final FileTreeController controller;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expanded = controller.isExpanded(path);
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '${expanded ? 'Collapse' : 'Expand'} $name',
|
||||
onTap: () => controller.toggle(path),
|
||||
child: _Row(
|
||||
key: selected ? selectedKey : null,
|
||||
depth: depth,
|
||||
onTap: () => controller.toggle(path),
|
||||
leading: ClideIcon(const ChevronRightIcon(), size: 10, color: tokens.sidebarForeground),
|
||||
label: name,
|
||||
rotateLeading: expanded,
|
||||
selected: selected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
const _FileRow({required this.name, required this.path, required this.depth});
|
||||
const _FileRow({required this.name, required this.path, required this.depth, this.selectedPath, this.selectedKey});
|
||||
|
||||
final String name;
|
||||
final String path;
|
||||
final int depth;
|
||||
final String? selectedPath;
|
||||
final Key? selectedKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = path == selectedPath;
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Open $name',
|
||||
onTap: () => _openFile(context, path),
|
||||
child: _Row(depth: depth, onTap: () => _openFile(context, path), label: name),
|
||||
child: _Row(key: selected ? selectedKey : null, depth: depth, onTap: () => _openFile(context, path), label: name, selected: selected),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,7 +249,7 @@ class _FileRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false});
|
||||
const _Row({super.key, required this.depth, required this.onTap, required this.label, this.leading, this.rotateLeading = false, this.selected = false});
|
||||
|
||||
final int depth;
|
||||
final VoidCallback onTap;
|
||||
@@ -188,6 +257,10 @@ class _Row extends StatelessWidget {
|
||||
final Widget? leading;
|
||||
final bool rotateLeading;
|
||||
|
||||
/// True when the keyboard selection cursor is on this row (T-406) — draws a
|
||||
/// persistent highlight + accent ring, distinct from transient hover.
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
@@ -195,7 +268,12 @@ class _Row extends StatelessWidget {
|
||||
return ClideTappable(
|
||||
onTap: onTap,
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.sidebarItemHover : null,
|
||||
decoration: selected
|
||||
? BoxDecoration(
|
||||
color: tokens.sidebarItemHover,
|
||||
border: Border.all(color: tokens.globalFocus, width: 1),
|
||||
)
|
||||
: (hovered ? BoxDecoration(color: tokens.sidebarItemHover) : null),
|
||||
padding: EdgeInsets.only(left: leftPadding, right: 8, top: 3, bottom: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class GraphView extends StatefulWidget {
|
||||
const GraphView({super.key});
|
||||
|
||||
@override
|
||||
State<GraphView> createState() => _GraphViewState();
|
||||
}
|
||||
|
||||
class _GraphViewState extends State<GraphView> {
|
||||
List<_GraphNode> _nodes = [];
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_loading || _nodes.isNotEmpty) return;
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final kernel = ClideKernel.of(context);
|
||||
final resp = await kernel.ipc.request(
|
||||
'pql.exec',
|
||||
args: {
|
||||
'argv': ['search', '--connections', '--limit', '50'],
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!resp.ok) {
|
||||
setState(() {
|
||||
_error = resp.error?.message ?? 'failed to load graph';
|
||||
_loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final raw = resp.data['stdout'] as String? ?? '[]';
|
||||
try {
|
||||
final list = (jsonDecode(raw) as List).cast<Map<String, dynamic>>();
|
||||
setState(() {
|
||||
_nodes = list.map(_GraphNode.fromJson).toList();
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = 'parse error: $e';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
if (_loading) {
|
||||
return const Center(child: ClideText('Loading graph...', muted: true));
|
||||
}
|
||||
if (_error != null) {
|
||||
return Padding(padding: const EdgeInsets.all(12), child: ClideText(_error!, muted: true));
|
||||
}
|
||||
if (_nodes.isEmpty) {
|
||||
return const Padding(padding: EdgeInsets.all(12), child: ClideText('No linked files found.\nAdd wikilinks to your markdown files.', muted: true));
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: _nodes.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final n = _nodes[i];
|
||||
return _NodeRow(node: n, tokens: tokens);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GraphNode {
|
||||
const _GraphNode({required this.path, this.inbound = 0, this.outbound = 0});
|
||||
final String path;
|
||||
final int inbound;
|
||||
final int outbound;
|
||||
|
||||
factory _GraphNode.fromJson(Map<String, dynamic> json) => _GraphNode(
|
||||
path: json['path'] as String? ?? json['relative_path'] as String? ?? '',
|
||||
inbound: (json['inbound_count'] as num?)?.toInt() ?? 0,
|
||||
outbound: (json['outbound_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _NodeRow extends StatelessWidget {
|
||||
const _NodeRow({required this.node, required this.tokens});
|
||||
final _GraphNode node;
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClideTappable(
|
||||
builder: (context, hovered, _) => Container(
|
||||
color: hovered ? tokens.listItemHoverBackground : null,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: ClideText(node.path, fontSize: clideFontCaption)),
|
||||
ClideText('${node.inbound}in ${node.outbound}out', color: tokens.globalTextMuted, fontSize: clideFontSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
String? _error;
|
||||
int _pid = 0;
|
||||
|
||||
/// Cached in didChangeDependencies — ancestor lookups are illegal in
|
||||
/// dispose(), and the old lookup-and-swallow there meant pane.close
|
||||
/// was never sent, leaking the backend PTY + daemon pane (T-366).
|
||||
KernelServices? _kernel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -45,6 +50,12 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _spawn());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_kernel = ClideKernel.of(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventSub?.cancel();
|
||||
@@ -53,14 +64,14 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
_paneId = null;
|
||||
if (id != null) {
|
||||
// Fire-and-forget. Daemon-side pane.close is idempotent.
|
||||
unawaited(_kernelIpc()?.request('pane.close', args: {'id': id}));
|
||||
unawaited(_kernel?.ipc.request('pane.close', args: {'id': id}));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _spawn() async {
|
||||
if (!mounted) return;
|
||||
final ipc = _kernelIpc();
|
||||
final ipc = _kernel?.ipc;
|
||||
if (ipc == null || !ipc.isConnected) {
|
||||
setState(() => _error = 'Backend not connected.');
|
||||
return;
|
||||
@@ -71,7 +82,9 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
// fallback.
|
||||
final shell = Platform.isWindows ? null : (Platform.environment['SHELL'] ?? '/bin/bash');
|
||||
final argv = shell != null ? [shell, '-l'] : ['powershell.exe', '-NoLogo'];
|
||||
final cwd = Directory.current.path;
|
||||
// The open workspace, not Directory.current — a desktop launch starts
|
||||
// in $HOME and a project switch doesn't move the process CWD (T-381).
|
||||
final cwd = _kernel?.project.current?.path ?? Directory.current.path;
|
||||
|
||||
final response = await ipc.request(
|
||||
'pane.spawn',
|
||||
@@ -90,7 +103,7 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
}
|
||||
|
||||
void _subscribeToPaneEvents() {
|
||||
final kernel = _kernel();
|
||||
final kernel = _kernel;
|
||||
if (kernel == null) return;
|
||||
_eventSub = kernel.events.on<DaemonEvent>().listen((event) {
|
||||
if (event.subsystem != 'pane') return;
|
||||
@@ -99,8 +112,9 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
case 'pane.output':
|
||||
final b64 = event.data['bytes_b64'];
|
||||
if (b64 is String) {
|
||||
final bytes = base64Decode(b64);
|
||||
_terminal.write(utf8.decode(bytes, allowMalformed: true));
|
||||
// writeBytes keeps UTF-8 decode state across chunks — a rune
|
||||
// split across PTY reads must not become U+FFFD (T-373).
|
||||
_terminal.writeBytes(base64Decode(b64));
|
||||
}
|
||||
case 'pane.exit':
|
||||
setState(() => _error = 'Shell exited.');
|
||||
@@ -115,23 +129,13 @@ class _TerminalPaneState extends State<TerminalPane> {
|
||||
void _onTerminalOutput(String text) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.write', args: {'id': id, 'text': text});
|
||||
_kernel?.ipc.request('pane.write', args: {'id': id, 'text': text});
|
||||
}
|
||||
|
||||
void _onTerminalResize(int cols, int rows, int pixelWidth, int pixelHeight) {
|
||||
final id = _paneId;
|
||||
if (id == null) return;
|
||||
_kernelIpc()?.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
}
|
||||
|
||||
DaemonClient? _kernelIpc() => _kernel()?.ipc;
|
||||
|
||||
KernelServices? _kernel() {
|
||||
try {
|
||||
return ClideKernel.of(context);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
_kernel?.ipc.request('pane.resize', args: {'id': id, 'cols': cols, 'rows': rows});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -74,13 +74,16 @@ class _TipsCard extends StatelessWidget {
|
||||
const _TipsCard({required this.tokens});
|
||||
final SurfaceTokens tokens;
|
||||
|
||||
// Every tip mirrors a binding that actually exists in the default
|
||||
// preset / contributed commands (T-383) — ctrl-based on the shipped
|
||||
// default keymap, hence ⌃ glyphs. If a binding moves, move the tip.
|
||||
static const _tips = <(String, String)>[
|
||||
('Quick open', '⌘P'),
|
||||
('Command palette', '⌘⇧P'),
|
||||
('Toggle sidebar', '⌘B'),
|
||||
('Toggle context', '⌘J'),
|
||||
('Switch theme', '⌘K ⌘T'),
|
||||
('New Claude session', '⌘⇧C'),
|
||||
('Quick open', '⌃P'),
|
||||
('Command palette', '⌃⇧P'),
|
||||
('Toggle sidebar', '⌃⇧1'),
|
||||
('Toggle context', '⌃⇧3'),
|
||||
('Find in files', '⌃⇧F'),
|
||||
('Focus mode', '⌃.'),
|
||||
];
|
||||
|
||||
@override
|
||||
@@ -169,9 +172,10 @@ class _StartColumn extends StatelessWidget {
|
||||
children: [
|
||||
ClideText('START', fontSize: clideFontSmall, color: tokens.sidebarSectionHeader, fontFamily: clideMonoFamily),
|
||||
const SizedBox(height: 20),
|
||||
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌘O', tokens: tokens, onTap: () => _openFolder(context)),
|
||||
_ActionRow(icon: PhosphorIcons.byName('git-branch'), label: 'Clone from git…', shortcut: '⌘G', tokens: tokens, onTap: () {}),
|
||||
_ActionRow(icon: PhosphorIcons.byName('chat-circle'), label: 'Start a Claude session', shortcut: '⌘C', tokens: tokens, onTap: () {}),
|
||||
// Only flows that exist get a tile — the old Clone-from-git and
|
||||
// Start-a-Claude-session rows were inert and advertised shortcuts
|
||||
// that were never registered (T-383). Re-add each WITH its flow.
|
||||
_ActionRow(icon: PhosphorIcons.byName('folder'), label: 'Open folder…', shortcut: '⌃O', tokens: tokens, onTap: () => _openFolder(context)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user