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

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:
2026-05-23 00:16:01 +02:00
co-authored by Claude Opus 4.7
parent 6910c79e5b
commit ce5996f869
7 changed files with 269 additions and 93 deletions
@@ -2143,3 +2143,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by,
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'status', 'in_progress', 'done', NULL, '2026-05-22 18:14:32', '2026-05-22 18:14:32', '2026-05-22 18:14:32', NULL, 'a284c7249dda3c63a52a202bad07f105', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'status', 'in_progress', 'done', NULL, '2026-05-22 18:14:32', '2026-05-22 18:14:32', '2026-05-22 18:14:32', NULL, 'a284c7249dda3c63a52a202bad07f105', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 21:08:05', '2026-05-22 21:08:05', '2026-05-22 21:08:05', NULL, 'c3dfefe24d912081cd89ac0be066b641', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 21:08:05', '2026-05-22 21:08:05', '2026-05-22 21:08:05', NULL, 'c3dfefe24d912081cd89ac0be066b641', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'status', 'in_progress', 'done', NULL, '2026-05-22 21:49:09', '2026-05-22 21:49:09', '2026-05-22 21:49:09', NULL, 'e4be02e339d638da70a4e7ce0f850691', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'status', 'in_progress', 'done', NULL, '2026-05-22 21:49:09', '2026-05-22 21:49:09', '2026-05-22 21:49:09', NULL, 'e4be02e339d638da70a4e7ce0f850691', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-142', 'status', 'backlog', 'in_progress', NULL, '2026-05-22 22:09:09', '2026-05-22 22:09:09', '2026-05-22 22:09:09', NULL, 'b799657bf436bc44e306ef71658c80e5', 1) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -2488,3 +2488,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 18:14:32', NULL, '6651a2c3c9099f7d38553f6cf9e41e98', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-137', 'story', 'T-132', 'ConversationView + replace the terminal in the Claude pane', 'New lib/builtin/claude/src/conversation_view.dart rendering user / assistant(markdown via ClideMarkdown) / tool-call card / tool-result+diff card; whole tree under one SelectionArea. Swap ClidePtyView -> ConversationView in lib/builtin/claude/src/claude_pane.dart (~line 287). Claude still runs in tmux (detached / output ignored) but content comes from the transcript (TranscriptReader). Keep the terminal builtin + ClidePtyView for general use. Surface conversation as events too (D-6). Cite D-75. Acceptance: make run shows the native Claude conversation from the live transcript; select+copy across cards works; no terminal in the Claude pane; terminal builtin still works; widget tests. Blocked by T3, T4.', 'done', 'high', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 18:14:32', NULL, '6651a2c3c9099f7d38553f6cf9e41e98', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t <pane> (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:08:05', NULL, '2e6b932ad78ac78d83c86c5e08290ed3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t <pane> (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:08:05', NULL, '2e6b932ad78ac78d83c86c5e08290ed3', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t <pane> (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:49:09', NULL, '8b247ce6f9fc63c6f56030303aece2e8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-138', 'task', 'T-132', 'Native composer + file/image paste', 'New lib/builtin/claude/src/claude_composer.dart: native text field -> tmux -L clide send-keys -t <pane> (Enter submit, Shift+Enter newline). Paste text inline; file/image per the T2 spike finding (likely temp-file + path/@-reference, since send-keys carries text only). Acceptance: typing in the composer reaches Claude; pasting a text file and an image both work via the validated mechanism. Blocked by T2, T5.', 'done', 'medium', NULL, NULL, NULL, '2026-05-22 15:59:34', '2026-05-22 21:49:09', NULL, '8b247ce6f9fc63c6f56030303aece2e8', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-142', 'task', 'T-132', 'Composer attachment chips (thumbnail/icon on paste)', 'Show pasted files/images as chips above the composer input instead of inserting the @path as text. Image chips render a thumbnail (Image.file of the cache/temp file); other types show a Phosphor file icon + basename; each chip has a remove ×. On submit, the typed text plus the chips'' @path tokens are assembled into the message sent via tmux.sendMessage, then text + chips clear. Typed @path text still works. resolveClipboardAttachment changes to return a list of attachment descriptors (path + isImage). No new package deps (Image.file + existing Phosphor icons). Builds on T-138.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-22 22:06:59', '2026-05-22 22:09:09', NULL, 'da746aa3a523e3b351eaa13f19fa51e1', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+5 -4
View File
@@ -23,10 +23,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
Submits via the tmux server (bracketed paste + Enter), so input Submits via the tmux server (bracketed paste + Enter), so input
reaches Claude even when no tmux client is attached; multi-line goes reaches Claude even when no tmux client is attached; multi-line goes
as one message. as one message.
- File and image paste in the composer (T-138) — Ctrl/Cmd+V of a copied - File and image paste in the composer (T-138, T-142) — Ctrl/Cmd+V of a
file or a clipboard image inserts an `@path` reference (images are copied file or clipboard image adds a removable chip (image thumbnail
saved to a cache dir first); plain text pastes inline. Backed by a or file icon) above the input; on send its `@path` is appended to the
native `clide/clipboard` channel (GTK + macOS). message. Plain text pastes inline. Backed by a native `clide/clipboard`
channel (GTK + macOS).
- Claude pane renders natively from the transcript (T-137, D-75) — the - Claude pane renders natively from the transcript (T-137, D-75) — the
conversation shows as native cards (user / assistant markdown / conversation shows as native cards (user / assistant markdown /
thinking / tool-use / result) instead of a terminal, with text thinking / tool-use / result) instead of a terminal, with text
+150 -62
View File
@@ -1,17 +1,19 @@
/// Native input composer for the Claude pane (epic T-132, T-138). /// Native input composer for the Claude pane (epic T-132, T-138).
/// ///
/// A no-Material [EditableText] (D-7) below the [ConversationView]. /// A no-Material [EditableText] (D-7) below the [ConversationView].
/// Enter submits; Shift+Enter inserts a newline. Submitted text is sent /// Enter submits; Shift+Enter inserts a newline. The composed message
/// to Claude's tmux session via `pane.write` (the same CLI verb the /// (typed text plus any attachment `@path` tokens) is handed to
/// terminal pane uses — D-6 parity), so there's no Claude-only input /// [ClaudeComposer.onSubmit]; the pane delivers it to Claude. Pasted
/// path. File/image paste (the `@path` mechanism) is layered on top via /// files/images show as removable chips (T-142); plain text paste falls
/// the paste-intent override; plain text paste falls through to the /// through to the default.
/// default.
library; library;
import 'dart:async'; 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/controller.dart';
import 'package:clide/kernel/src/theme/tokens.dart';
import 'package:clide/widgets/widgets.dart'; import 'package:clide/widgets/widgets.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -43,18 +45,18 @@ class ClaudeComposer extends StatefulWidget {
this.pasteResolver, this.pasteResolver,
}); });
/// Called with the raw composed text when the user submits. The text /// Called with the composed message (typed text plus attachment `@path`
/// is not yet PTY-encoded — the pane wraps it with [encodeClaudeInput]. /// tokens) when the user submits. The pane delivers it to Claude.
final void Function(String text) onSubmit; final void Function(String text) onSubmit;
final bool enabled; final bool enabled;
final String hint; final String hint;
/// Optional override of paste handling: given nothing, returns the /// Optional override of paste handling: returns the attachments on the
/// text to insert at the cursor (e.g. `@/path/to/file`) or null to /// clipboard (files / images), or an empty list to fall back to the
/// fall back to the default plain-text paste. Injected so the pane can /// default plain-text paste. Injected so the pane can wire in native
/// wire in native file/image clipboard support and tests can fake it. /// file/image clipboard support and tests can fake it.
final Future<String?> Function()? pasteResolver; final Future<List<ComposerAttachment>> Function()? pasteResolver;
@override @override
State<ClaudeComposer> createState() => _ClaudeComposerState(); State<ClaudeComposer> createState() => _ClaudeComposerState();
@@ -63,6 +65,7 @@ class ClaudeComposer extends StatefulWidget {
class _ClaudeComposerState extends State<ClaudeComposer> { class _ClaudeComposerState extends State<ClaudeComposer> {
final TextEditingController _controller = TextEditingController(); final TextEditingController _controller = TextEditingController();
final FocusNode _focus = FocusNode(); final FocusNode _focus = FocusNode();
final List<ComposerAttachment> _attachments = [];
@override @override
void initState() { void initState() {
@@ -83,18 +86,25 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
void _submit() { void _submit() {
if (!widget.enabled) return; if (!widget.enabled) return;
final text = _controller.text; final text = _controller.text;
if (text.trim().isEmpty) return; final tokens = _attachments.map((a) => a.pathToken);
widget.onSubmit(text); 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(); _controller.clear();
setState(() => _attachments.clear());
} }
Future<void> _handlePaste() async { Future<void> _handlePaste() async {
final resolver = widget.pasteResolver; final resolver = widget.pasteResolver;
if (resolver != null) { if (resolver != null) {
final inserted = await resolver(); final attachments = await resolver();
if (inserted != null) { if (attachments.isNotEmpty) {
if (!mounted) return; if (!mounted) return;
_insertAtCursor(inserted); setState(() => _attachments.addAll(attachments));
return; return;
} }
} }
@@ -106,6 +116,10 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
} }
} }
void _removeAttachment(ComposerAttachment attachment) {
setState(() => _attachments.remove(attachment));
}
void _insertAtCursor(String insertion) { void _insertAtCursor(String insertion) {
final value = _controller.value; final value = _controller.value;
final sel = value.selection; final sel = value.selection;
@@ -120,66 +134,140 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface; final theme = ClideTheme.of(context).surface;
final hasText = _controller.text.isNotEmpty; final hasText = _controller.text.isNotEmpty;
final fg = widget.enabled ? tokens.globalForeground : tokens.globalTextMuted; final fg = widget.enabled ? theme.globalForeground : theme.globalTextMuted;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 6, 10, 10), padding: const EdgeInsets.fromLTRB(10, 6, 10, 10),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: tokens.globalBorder), border: Border.all(color: theme.globalBorder),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Semantics( child: Column(
label: widget.hint, crossAxisAlignment: CrossAxisAlignment.stretch,
textField: true, mainAxisSize: MainAxisSize.min,
child: Shortcuts( children: [
shortcuts: const { if (_attachments.isNotEmpty)
SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(), Padding(
SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(), padding: const EdgeInsets.only(bottom: 8),
}, child: Wrap(
child: Actions( spacing: 6,
actions: { runSpacing: 6,
SubmitComposerIntent: CallbackAction<SubmitComposerIntent>( children: [for (final a in _attachments) _chip(theme, a)],
onInvoke: (_) {
_submit();
return null;
},
), ),
PasteTextIntent: CallbackAction<PasteTextIntent>( ),
onInvoke: (_) { Semantics(
unawaited(_handlePaste()); label: widget.hint,
return null; textField: true,
}, child: Shortcuts(
), shortcuts: const {
}, SingleActivator(LogicalKeyboardKey.enter): SubmitComposerIntent(),
child: Stack( SingleActivator(LogicalKeyboardKey.numpadEnter): SubmitComposerIntent(),
children: [ },
if (!hasText) child: Actions(
Positioned( actions: {
left: 0, SubmitComposerIntent: CallbackAction<SubmitComposerIntent>(
top: 0, onInvoke: (_) {
right: 0, _submit();
child: ClideText(widget.hint, muted: true, fontSize: clideFontBody), return null;
},
), ),
EditableText( PasteTextIntent: CallbackAction<PasteTextIntent>(
controller: _controller, onInvoke: (_) {
focusNode: _focus, unawaited(_handlePaste());
readOnly: !widget.enabled, return null;
style: TextStyle(fontSize: clideFontBody, color: fg, height: 1.4), },
cursorColor: tokens.globalFocus, ),
backgroundCursorColor: tokens.globalTextMuted, },
maxLines: 8, child: Stack(
minLines: 1, 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);
}
} }
+36 -9
View File
@@ -13,6 +13,31 @@ import 'dart:io';
import 'package:flutter/services.dart'; 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 /// Read side of the OS clipboard for the non-text content the composer
/// turns into `@path` tokens. Abstracted so the resolver is testable /// turns into `@path` tokens. Abstracted so the resolver is testable
/// without the platform channel. /// without the platform channel.
@@ -67,21 +92,23 @@ String pasteCacheDir() {
return '$base/clide/pasted'; return '$base/clide/pasted';
} }
/// Resolve a paste into a string to insert at the composer's cursor, or /// Resolve a paste into composer attachments, or an empty list to fall
/// null to fall back to plain-text paste. /// back to plain-text paste.
/// ///
/// Files already on disk become `@path` tokens directly. A raw image is /// Files already on disk become attachments directly. A raw image is
/// written to [tempDir] (default [pasteCacheDir]) and referenced by its /// written to [tempDir] (default [pasteCacheDir]) and attached by its
/// `@path`. Returns null when the clipboard holds neither, so the /// path. Returns an empty list when the clipboard holds neither, so the
/// composer pastes text instead. /// composer pastes text instead.
Future<String?> resolveClipboardAttachment( Future<List<ComposerAttachment>> resolveClipboardAttachment(
ClipboardSource source, { ClipboardSource source, {
Directory? tempDir, Directory? tempDir,
DateTime Function() now = DateTime.now, DateTime Function() now = DateTime.now,
}) async { }) async {
final files = await source.readFiles(); final files = await source.readFiles();
if (files.isNotEmpty) { 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(); final image = await source.readImage();
@@ -90,8 +117,8 @@ Future<String?> resolveClipboardAttachment(
await dir.create(recursive: true); await dir.create(recursive: true);
final file = File('${dir.path}/paste-${now().millisecondsSinceEpoch}.png'); final file = File('${dir.path}/paste-${now().millisecondsSinceEpoch}.png');
await file.writeAsBytes(image); await file.writeAsBytes(image);
return '@${file.path}'; return [ComposerAttachment(path: file.path, isImage: true)];
} }
return null; return const [];
} }
+58 -11
View File
@@ -4,6 +4,7 @@
library; library;
import 'package:clide/builtin/claude/src/claude_composer.dart'; import 'package:clide/builtin/claude/src/claude_composer.dart';
import 'package:clide/builtin/claude/src/clipboard_paste.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -80,7 +81,16 @@ void main() {
expect(tester.widget<EditableText>(find.byType(EditableText)).readOnly, isTrue); expect(tester.widget<EditableText>(find.byType(EditableText)).readOnly, isTrue);
}); });
testWidgets('Ctrl+V routes through the paste resolver and inserts its @path', (tester) async { Future<void> pasteAttachment(WidgetTester tester) async {
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
await tester.pumpAndSettle();
}
testWidgets('Ctrl+V of a file shows a chip and leaves the text field empty', (tester) async {
// WidgetsApp provides DefaultTextEditingShortcuts in the real app; // WidgetsApp provides DefaultTextEditingShortcuts in the real app;
// the bare harness doesn't, so wrap explicitly to map Ctrl+V -> // the bare harness doesn't, so wrap explicitly to map Ctrl+V ->
// PasteTextIntent, which the composer's Actions override intercepts. // PasteTextIntent, which the composer's Actions override intercepts.
@@ -89,22 +99,59 @@ void main() {
DefaultTextEditingShortcuts( DefaultTextEditingShortcuts(
child: ClaudeComposer( child: ClaudeComposer(
onSubmit: (_) {}, onSubmit: (_) {},
pasteResolver: () async => '@/tmp/shot.png', pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
), ),
), ),
)); ));
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus(); await pasteAttachment(tester);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.control); expect(find.text('notes.txt'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.keyV); expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
await tester.sendKeyUpEvent(LogicalKeyboardKey.control); });
testWidgets('submit appends attachment @path tokens to the message', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
));
await tester.enterText(find.byType(EditableText), 'look at this');
await pasteAttachment(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(submitted, ['look at this @/tmp/notes.txt']);
tester.widget<EditableText>(find.byType(EditableText)).controller.text, // Chip cleared after send.
'@/tmp/shot.png', expect(find.text('notes.txt'), findsNothing);
); });
testWidgets('remove × cancels the attachment before send', (tester) async {
final submitted = <String>[];
await tester.pumpWidget(harness(
f,
DefaultTextEditingShortcuts(
child: ClaudeComposer(
onSubmit: submitted.add,
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
),
),
));
await pasteAttachment(tester);
expect(find.text('notes.txt'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('composer-remove-/tmp/notes.txt')));
await tester.pumpAndSettle();
expect(find.text('notes.txt'), findsNothing);
await tester.enterText(find.byType(EditableText), 'just text');
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
expect(submitted, ['just text']);
}); });
}); });
} }
+18 -7
View File
@@ -25,13 +25,15 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('resolveClipboardAttachment', () { group('resolveClipboardAttachment', () {
test('files become space-joined @path tokens (no temp file)', () async { test('files become attachments with isImage by extension', () async {
final source = _FakeSource(files: ['/home/u/a.txt', '/home/u/b.png']); final source = _FakeSource(files: ['/home/u/a.txt', '/home/u/b.png']);
final result = await resolveClipboardAttachment(source); final result = await resolveClipboardAttachment(source);
expect(result, '@/home/u/a.txt @/home/u/b.png'); expect(result.map((a) => a.path), ['/home/u/a.txt', '/home/u/b.png']);
expect(result.map((a) => a.isImage), [false, true]);
expect(result.map((a) => a.pathToken), ['@/home/u/a.txt', '@/home/u/b.png']);
}); });
test('a raw image is written to a temp file and referenced by @path', () async { test('a raw image is written to a temp file and attached', () async {
final dir = await Directory.systemTemp.createTemp('clide-paste-test-'); final dir = await Directory.systemTemp.createTemp('clide-paste-test-');
addTearDown(() => dir.delete(recursive: true)); addTearDown(() => dir.delete(recursive: true));
final bytes = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); final bytes = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
@@ -44,7 +46,9 @@ void main() {
); );
final expectedPath = '${dir.path}/paste-1700000000000.png'; final expectedPath = '${dir.path}/paste-1700000000000.png';
expect(result, '@$expectedPath'); expect(result, hasLength(1));
expect(result.single.isImage, isTrue);
expect(result.single.path, expectedPath);
expect(await File(expectedPath).readAsBytes(), bytes); expect(await File(expectedPath).readAsBytes(), bytes);
}); });
@@ -52,11 +56,18 @@ void main() {
final result = await resolveClipboardAttachment( final result = await resolveClipboardAttachment(
_FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])), _FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])),
); );
expect(result, '@/x/y'); expect(result.map((a) => a.path), ['/x/y']);
}); });
test('empty clipboard returns null (fall back to text)', () async { test('empty clipboard returns no attachments (fall back to text)', () async {
expect(await resolveClipboardAttachment(_FakeSource()), isNull); expect(await resolveClipboardAttachment(_FakeSource()), isEmpty);
});
});
group('ComposerAttachment', () {
test('fileName is the last path segment', () {
expect(const ComposerAttachment(path: '/a/b/c.png', isImage: true).fileName, 'c.png');
expect(const ComposerAttachment(path: 'bare.txt', isImage: false).fileName, 'bare.txt');
}); });
}); });