The inline image cards (T-249) are often too small to read. Clicking one now opens a full-screen lightbox: zoom (scroll wheel / pinch), pan when zoomed, double-click to reset to fit, Esc / close button / backdrop click to dismiss. ClideLightbox is a reusable primitive (lib/widgets/) over Flutter's InteractiveViewer with clide-owned zoom gestures, shown via the DialogRouter (dimmed backdrop, single modal at a time, D-78). The card stays display-only; the click is a navigation gesture, not an inline control. CLI parity (D-6): `clide image show <path> --fullscreen` opens straight into the lightbox instead of injecting a card. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
4.7 KiB
Dart
127 lines
4.7 KiB
Dart
/// Registers `image.show` — drive an image card into the Claude conversation
|
|
/// log from the CLI (T-249, D-6 parity).
|
|
///
|
|
/// clide image show docs/wireframes/hud.png
|
|
/// clide image show /abs/path/shot.jpg --caption "before the fix"
|
|
///
|
|
/// The card itself is clide-owned rendering in the conversation view; this is
|
|
/// its CLI counterpart. Like `ui.open` / `ui.toast`, the handler is decoupled
|
|
/// from the live UI: it validates the request, resolves the path to a real
|
|
/// on-disk file via an injected [ImagePathResolver], then publishes an `image`
|
|
/// message on the kernel MessageBus (captured post-boot in main.dart). A
|
|
/// consumer in the Claude extension injects the matching [ImageMessage] into
|
|
/// the primary session's conversation. Flutter-free so it runs under
|
|
/// `dart test`.
|
|
library;
|
|
|
|
import '../ipc/command_schema.dart';
|
|
import '../ipc/envelope.dart';
|
|
import '../ipc/schema_v1.dart';
|
|
import 'dispatcher.dart';
|
|
import 'ui_command.dart' show MessagePublisher;
|
|
|
|
/// Image formats `image.show` accepts, matched on the path's extension. Mirrors
|
|
/// the composer's attachment sniff so what you can paste in, you can show.
|
|
const imageShowExtensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
|
|
|
|
/// Resolves a user-supplied image path (absolute or workspace-relative) to an
|
|
/// absolute path to an existing file, or null when no such file exists.
|
|
/// Injected so this file stays Flutter-free and unit-testable without touching
|
|
/// the real filesystem; main.dart wires it to the workspace root + `File`.
|
|
typedef ImagePathResolver = String? Function(String path);
|
|
|
|
/// The MessageBus channel `image.show` publishes on; the Claude extension
|
|
/// subscribes to the same literal to inject the card. Kept here next to the
|
|
/// publisher so both ends point at one name.
|
|
const imageShowChannel = 'image';
|
|
|
|
void registerImageCommands(
|
|
DaemonDispatcher d,
|
|
MessagePublisher? Function() publisher, {
|
|
ImagePathResolver? resolve,
|
|
}) {
|
|
d.register(
|
|
'image.show',
|
|
(req) async => _show(req, publisher, resolve),
|
|
schema: const CommandSchema(
|
|
positional: ['path'],
|
|
args: {
|
|
'path': ArgSpec(required: true, rejectLeadingDash: true),
|
|
'caption': ArgSpec(),
|
|
'fullscreen': ArgSpec(type: ArgType.boolean),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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> _show(
|
|
IpcRequest req,
|
|
MessagePublisher? Function() publisherSource,
|
|
ImagePathResolver? resolve,
|
|
) async {
|
|
final path = req.args['path'] as String?;
|
|
if (path == null || path.trim().isEmpty) {
|
|
return _userErr(req.id, 'an image path is required (e.g. `image show docs/diagram.png`)');
|
|
}
|
|
|
|
final ext = _extensionOf(path);
|
|
if (!imageShowExtensions.contains(ext)) {
|
|
return _userErr(
|
|
req.id,
|
|
'unsupported image format${ext.isEmpty ? '' : ' ".$ext"'}',
|
|
hint: 'one of: ${(imageShowExtensions.toList()..sort()).join(', ')}',
|
|
);
|
|
}
|
|
|
|
// Resolve to a concrete file before publishing, so the CLI fails honestly on
|
|
// a typo instead of silently showing a broken card. A null resolver (headless
|
|
// tests) passes the path through unverified.
|
|
String resolved = path;
|
|
if (resolve != null) {
|
|
final abs = resolve(path);
|
|
if (abs == null) {
|
|
return IpcResponse.err(
|
|
id: req.id,
|
|
error: IpcError(
|
|
code: IpcExitCode.notFound,
|
|
kind: IpcErrorKind.notFound,
|
|
message: 'no such image: $path',
|
|
hint: 'path is resolved relative to the workspace root',
|
|
),
|
|
);
|
|
}
|
|
resolved = abs;
|
|
}
|
|
|
|
final caption = req.args['caption'] as String?;
|
|
|
|
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)'),
|
|
);
|
|
}
|
|
final fullscreen = req.args['fullscreen'] == true;
|
|
publish('cli', imageShowChannel, {
|
|
'path': resolved,
|
|
if (caption != null && caption.trim().isNotEmpty) 'caption': caption.trim(),
|
|
if (fullscreen) 'fullscreen': true,
|
|
});
|
|
return IpcResponse.ok(id: req.id, data: {'path': resolved, if (caption != null) 'caption': caption, 'fullscreen': fullscreen, 'shown': true});
|
|
}
|
|
|
|
/// Lower-cased extension (without the dot) of [path], or '' if none.
|
|
String _extensionOf(String path) {
|
|
final slash = path.lastIndexOf('/');
|
|
final dot = path.lastIndexOf('.');
|
|
if (dot <= 0 || dot < slash || dot == path.length - 1) return '';
|
|
return path.substring(dot + 1).toLowerCase();
|
|
}
|