show pasted files/images as removable chips in the composer (T-142)
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 31s
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 31s
Pasting a file or image now adds a chip above the input instead of inserting the raw @path as editable text: an image thumbnail (Image.file of the cache/temp file, with an icon fallback) or a file icon plus the basename, each with a × to cancel it before sending. On submit the chips' @path tokens are appended to the typed text and the chips clear. resolveClipboardAttachment now returns ComposerAttachment descriptors (path + isImage) rather than a pre-joined token string, so the composer can render and manage each one. No new package dependency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
/// Native input composer for the Claude pane (epic T-132, T-138).
|
||||
///
|
||||
/// A no-Material [EditableText] (D-7) below the [ConversationView].
|
||||
/// Enter submits; Shift+Enter inserts a newline. Submitted text is sent
|
||||
/// to Claude's tmux session via `pane.write` (the same CLI verb the
|
||||
/// terminal pane uses — D-6 parity), so there's no Claude-only input
|
||||
/// path. File/image paste (the `@path` mechanism) is layered on top via
|
||||
/// the paste-intent override; plain text paste falls through to the
|
||||
/// default.
|
||||
/// Enter submits; Shift+Enter inserts a newline. The composed message
|
||||
/// (typed text plus any attachment `@path` tokens) is handed to
|
||||
/// [ClaudeComposer.onSubmit]; the pane delivers it to Claude. Pasted
|
||||
/// files/images show as removable chips (T-142); plain text paste falls
|
||||
/// through to the default.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/claude/src/clipboard_paste.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';
|
||||
@@ -43,18 +45,18 @@ class ClaudeComposer extends StatefulWidget {
|
||||
this.pasteResolver,
|
||||
});
|
||||
|
||||
/// Called with the raw composed text when the user submits. The text
|
||||
/// is not yet PTY-encoded — the pane wraps it with [encodeClaudeInput].
|
||||
/// Called with the composed message (typed text plus attachment `@path`
|
||||
/// tokens) when the user submits. The pane delivers it to Claude.
|
||||
final void Function(String text) onSubmit;
|
||||
|
||||
final bool enabled;
|
||||
final String hint;
|
||||
|
||||
/// Optional override of paste handling: given nothing, returns the
|
||||
/// text to insert at the cursor (e.g. `@/path/to/file`) or null to
|
||||
/// fall back to the default plain-text paste. Injected so the pane can
|
||||
/// wire in native file/image clipboard support and tests can fake it.
|
||||
final Future<String?> Function()? pasteResolver;
|
||||
/// Optional override of paste handling: returns the attachments on the
|
||||
/// clipboard (files / images), or an empty list to fall back to the
|
||||
/// default plain-text paste. Injected so the pane can wire in native
|
||||
/// file/image clipboard support and tests can fake it.
|
||||
final Future<List<ComposerAttachment>> Function()? pasteResolver;
|
||||
|
||||
@override
|
||||
State<ClaudeComposer> createState() => _ClaudeComposerState();
|
||||
@@ -63,6 +65,7 @@ class ClaudeComposer extends StatefulWidget {
|
||||
class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focus = FocusNode();
|
||||
final List<ComposerAttachment> _attachments = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -83,18 +86,25 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
void _submit() {
|
||||
if (!widget.enabled) return;
|
||||
final text = _controller.text;
|
||||
if (text.trim().isEmpty) return;
|
||||
widget.onSubmit(text);
|
||||
final tokens = _attachments.map((a) => a.pathToken);
|
||||
if (text.trim().isEmpty && _attachments.isEmpty) return;
|
||||
// Typed text first, then the attachment @path references.
|
||||
final message = [
|
||||
if (text.trim().isNotEmpty) text,
|
||||
...tokens,
|
||||
].join(' ');
|
||||
widget.onSubmit(message);
|
||||
_controller.clear();
|
||||
setState(() => _attachments.clear());
|
||||
}
|
||||
|
||||
Future<void> _handlePaste() async {
|
||||
final resolver = widget.pasteResolver;
|
||||
if (resolver != null) {
|
||||
final inserted = await resolver();
|
||||
if (inserted != null) {
|
||||
final attachments = await resolver();
|
||||
if (attachments.isNotEmpty) {
|
||||
if (!mounted) return;
|
||||
_insertAtCursor(inserted);
|
||||
setState(() => _attachments.addAll(attachments));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -106,6 +116,10 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
}
|
||||
}
|
||||
|
||||
void _removeAttachment(ComposerAttachment attachment) {
|
||||
setState(() => _attachments.remove(attachment));
|
||||
}
|
||||
|
||||
void _insertAtCursor(String insertion) {
|
||||
final value = _controller.value;
|
||||
final sel = value.selection;
|
||||
@@ -120,66 +134,140 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tokens = ClideTheme.of(context).surface;
|
||||
final theme = ClideTheme.of(context).surface;
|
||||
final hasText = _controller.text.isNotEmpty;
|
||||
final fg = widget.enabled ? tokens.globalForeground : tokens.globalTextMuted;
|
||||
final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 6, 10, 10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: tokens.globalBorder),
|
||||
border: Border.all(color: theme.globalBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Semantics(
|
||||
label: widget.hint,
|
||||
textField: true,
|
||||
child: Shortcuts(
|
||||
shortcuts: const {
|
||||
SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
SubmitComposerIntent: CallbackAction<SubmitComposerIntent>(
|
||||
onInvoke: (_) {
|
||||
_submit();
|
||||
return null;
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_attachments.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [for (final a in _attachments) _chip(theme, a)],
|
||||
),
|
||||
PasteTextIntent: CallbackAction<PasteTextIntent>(
|
||||
onInvoke: (_) {
|
||||
unawaited(_handlePaste());
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!hasText)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
|
||||
),
|
||||
Semantics(
|
||||
label: widget.hint,
|
||||
textField: true,
|
||||
child: Shortcuts(
|
||||
shortcuts: const {
|
||||
SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(),
|
||||
SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: {
|
||||
SubmitComposerIntent: CallbackAction<SubmitComposerIntent>(
|
||||
onInvoke: (_) {
|
||||
_submit();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
readOnly: !widget.enabled,
|
||||
style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4),
|
||||
cursorColor: tokens.globalFocus,
|
||||
backgroundCursorColor: tokens.globalTextMuted,
|
||||
maxLines: 8,
|
||||
minLines: 1,
|
||||
PasteTextIntent: CallbackAction<PasteTextIntent>(
|
||||
onInvoke: (_) {
|
||||
unawaited(_handlePaste());
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!hasText)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody),
|
||||
),
|
||||
EditableText(
|
||||
controller: _controller,
|
||||
focusNode: _focus,
|
||||
readOnly: !widget.enabled,
|
||||
style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4),
|
||||
cursorColor: theme.globalFocus,
|
||||
backgroundCursorColor: theme.globalTextMuted,
|
||||
maxLines: 8,
|
||||
minLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One attachment chip: a thumbnail (images) or file icon (other types),
|
||||
/// the filename, and a remove × that cancels the attachment before send.
|
||||
Widget _chip(SurfaceTokens theme, ComposerAttachment a) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.panelBackground,
|
||||
border: Border.all(color: theme.globalBorder),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(6, 4, 4, 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_chipLeading(theme, a),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: ClideText(
|
||||
a.fileName,
|
||||
fontSize: clideFontSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: 'Remove ${a.fileName}',
|
||||
child: GestureDetector(
|
||||
key: ValueKey('composer-remove-${a.path}'),
|
||||
onTap: () => _removeAttachment(a),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: ClideIcon(PhosphorIcons.xMark, size: 12, color: theme.globalTextMuted),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chipLeading(SurfaceTokens theme, ComposerAttachment a) {
|
||||
const dim = 28.0;
|
||||
if (a.isImage) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.file(
|
||||
File(a.path),
|
||||
width: dim,
|
||||
height: dim,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => ClideIcon(PhosphorIcons.image, size: 18, color: theme.globalTextMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ClideIcon(PhosphorIcons.fileText, size: 18, color: theme.globalTextMuted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,31 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A pasted file or image the composer shows as a chip and sends to
|
||||
/// Claude as an `@path` reference.
|
||||
class ComposerAttachment {
|
||||
const ComposerAttachment({required this.path, required this.isImage});
|
||||
|
||||
/// Absolute path on disk (a real file, or a temp file for a pasted
|
||||
/// raw image).
|
||||
final String path;
|
||||
|
||||
/// Whether [path] is a raster image — chips render a thumbnail for
|
||||
/// these and a file icon otherwise.
|
||||
final bool isImage;
|
||||
|
||||
/// The token inserted into the message Claude receives.
|
||||
String get pathToken => '@$path';
|
||||
|
||||
/// Last path segment, for the chip label.
|
||||
String get fileName => path.split('/').where((s) => s.isNotEmpty).lastOrNull ?? path;
|
||||
}
|
||||
|
||||
bool _looksLikeImage(String path) {
|
||||
final p = path.toLowerCase();
|
||||
return p.endsWith('.png') || p.endsWith('.jpg') || p.endsWith('.jpeg') || p.endsWith('.gif') || p.endsWith('.webp') || p.endsWith('.bmp');
|
||||
}
|
||||
|
||||
/// Read side of the OS clipboard for the non-text content the composer
|
||||
/// turns into `@path` tokens. Abstracted so the resolver is testable
|
||||
/// without the platform channel.
|
||||
@@ -67,21 +92,23 @@ String pasteCacheDir() {
|
||||
return '$base/clide/pasted';
|
||||
}
|
||||
|
||||
/// Resolve a paste into a string to insert at the composer's cursor, or
|
||||
/// null to fall back to plain-text paste.
|
||||
/// Resolve a paste into composer attachments, or an empty list to fall
|
||||
/// back to plain-text paste.
|
||||
///
|
||||
/// Files already on disk become `@path` tokens directly. A raw image is
|
||||
/// written to [tempDir] (default [pasteCacheDir]) and referenced by its
|
||||
/// `@path`. Returns null when the clipboard holds neither, so the
|
||||
/// Files already on disk become attachments directly. A raw image is
|
||||
/// written to [tempDir] (default [pasteCacheDir]) and attached by its
|
||||
/// path. Returns an empty list when the clipboard holds neither, so the
|
||||
/// composer pastes text instead.
|
||||
Future<String?> resolveClipboardAttachment(
|
||||
Future<List<ComposerAttachment>> resolveClipboardAttachment(
|
||||
ClipboardSource source, {
|
||||
Directory? tempDir,
|
||||
DateTime Function() now = DateTime.now,
|
||||
}) async {
|
||||
final files = await source.readFiles();
|
||||
if (files.isNotEmpty) {
|
||||
return files.map((p) => '@$p').join(' ');
|
||||
return [
|
||||
for (final p in files) ComposerAttachment(path: p, isImage: _looksLikeImage(p)),
|
||||
];
|
||||
}
|
||||
|
||||
final image = await source.readImage();
|
||||
@@ -90,8 +117,8 @@ Future<String?> resolveClipboardAttachment(
|
||||
await dir.create(recursive: true);
|
||||
final file = File('${dir.path}/paste-${now().millisecondsSinceEpoch}.png');
|
||||
await file.writeAsBytes(image);
|
||||
return '@${file.path}';
|
||||
return [ComposerAttachment(path: file.path, isImage: true)];
|
||||
}
|
||||
|
||||
return null;
|
||||
return const [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user