add clide ui open to drive GUI readers from the CLI (T-231)
The drive-half complement to clide status (observe): an agent can now open a doc in a GUI reader from the CLI so it can show the user what it's looking at. 'clide ui open tickets T-48' (decisions by id, markdown by path) publishes a 'selection' to the kernel MessageBus that the reader's ReaderNav picks up. The verb (ui_command.dart) is decoupled from the kernel via a publish callback, wired in main.dart to the post-boot-captured MessageBus, so it stays Flutter-free under dart test. Reads CLI positionals or named args. Also files T-232 (CLI argv args don't reach typed handlers -- the gating drive-the-IDE bug) and T-233 (diff-panel ui.open follow-up). Closes T-231 (under T-208). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
import 'package:clide/src/daemon/status_command.dart';
|
||||
import 'package:clide/src/daemon/ui_command.dart';
|
||||
import 'package:clide/src/daemon/panel_commands.dart';
|
||||
import 'package:clide/src/daemon/panel_resizer_kernel.dart';
|
||||
import 'package:clide/src/daemon/pql_commands.dart';
|
||||
@@ -92,6 +93,9 @@ Future<void> main() async {
|
||||
// Captured after boot so `clide status` can report the read-only reader's
|
||||
// viewed doc (D-81), which isn't an editor buffer (T-221).
|
||||
ReaderNavRegistry? kernelReaderNav;
|
||||
// The kernel MessageBus, captured post-boot so `ui.open` can drive the GUI
|
||||
// readers (publish a 'selection') from the CLI — the drive-half of D-6 (T-231).
|
||||
MessageBus? kernelMessages;
|
||||
// IPC socket server (T-99 / T-124, per D-70/71/72). One server per
|
||||
// workspace; restarted when the active project switches because the
|
||||
// socket path is workspace-derived. The local DaemonClient connects
|
||||
@@ -207,6 +211,10 @@ Future<void> main() async {
|
||||
final pql = PqlClient(workDir: workRoot, toolchain: tc);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
registerPanelCommands(dispatcher, ArrangementPanelResizer(arrangement));
|
||||
// `clide ui open <reader> <id|path>` — drive the GUI readers from the CLI
|
||||
// (T-231, drive-half of D-6). Publishes a 'selection' to the kernel
|
||||
// MessageBus, captured post-boot; null in headless contexts.
|
||||
registerUiCommands(dispatcher, () => kernelMessages?.publish);
|
||||
// `clide status` — one-shot orientation snapshot (T-221): active pane,
|
||||
// focused file + selection, git summary, layout. Assembled here where the
|
||||
// live kernel + subsystem state is in scope; the reader's viewed doc is
|
||||
@@ -317,6 +325,7 @@ Future<void> main() async {
|
||||
// creates it before the daemonClientFactory runs, but the status closure
|
||||
// only reads it at request time (post-boot), so capturing it here is safe.
|
||||
kernelReaderNav = services.readerNav;
|
||||
kernelMessages = services.messages;
|
||||
|
||||
// Register every built-in. Tier 0 activates only the four that do
|
||||
// real work; the rest compile in as stubs so the extensions-ui can
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/// Registers `ui.open` — the drive-half of D-6 parity (T-231).
|
||||
///
|
||||
/// Epic C (T-218) gave the CLI the *observe* half: `clide status` / `pane
|
||||
/// list` read live UI state. This is the complement — the agent asks the
|
||||
/// GUI to open a doc in one of its readers, so it can say "look at this
|
||||
/// with me." Opening a reader doc is otherwise a UI-only action (a click
|
||||
/// publishes a `selection` to the kernel MessageBus); this verb publishes
|
||||
/// the same message from the CLI.
|
||||
///
|
||||
/// clide ui open tickets T-48
|
||||
/// clide ui open decisions D-17
|
||||
/// clide ui open markdown docs/initial-plan.md
|
||||
///
|
||||
/// The handler is decoupled from the kernel: it takes a [MessagePublisher]
|
||||
/// callback (wired to the MessageBus in main.dart), so this file stays
|
||||
/// Flutter-free and runs under `dart test`.
|
||||
library;
|
||||
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
/// Publishes a message to the kernel MessageBus. `void Function(publisher,
|
||||
/// channel, data)` — the tear-off of `MessageBus.publish`.
|
||||
typedef MessagePublisher = void Function(String publisher, String channel, Map<String, Object?> data);
|
||||
|
||||
/// The readers `ui.open` can target → (bus publisher id, payload key the
|
||||
/// reader reads its entry from). Matches each reader's `ReaderNav` dataKey
|
||||
/// (tickets/decisions key on `id`; markdown on `path`).
|
||||
const Map<String, ({String publisher, String dataKey})> _readers = {
|
||||
'tickets': (publisher: 'builtin.tickets', dataKey: 'id'),
|
||||
'decisions': (publisher: 'builtin.decisions', dataKey: 'id'),
|
||||
'markdown': (publisher: 'builtin.markdown', dataKey: 'path'),
|
||||
};
|
||||
|
||||
void registerUiCommands(DaemonDispatcher d, MessagePublisher? Function() publisher) {
|
||||
d.register('ui.open', (req) async => _open(req, publisher));
|
||||
}
|
||||
|
||||
IpcResponse _userErr(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
|
||||
);
|
||||
|
||||
Future<IpcResponse> _open(IpcRequest req, MessagePublisher? Function() publisherSource) async {
|
||||
// Accept CLI positionals (`ui open <reader> <ref>`) or named args.
|
||||
final positional = (req.args['positional'] as List?)?.whereType<String>().toList() ?? const <String>[];
|
||||
final reader = (req.args['reader'] as String?) ?? (positional.isNotEmpty ? positional[0] : null);
|
||||
final ref = (req.args['ref'] as String?) ?? (positional.length > 1 ? positional[1] : null);
|
||||
|
||||
if (reader == null) {
|
||||
return _userErr(req.id, 'reader is required', hint: 'one of: ${_readers.keys.join(', ')}');
|
||||
}
|
||||
final target = _readers[reader];
|
||||
if (target == null) {
|
||||
return _userErr(req.id, 'unknown reader: $reader', hint: 'one of: ${_readers.keys.join(', ')}');
|
||||
}
|
||||
if (ref == null || ref.isEmpty) {
|
||||
return _userErr(req.id, 'a doc id/path is required (e.g. `ui open $reader <ref>`)');
|
||||
}
|
||||
|
||||
final publish = publisherSource();
|
||||
if (publish == null) {
|
||||
// No live UI bus — headless / CLI-only context. Honest failure, not a hang.
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'no live UI to drive (clide is not running a GUI)'),
|
||||
);
|
||||
}
|
||||
publish(target.publisher, 'selection', {target.dataKey: ref});
|
||||
return IpcResponse.ok(id: req.id, data: {'reader': reader, 'ref': ref, 'opened': true});
|
||||
}
|
||||
Reference in New Issue
Block a user