Add new features and update configurations for improved functionality

This commit is contained in:
ImBenji
2026-04-11 12:34:00 +01:00
parent fa4415553d
commit 0b6b604c56
125 changed files with 14119 additions and 1664 deletions
+44
View File
@@ -0,0 +1,44 @@
import "package:gpt_markdown/gpt_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class AdvisorMessage extends StatelessWidget {
const AdvisorMessage({super.key, required this.title, required this.body});
final String title;
final String body;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OutlinedContainer(
padding: const EdgeInsets.all(10),
backgroundColor: theme.colorScheme.primary,
child: Icon(LucideIcons.brain).iconSmall,
),
Gap(8),
Text(
title,
style: theme.typography.p.copyWith(fontSize: 13),
),
],
),
if (body.isNotEmpty) ...[
Gap(8),
OutlinedContainer(
padding: const EdgeInsets.all(12),
child: GptMarkdown(body),
),
],
],
);
}
}
+143
View File
@@ -0,0 +1,143 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import '../../models/attachment.dart';
import '../common/button.dart';
class AttachmentPreview extends StatelessWidget {
final List<Attachment> attachments;
final Function(int) onRemove;
const AttachmentPreview({
required this.attachments,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
if (attachments.isEmpty) {
return SizedBox.shrink();
}
return MouseRegion(
cursor: SystemMouseCursors.basic,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < attachments.length; i++)
Padding(
padding: EdgeInsets.only(right: 8),
child: AttachmentItem(
attachment: attachments[i],
onRemove: () => onRemove(i),
),
),
],
),
),
),
);
}
}
class AttachmentItem extends StatelessWidget {
final Attachment attachment;
final VoidCallback onRemove;
const AttachmentItem({
required this.attachment,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
String sanitisedName = attachment.displayName;
String type = attachment.mimeType.split("/").last.toUpperCase();
return OutlinedContainer(
height: 52,
borderRadius: Theme.of(context).borderRadiusSm,
padding: EdgeInsets.all(8),
borderColor: Theme.of(context).colorScheme.border,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
OutlinedContainer(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusSm - 4
),
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.zero,
child: _buildPreview(context),
),
),
),
Gap(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
sanitisedName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small.semiBold,
Gap(2),
Text(
type,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).extraLight.small,
],
),
Gap(8),
SizedBox(
child: ClipRRect(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusSm - 4
),
child: AspectRatio(
aspectRatio: 1,
child: AgcGhostButton(
onPressed: onRemove,
child: Icon(LucideIcons.x, size: 14),
),
),
),
)
],
),
);
}
Widget _buildPreview(BuildContext context) {
if (attachment.isImage) {
return Image.memory(
attachment.data,
fit: BoxFit.cover,
);
}
final icon = _getIconForMimeType(attachment.mimeType);
return Container(
color: Theme.of(context).colorScheme.muted,
child: Icon(icon).iconMedium,
);
}
IconData _getIconForMimeType(String mimeType) {
if (mimeType == 'application/pdf') {
return LucideIcons.book;
} else if (mimeType.startsWith('text/') || mimeType == 'application/json') {
return LucideIcons.fileText;
} else if (mimeType.startsWith('image/')) {
return LucideIcons.image;
} else {
return LucideIcons.file;
}
}
}
@@ -0,0 +1,13 @@
import "package:gpt_markdown/gpt_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class AssistantBubble extends StatelessWidget {
const AssistantBubble({super.key, required this.content});
final String content;
@override
Widget build(BuildContext context) {
return GptMarkdown(content);
}
}
@@ -0,0 +1 @@
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
@@ -0,0 +1,89 @@
import "dart:convert";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tools/advisor_bubble.dart";
import "tools/bash_bubble.dart";
import "tools/default_tool_bubble.dart";
import "tools/edit_bubble.dart";
import "tools/glob_bubble.dart";
import "tools/grep_bubble.dart";
import "tools/read_bubble.dart";
import "tools/web_fetch_bubble.dart";
import "tools/web_search_bubble.dart";
import "tools/write_bubble.dart";
class ToolBubble extends StatelessWidget {
const ToolBubble({
super.key,
required this.toolName,
this.toolInput,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final Map<String, dynamic>? toolInput;
final String? result;
final bool isPendingPermission;
// parse a tool message content string into (toolName, toolInput)
// format: "$toolName call\n{json}" or "$toolName result\n..."
static (String, Map<String, dynamic>?) parseContent(String content) {
final newlineIdx = content.indexOf("\n");
if (newlineIdx == -1) {
// no body, just a label line
final name = _extractName(content);
return (name, null);
}
final firstLine = content.substring(0, newlineIdx).trim();
final rest = content.substring(newlineIdx + 1).trim();
final name = _extractName(firstLine);
if (firstLine.endsWith(" call") && rest.isNotEmpty) {
try {
final decoded = jsonDecode(rest);
if (decoded is Map<String, dynamic>) {
return (name, decoded);
}
} catch (_) {}
}
return (name, null);
}
static String _extractName(String line) {
// strip trailing " call" or " result"
if (line.endsWith(" call")) return line.substring(0, line.length - 5).trim();
if (line.endsWith(" result")) return line.substring(0, line.length - 7).trim();
return line.trim();
}
@override
Widget build(BuildContext context) {
final input = toolInput ?? {};
switch (toolName) {
case "Bash":
return BashBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Edit":
return EditBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Read":
return ReadBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Write":
return WriteBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Glob":
return GlobBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Grep":
return GrepBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "WebSearch":
return WebSearchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "WebFetch":
return WebFetchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
case "Advisor":
return AdvisorBubble(input: input, result: result, isPendingPermission: isPendingPermission);
default:
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, isPendingPermission: isPendingPermission);
}
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class AdvisorBubble extends StatelessWidget {
const AdvisorBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final model = input["model"] as String? ?? "";
return ToolBubbleBase(
toolName: "Advisor",
icon: LucideIcons.brain,
result: result,
isPendingPermission: isPendingPermission,
detail: model,
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class BashBubble extends StatelessWidget {
const BashBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final command = input["command"] as String? ?? "";
return ToolBubbleBase(
toolName: "Bash",
icon: LucideIcons.terminal,
result: result,
isPendingPermission: isPendingPermission,
detail: command,
);
}
}
@@ -0,0 +1,43 @@
import "dart:convert";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class DefaultToolBubble extends StatelessWidget {
const DefaultToolBubble({
super.key,
required this.toolName,
this.input,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final Map<String, dynamic>? input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ToolBubbleBase(
toolName: toolName,
icon: LucideIcons.wrench,
result: result,
isPendingPermission: isPendingPermission,
body: input != null && input!.isNotEmpty
? Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
const JsonEncoder.withIndent(" ").convert(input),
style: theme.typography.p.copyWith(
fontSize: 12,
color: theme.colorScheme.mutedForeground,
fontFamily: "monospace",
),
),
)
: null,
);
}
}
@@ -0,0 +1,39 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "../../diff_view.dart";
import "tool_bubble_base.dart";
class EditBubble extends StatelessWidget {
const EditBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
final oldString = input["old_string"] as String? ?? "";
final newString = input["new_string"] as String? ?? "";
return ToolBubbleBase(
toolName: "Edit",
icon: LucideIcons.filePen,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
body: DiffView(
oldString: oldString,
newString: newString,
),
);
}
}
@@ -0,0 +1,37 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class GlobBubble extends StatelessWidget {
const GlobBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final pattern = input["pattern"] as String? ?? "";
final searchPath = input["path"] as String?;
final detail = searchPath != null && searchPath.isNotEmpty
? "${shortenPath(searchPath, projectRoot)}/$pattern"
: pattern;
return ToolBubbleBase(
toolName: "Glob",
icon: LucideIcons.folderSearch,
result: result,
isPendingPermission: isPendingPermission,
detail: detail,
);
}
}
@@ -0,0 +1,37 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class GrepBubble extends StatelessWidget {
const GrepBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final pattern = input["pattern"] as String? ?? "";
final searchPath = input["path"] as String?;
final detail = searchPath != null && searchPath.isNotEmpty
? "${shortenPath(searchPath, projectRoot)}$pattern"
: pattern;
return ToolBubbleBase(
toolName: "Grep",
icon: LucideIcons.search,
result: result,
isPendingPermission: isPendingPermission,
detail: detail,
);
}
}
@@ -0,0 +1,32 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "tool_bubble_base.dart";
class ReadBubble extends StatelessWidget {
const ReadBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
return ToolBubbleBase(
toolName: "Read",
icon: LucideIcons.fileText,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
);
}
}
@@ -0,0 +1,145 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../permission_decision.dart";
class ToolBubbleBase extends StatelessWidget {
const ToolBubbleBase({
super.key,
required this.toolName,
required this.icon,
this.detail,
this.body,
this.result,
this.isPendingPermission = false,
});
final String toolName;
final IconData icon;
final String? detail;
final Widget? body;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedContainer(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IntrinsicHeight(
child: Row(
children: [
Container(
color: theme.colorScheme.primary.scaleAlpha(0.5),
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
children: [
Icon(icon).iconSmall,
Gap(8),
Text(
toolName,
).textSmall,
],
),
),
VerticalDivider(),
if (detail != null)...[
Gap(16),
Text(
detail!,
).mono.xSmall
]
],
),
),
if (body != null) ...[
Divider(),
body!,
],
if (result != null) ...[
Divider(),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: SelectableText(
"\u200B${result!}",
style: TextStyle(
color: theme.colorScheme.mutedForeground,
),
).xSmall.mono,
)
]
],
),
),
if (isPendingPermission) ...[
Gap(8),
Row(
children: [
Expanded(
child: Button.outline(
leading: Icon(LucideIcons.check).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
child: Text("Allow").small,
),
),
Gap(8),
Expanded(
child: Button.outline(
leading: Icon(LucideIcons.checkCheck).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
child: Text("Allow always").small,
),
),
Gap(8),
Expanded(
child: Button.destructive(
leading: Icon(LucideIcons.x).iconSmall,
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
child: Text("Reject").small,
),
),
],
),
],
],
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class WebFetchBubble extends StatelessWidget {
const WebFetchBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final url = input["url"] as String? ?? "";
return ToolBubbleBase(
toolName: "WebFetch",
icon: LucideIcons.link,
result: result,
isPendingPermission: isPendingPermission,
detail: url,
);
}
}
@@ -0,0 +1,28 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "tool_bubble_base.dart";
class WebSearchBubble extends StatelessWidget {
const WebSearchBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final query = input["query"] as String? ?? "";
return ToolBubbleBase(
toolName: "WebSearch",
icon: LucideIcons.globe,
result: result,
isPendingPermission: isPendingPermission,
detail: query,
);
}
}
@@ -0,0 +1,38 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../../providers/chat_provider.dart";
import "../../../../utils/path_utils.dart";
import "../../diff_view.dart";
import "tool_bubble_base.dart";
class WriteBubble extends StatelessWidget {
const WriteBubble({
super.key,
required this.input,
this.result,
this.isPendingPermission = false,
});
final Map<String, dynamic> input;
final String? result;
final bool isPendingPermission;
@override
Widget build(BuildContext context) {
final projectRoot = context.read<ChatProvider>().workingDirectory;
final filePath = input["file_path"] as String? ?? "";
final content = input["content"] as String? ?? "";
return ToolBubbleBase(
toolName: "Write",
icon: LucideIcons.filePlus,
result: result,
isPendingPermission: isPendingPermission,
detail: shortenPath(filePath, projectRoot),
body: DiffView(
oldString: "",
newString: content,
),
);
}
}
@@ -0,0 +1,19 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
class UserBubble extends StatelessWidget {
const UserBubble({super.key, required this.content});
final String content;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: Theme.of(context).colorScheme.border,
child: SelectableText(content),
),
);
}
}
+455
View File
@@ -0,0 +1,455 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import 'package:pasteboard/pasteboard.dart';
import 'package:flutter/services.dart';
import 'package:file_picker/file_picker.dart';
import 'package:provider/provider.dart';
import 'dart:io';
import '../../constants.dart';
import '../../models/attachment.dart';
import '../../providers/chat_provider.dart';
import '../../providers/home_coordinator.dart';
import '../../providers/session_provider.dart';
import '../../providers/settings_provider.dart';
import 'attachment_preview.dart';
import '../common/button.dart';
import 'model_picker_dialog.dart';
class ChatBox extends StatefulWidget {
const ChatBox({super.key});
@override
State<ChatBox> createState() => _ChatBoxState();
}
class _ChatBoxState extends State<ChatBox> {
late TextEditingController _controller;
late FocusNode _focusNode;
final List<Attachment> _attachments = [];
@override
void initState() {
super.initState();
_controller = TextEditingController();
_focusNode = FocusNode();
_controller.addListener(_onTextChanged);
}
Future<void> _onPastePressed() async {
try {
final filePaths = await Pasteboard.files();
if (filePaths.isNotEmpty && mounted) {
for (var filePath in filePaths) {
try {
final file = File(filePath);
final fileBytes = await file.readAsBytes();
final fileName = file.path.split('/').last;
if (mounted) {
setState(() {
_attachments.add(
Attachment(
name: fileName,
mimeType: _getMimeType(fileName, fileBytes),
data: fileBytes,
),
);
});
}
} catch (e) {
// skip files that cant be read
}
}
return;
}
} catch (e) {
// no files in clipboard
}
// fallback to raw image data (screenshots etc)
try {
final imageBytes = await Pasteboard.image;
if (imageBytes != null && mounted) {
final imageData = Uint8List.fromList(imageBytes);
setState(() {
_attachments.add(
Attachment(
name: 'image.png',
mimeType: _getMimeType('image.png', imageData),
data: imageData,
),
);
});
}
} catch (e) {
// no image in clipboard
}
}
String _getMimeType(String filename, Uint8List data) {
if (data.length >= 4) {
if (data[0] == 0x25 &&
data[1] == 0x50 &&
data[2] == 0x44 &&
data[3] == 0x46) {
return 'application/pdf';
}
if (data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47) {
return 'image/png';
}
if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) {
return 'image/jpeg';
}
if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46) {
return 'image/gif';
}
if (data[0] == 0x52 &&
data[1] == 0x49 &&
data[2] == 0x46 &&
data[3] == 0x46) {
if (data.length >= 12 &&
data[8] == 0x57 &&
data[9] == 0x45 &&
data[10] == 0x42 &&
data[11] == 0x50) {
return 'image/webp';
}
}
}
final extension = filename.split('.').last.toLowerCase();
switch (extension) {
case 'pdf':
return 'application/pdf';
case 'txt':
return 'text/plain';
case 'json':
return 'application/json';
case 'csv':
return 'text/csv';
case 'md':
return 'text/markdown';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'gif':
return 'image/gif';
case 'webp':
return 'image/webp';
default:
return 'application/octet-stream';
}
}
void _onTextChanged() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() {});
});
}
Future<void> _onAttachPressed() async {
final result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null || !mounted) return;
for (final file in result.files) {
if (file.path == null) continue;
try {
final f = File(file.path!);
final bytes = await f.readAsBytes();
if (!mounted) return;
setState(() {
_attachments.add(
Attachment(
name: file.name,
mimeType: _getMimeType(file.name, bytes),
data: bytes,
),
);
});
} catch (e) {
// skip unreadable files
}
}
}
Widget _left(BuildContext context) {
return SizedBox(
height: 38,
child: AspectRatio(
aspectRatio: 1,
child: AgcGhostButton(
borderRadius: BorderRadius.circular(Theme.of(context).radiusLg - 4),
onPressed: _onAttachPressed,
child: Icon(LucideIcons.paperclip),
),
),
);
}
void _openModelDialog(BuildContext context) async {
final settings = context.read<SettingsProvider>();
final session = context.read<SessionProvider>();
final selectedModel = settings.normalizeModelId(settings.settings.model);
final result = await showDialog<String>(
context: context,
builder: (context) => ModelPickerDialog(
models: selectableAiModels,
selectedModel: selectedModel,
),
);
if (result != null) {
await settings.updateModel(result);
await session.updateSessionModel(result);
}
}
Widget _right(BuildContext context) {
final settings = context.read<SettingsProvider>();
final selectedModel = settings.normalizeModelId(settings.settings.model);
return SizedBox(
height: 38,
child: Row(
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 150,
minHeight: double.infinity,
),
child: AgcGhostButton(
borderRadius: BorderRadius.circular(
Theme.of(context).radiusLg - 4,
),
onPressed: () => _openModelDialog(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
selectableAiModels
.where((m) => m.id == selectedModel)
.map((m) => m.label)
.firstOrNull ??
selectedModel,
overflow: TextOverflow.ellipsis,
).small,
),
Gap(8),
Icon(LucideIcons.chevronsUpDown),
],
),
),
),
),
Gap(8),
AspectRatio(
aspectRatio: 1,
child: AgcSecondaryButton(
enabled: _controller.text.isNotEmpty,
onPressed: () {
final text = _controller.text.trim();
if (text.isEmpty) return;
context.read<HomeCoordinator>().sendMessage(text);
_controller.clear();
},
child: Icon(LucideIcons.arrowUp),
),
),
],
),
);
}
Widget _buildLeading(BuildContext context, int numberOfLines) {
if (numberOfLines > 1) return SizedBox.shrink();
return _left(context);
}
Widget _buildTrailing(int numberOfLines) {
if (numberOfLines > 1) return SizedBox.shrink();
return _right(context);
}
Widget? _buildBottom(BuildContext context, int numberOfLines) {
if (numberOfLines <= 1) return null;
return Container(
margin: EdgeInsets.only(top: 8),
height: 32,
child: Row(children: [_left(context), Spacer(), _right(context)]),
);
}
String _fmtTokens(int n) {
final s = n.toString();
final buf = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 == 0) buf.write(",");
buf.write(s[i]);
}
return buf.toString();
}
void _removeAttachment(int index) {
setState(() {
_attachments.removeAt(index);
});
_focusNode.requestFocus();
}
@override
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final chat = context.watch<ChatProvider>();
context
.watch<SettingsProvider>(); // needed so model label updates reactively
final queuedMessages = chat.queuedMessages;
final contextTokens = chat.contextTokens;
return Column(
children: [
LayoutBuilder(
builder: (context, constraints) {
final style = DefaultTextStyle.of(context).style;
const reservedForIcons = 34;
final painter = TextPainter(
text: TextSpan(text: _controller.text, style: style),
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth - reservedForIcons);
final numberOfLines = painter.computeLineMetrics().length;
return Focus(
onKeyEvent: (node, event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.keyV &&
(HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed)) {
_onPastePressed();
}
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.enter) {
if (HardwareKeyboard.instance.isShiftPressed) {
final sel = _controller.selection;
final text = _controller.text;
final newText = text.replaceRange(sel.start, sel.end, '\n');
_controller.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(offset: sel.start + 1),
);
return KeyEventResult.handled;
} else {
final text = _controller.text.trim();
if (text.isNotEmpty) {
context.read<HomeCoordinator>().sendMessage(text);
_controller.clear();
}
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;
},
child: OutlinedContainer(
child: ButtonGroup.vertical(
expands: true,
children: [
for (int i = 0; i < queuedMessages.length; i++) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 0,
),
child: Row(
children: [
Icon(
LucideIcons.cornerDownRight,
).iconSmall.iconMutedForeground,
Gap(14),
Expanded(
child: Text(queuedMessages[i]).small.textMuted,
),
IconButton.text(
onPressed: () => chat.removeQueuedMessage(i),
icon: const Icon(LucideIcons.trash2),
).iconSmall,
],
),
),
Divider(),
],
TextField(
controller: _controller,
focusNode: _focusNode,
borderRadius: Theme.of(context).borderRadiusLg,
placeholder: Text("Ask the agency anything"),
minLines: 1,
maxLines: numberOfLines > 1 ? 5 : 1,
clipBehavior: Clip.hardEdge,
padding: EdgeInsets.all(8),
features: [
if (_attachments.isNotEmpty)
InputFeature.above(
AttachmentPreview(
attachments: _attachments,
onRemove: _removeAttachment,
),
),
InputFeature.leading(
_buildLeading(context, numberOfLines),
),
InputFeature.trailing(_buildTrailing(numberOfLines)),
InputFeature.below(
_buildBottom(context, numberOfLines),
),
],
),
if (chat.isLoading)
SizedBox(
height: 4,
child: LinearProgressIndicator()
)
],
),
),
);
},
),
],
);
}
}
+383
View File
@@ -0,0 +1,383 @@
import "package:provider/provider.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../src/session/session_types.dart";
import "../../providers/chat_provider.dart";
import "bubbles/assistant_bubble.dart";
import "bubbles/tool_bubble.dart";
import "bubbles/user_bubble.dart";
class ChatView extends StatefulWidget {
final ScrollController scrollController;
const ChatView({super.key, required this.scrollController});
@override
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> {
ScrollController get _scrollController => widget.scrollController;
List<String> _previousMessageContents = [];
bool _isUserScrolling = false;
DateTime? _lastScrollTime;
bool _showJumpToBottom = false;
bool _hasNewMessagesWhileScrolledAway = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_handleScroll);
}
@override
void dispose() {
_scrollController.removeListener(_handleScroll);
super.dispose();
}
void _handleScroll() {
_lastScrollTime = DateTime.now();
_isUserScrolling = true;
if (_scrollController.hasClients) {
final position = _scrollController.position;
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
if (isFarFromBottom != _showJumpToBottom) {
setState(() {
_showJumpToBottom = isFarFromBottom;
});
}
if (!isFarFromBottom) {
setState(() {
_hasNewMessagesWhileScrolledAway = false;
});
}
}
Future.delayed(const Duration(milliseconds: 150), () {
if (_lastScrollTime != null &&
DateTime.now().difference(_lastScrollTime!) >= const Duration(milliseconds: 150)) {
if (mounted) {
setState(() {
_isUserScrolling = false;
});
}
}
});
}
bool _isNearBottom() {
if (!_scrollController.hasClients) return false;
final position = _scrollController.position;
return position.pixels >= position.maxScrollExtent - 150;
}
void _jumpToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
setState(() {
_showJumpToBottom = false;
_hasNewMessagesWhileScrolledAway = false;
});
}
}
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
final currentMessages = chatProvider.messages;
bool messagesChanged = false;
if (currentMessages.length != _previousMessageContents.length) {
messagesChanged = true;
} else {
for (int i = 0; i < currentMessages.length; i++) {
if (currentMessages[i].content != _previousMessageContents[i]) {
messagesChanged = true;
break;
}
}
}
if (messagesChanged && currentMessages.isNotEmpty) {
final nearBottom = _isNearBottom();
if (nearBottom && !_isUserScrolling) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
_hasNewMessagesWhileScrolledAway = false;
} else if (!nearBottom) {
_hasNewMessagesWhileScrolledAway = true;
}
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_previousMessageContents = currentMessages.map((m) => m.content).toList();
});
final entries = _buildEntries(currentMessages);
return Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: ListView.builder(
controller: _scrollController,
physics: const ClampingScrollPhysics(),
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final pending = chatProvider.pendingPermission;
final isThisPending = pending != null &&
index == entries.length - 1 &&
entry is _ToolEntry &&
entry.toolName == pending.toolName;
Widget bubble;
if (entry is _MessageEntry) {
final msg = entry.message;
if (msg.role == "user") {
bubble = UserBubble(content: msg.content);
} else if (msg.role == "assistant") {
bubble = AssistantBubble(content: msg.content);
} else {
bubble = Text(msg.content);
}
} else if (entry is _ToolEntry) {
bubble = ToolBubble(
toolName: entry.toolName,
toolInput: entry.toolInput,
result: entry.result,
isPendingPermission: isThisPending,
);
} else {
bubble = const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: bubble,
);
},
),
),
),
if (_showJumpToBottom && _hasNewMessagesWhileScrolledAway)
Positioned(
bottom: 16,
right: 16,
child: GestureDetector(
onTap: _jumpToBottom,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background.withOpacity(0.9),
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: const Color(0xFF000000).withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.arrowDown,
size: 16,
color: Theme.of(context).colorScheme.foreground,
),
const SizedBox(width: 6),
Text(
"New messages",
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.foreground,
),
),
],
),
),
),
),
],
);
},
);
}
// merge consecutive tool call + result messages into single entries
List<_ChatEntry> _buildEntries(List<Message> messages) {
final result = <_ChatEntry>[];
int i = 0;
while (i < messages.length) {
final msg = messages[i];
if (msg.role == "tool") {
final firstLine = msg.content.split("\n").first.trim();
if (firstLine.endsWith(" call")) {
final (toolName, toolInput) = ToolBubble.parseContent(msg.content);
// check if next message is the matching result
String? toolResult;
if (i + 1 < messages.length) {
final next = messages[i + 1];
final nextFirst = next.content.split("\n").first.trim();
if (next.role == "tool" && nextFirst == "$toolName result") {
final body = next.content.indexOf("\n");
toolResult = body != -1 ? next.content.substring(body + 1).trim() : null;
i++;
}
}
result.add(_ToolEntry(
toolName: toolName,
toolInput: toolInput,
result: toolResult,
));
i++;
continue;
}
// orphan result or unknown tool message — skip it
// (already consumed as part of a call above, or genuinely standalone)
final (toolName, _) = ToolBubble.parseContent(msg.content);
result.add(_ToolEntry(toolName: toolName));
i++;
} else {
result.add(_MessageEntry(msg));
i++;
}
}
return result;
}
}
sealed class _ChatEntry {}
class _MessageEntry extends _ChatEntry {
_MessageEntry(this.message);
final Message message;
}
class _ToolEntry extends _ChatEntry {
_ToolEntry({required this.toolName, this.toolInput, this.result});
final String toolName;
final Map<String, dynamic>? toolInput;
final String? result;
}
class FullHeightScrollbar extends StatefulWidget {
final ScrollController controller;
const FullHeightScrollbar({super.key, required this.controller});
@override
State<FullHeightScrollbar> createState() => _FullHeightScrollbarState();
}
class _FullHeightScrollbarState extends State<FullHeightScrollbar> {
bool _hovering = false;
bool _scrolling = false;
DateTime _lastScroll = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
widget.controller.addListener(_onScroll);
}
void _onScroll() {
_lastScroll = DateTime.now();
setState(() => _scrolling = true);
Future.delayed(const Duration(milliseconds: 800), () {
if (!mounted) return;
if (DateTime.now().difference(_lastScroll).inMilliseconds >= 800) {
setState(() => _scrolling = false);
}
});
}
@override
void dispose() {
widget.controller.removeListener(_onScroll);
super.dispose();
}
@override
Widget build(BuildContext context) {
final visible = _hovering || _scrolling;
return MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() => _hovering = false),
child: AnimatedOpacity(
opacity: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: LayoutBuilder(
builder: (context, constraints) {
final totalHeight = constraints.maxHeight;
if (!widget.controller.hasClients) return const SizedBox.shrink();
final pos = widget.controller.position;
final maxScroll = pos.maxScrollExtent;
if (maxScroll <= 0) return const SizedBox.shrink();
final viewportFraction = pos.viewportDimension / (pos.viewportDimension + maxScroll);
final thumbHeight = (viewportFraction * totalHeight).clamp(32.0, totalHeight);
final scrollFraction = pos.pixels / maxScroll;
final thumbTop = scrollFraction * (totalHeight - thumbHeight);
final color = Theme.of(context).colorScheme.mutedForeground.withValues(alpha: 0.4);
return Stack(
children: [
Positioned(
top: thumbTop,
left: 2,
right: 2,
height: thumbHeight,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
),
],
);
},
),
),
);
}
}
+329
View File
@@ -0,0 +1,329 @@
import "package:diff_match_patch/diff_match_patch.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
const _contextLines = 3;
class DiffView extends StatelessWidget {
const DiffView({
super.key,
this.oldString,
this.newString,
this.content,
}) : assert(
content != null || (oldString != null && newString != null),
"Provide either content (view-only) or oldString+newString (diff)",
);
final String? oldString;
final String? newString;
// view-only mode — show content as plain code, no diff colors
final String? content;
@override
Widget build(BuildContext context) {
if (content != null) {
final lines = content!.split("\n");
final viewLines = [
for (int i = 0; i < lines.length; i++)
_DiffLine(_LineKind.context, lines[i], newLine: i + 1),
];
final hunk = _Hunk(oldStart: 1, newStart: 1, lines: viewLines);
return _HunkView(hunk: hunk);
}
final hunks = _computeHunks(oldString!, newString!);
if (hunks.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final hunk in hunks) ...[
// is first
if (hunk != hunks.first) ...[
Divider(),
Gap(1),
Divider(),
],
_HunkView(hunk: hunk)
],
],
);
}
}
// ─── data model ───────────────────────────────────────────────────────────────
enum _LineKind { context, added, removed }
class _DiffLine {
const _DiffLine(this.kind, this.text, {this.oldLine, this.newLine});
final _LineKind kind;
final String text;
final int? oldLine;
final int? newLine;
}
class _Hunk {
_Hunk({
required this.oldStart,
required this.newStart,
required this.lines,
});
final int oldStart;
final int newStart;
final List<_DiffLine> lines;
int get oldCount => lines.where((l) => l.kind != _LineKind.added).length;
int get newCount => lines.where((l) => l.kind != _LineKind.removed).length;
}
// ─── diff computation ─────────────────────────────────────────────────────────
List<_Hunk> _computeHunks(String oldStr, String newStr) {
final dmp = DiffMatchPatch();
final oldLines = oldStr.split("\n");
final newLines = newStr.split("\n");
// encode lines → single chars so dmp does line-level diff
final enc = _encodeLines(oldLines, newLines);
final diffs = dmp.diff(enc.oldEncoded, enc.newEncoded, false);
dmp.diffCleanupSemantic(diffs);
// expand diffs back to line sequences
final rawLines = <_DiffLine>[];
int oldIdx = 0;
int newIdx = 0;
for (final d in diffs) {
final count = d.text.length; // each char == one line
switch (d.operation) {
case DIFF_EQUAL:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.context,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
oldLine: oldIdx + 1,
newLine: newIdx + 1,
));
oldIdx++;
newIdx++;
}
break;
case DIFF_DELETE:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.removed,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
oldLine: oldIdx + 1,
));
oldIdx++;
}
break;
case DIFF_INSERT:
for (int i = 0; i < count; i++) {
rawLines.add(_DiffLine(
_LineKind.added,
enc.lines[d.text.codeUnitAt(i) - 0xE000],
newLine: newIdx + 1,
));
newIdx++;
}
break;
}
}
return _groupIntoHunks(rawLines);
}
// keep only context lines that are within _contextLines of a change
List<_Hunk> _groupIntoHunks(List<_DiffLine> rawLines) {
final n = rawLines.length;
// mark which context lines to keep
final keep = List<bool>.filled(n, false);
for (int i = 0; i < n; i++) {
if (rawLines[i].kind != _LineKind.context) {
for (int j = (i - _contextLines).clamp(0, n - 1);
j <= (i + _contextLines).clamp(0, n - 1);
j++) {
keep[j] = true;
}
}
}
final hunks = <_Hunk>[];
int i = 0;
while (i < n) {
if (!keep[i]) {
i++;
continue;
}
// start of a new hunk
final hunkLines = <_DiffLine>[];
int oldStart = rawLines[i].oldLine ?? 1;
int newStart = rawLines[i].newLine ?? 1;
while (i < n && keep[i]) {
hunkLines.add(rawLines[i]);
i++;
}
hunks.add(_Hunk(
oldStart: oldStart,
newStart: newStart,
lines: hunkLines,
));
}
return hunks;
}
// line encoding — maps unique lines to single unicode chars starting at U+E000
class _LineEncoding {
final List<String> lines; // index → line text
final String oldEncoded;
final String newEncoded;
const _LineEncoding(this.lines, this.oldEncoded, this.newEncoded);
}
_LineEncoding _encodeLines(List<String> oldLines, List<String> newLines) {
final lineIndex = <String, int>{};
final lines = <String>[];
String encode(List<String> src) {
final buf = StringBuffer();
for (final line in src) {
if (!lineIndex.containsKey(line)) {
lineIndex[line] = lines.length;
lines.add(line);
}
buf.writeCharCode(0xE000 + lineIndex[line]!);
}
return buf.toString();
}
final oldEncoded = encode(oldLines);
final newEncoded = encode(newLines);
return _LineEncoding(lines, oldEncoded, newEncoded);
}
// ─── widgets ──────────────────────────────────────────────────────────────────
String _hunkSummary(_Hunk hunk) {
final added = hunk.lines.where((l) => l.kind == _LineKind.added).length;
final removed = hunk.lines.where((l) => l.kind == _LineKind.removed).length;
final parts = <String>[];
if (added > 0) parts.add("Added $added ${added == 1 ? 'line' : 'lines'}");
if (removed > 0) parts.add("removed $removed ${removed == 1 ? 'line' : 'lines'}");
return parts.join(", ");
}
class _HunkView extends StatelessWidget {
const _HunkView({required this.hunk});
final _Hunk hunk;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// hunk header
Container(
// color: theme.colorScheme.muted.withValues(alpha: 0.4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
_hunkSummary(hunk),
style: TextStyle(color: theme.colorScheme.mutedForeground),
).xSmall.mono,
),
Divider(),
for (final line in hunk.lines) _LineView(line: line),
],
);
}
}
class _LineView extends StatelessWidget {
const _LineView({required this.line});
final _DiffLine line;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Color bg;
final Color fg;
final String prefix;
switch (line.kind) {
case _LineKind.added:
bg = const Color(0xFF166534).withValues(alpha: 0.2);
fg = const Color(0xFF4ADE80);
prefix = "+";
break;
case _LineKind.removed:
bg = const Color(0xFF991B1B).withValues(alpha: 0.2);
fg = const Color(0xFFF87171);
prefix = "-";
break;
case _LineKind.context:
bg = Colors.transparent;
fg = theme.colorScheme.mutedForeground;
prefix = " ";
break;
}
final numColor = theme.colorScheme.mutedForeground.withValues(alpha: 0.5);
final lineNum = line.kind == _LineKind.removed ? line.oldLine : line.newLine;
return Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 32,
child: Text(
lineNum != null ? "$lineNum" : "",
textAlign: TextAlign.right,
style: TextStyle(color: numColor),
).mono.xSmall,
),
const SizedBox(width: 8),
SizedBox(
width: 10,
child: Text(prefix, style: TextStyle(color: fg)).mono.xSmall,
),
const SizedBox(width: 4),
Expanded(
child: Text(line.text, style: TextStyle(color: fg)).mono.xSmall,
),
],
),
);
}
}
+181
View File
@@ -0,0 +1,181 @@
import "package:flutter/src/material/theme_data.dart";
import "package:flutter_markdown/flutter_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../../src/permissions/permission_types.dart";
import "../../../src/session/session_types.dart";
import "advisor_message.dart";
import "../common/button.dart";
class MessageBubble extends StatelessWidget {
const MessageBubble({
required this.message,
this.isPendingPermission = false,
this.onPermissionDecision,
});
final Message message;
final bool isPendingPermission;
final void Function(PermissionDecision)? onPermissionDecision;
@override
Widget build(BuildContext context) {
final isUser = message.role == "user";
final isTool = message.role == "tool";
final isAssistant = message.role == "assistant";
final theme = Theme.of(context);
if (isUser) {
return Align(
alignment: Alignment.centerRight,
child: OutlinedContainer(
padding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
backgroundColor: theme.colorScheme.border,
child: MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
),
),
);
} else if (isAssistant) {
return MarkdownBody(
data: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: _toolMarkdownStyleSheet(context),
);
} else if (isTool) {
final lines = message.content.split("\n");
final title = lines.first.trim();
final isAdvisor = title.startsWith("Advisor");
if (isAdvisor) {
final body = lines.skip(1).join("\n").trim();
return AdvisorMessage(title: title, body: body);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OutlinedContainer(
padding: const EdgeInsets.all(10),
backgroundColor: theme.colorScheme.primary,
child: Icon(LucideIcons.wrench).iconSmall,
),
Gap(8),
Expanded(
child: Text(
title,
style: theme.typography.p.copyWith(fontSize: 13),
),
),
],
),
if (isPendingPermission) ...[
Gap(8),
Row(
children: [
AgcSecondaryButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowOnce),
child: Text("Allow").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.allowAlways),
child: Text("Allow always").small,
),
Gap(8),
AgcGhostButton(
onPressed: () => onPermissionDecision?.call(PermissionDecision.reject),
child: Text("Reject").small,
),
],
),
],
],
);
}
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.role,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
if (isAssistant || isTool)
MarkdownBody(
data: isTool
? _buildToolMarkdown(message.content)
: message.content,
selectable: true,
shrinkWrap: true,
styleSheet: isTool
? _toolMarkdownStyleSheet(context)
: null,
)
else
Text(message.content),
],
),
),
),
);
}
String _buildToolMarkdown(String content) {
final lines = content.split("\n");
if (lines.isEmpty) {
return "```text\n\n```";
}
final title = lines.first.trim();
final body = lines.skip(1).join("\n").trimRight();
if (body.isEmpty) {
return title;
}
return "$title\n\n```text\n$body\n```";
}
MarkdownStyleSheet _toolMarkdownStyleSheet(BuildContext context) {
final theme = Theme.of(context);
return MarkdownStyleSheet(
p: theme.typography.p.copyWith(
fontSize: 13
),
);
}
}
+145
View File
@@ -0,0 +1,145 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "../../../src/api/openrouter_client.dart";
import "../../providers/settings_provider.dart";
class ModelPicker extends StatefulWidget {
const ModelPicker();
@override
State<ModelPicker> createState() => _ModelPickerState();
}
class _ModelPickerState extends State<ModelPicker> {
late Future<List<Map<String, dynamic>>> _modelsFuture;
@override
void initState() {
super.initState();
_modelsFuture = _loadModels();
}
Future<List<Map<String, dynamic>>> _loadModels() async {
try {
final apiKey = context.read<SettingsProvider>().settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
return [
{"id": "openai/gpt-4o", "name": "GPT-4o"},
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6"},
{"id": "google/gemini-2.0-flash-001", "name": "Gemini 2.0 Flash"},
];
}
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
final models = await client.listModels();
client.close();
return models;
} catch (e) {
return [
{"id": "openai/gpt-4o", "name": "GPT-4o"},
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6"},
{"id": "google/gemini-2.0-flash-001", "name": "Gemini 2.0 Flash"},
];
}
}
@override
Widget build(BuildContext context) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, _) {
final currentModel = settingsProvider.normalizeModelId(
settingsProvider.settings.model,
);
return FutureBuilder<List<Map<String, dynamic>>>(
future: _modelsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Text("Loading models...");
}
final models = snapshot.data ?? [];
final selectedModel = models.firstWhere(
(m) => m["id"] == currentModel,
orElse: () => {"id": currentModel, "name": currentModel},
);
return GestureDetector(
onTap: () => _showModelMenu(
context,
models,
currentModel,
settingsProvider,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
borderRadius: BorderRadius.circular(6),
),
child: Text(selectedModel["name"] as String? ?? currentModel),
),
);
},
);
},
);
}
void _showModelMenu(
BuildContext context,
List<Map<String, dynamic>> models,
String currentModel,
SettingsProvider settingsProvider,
) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Container(
constraints: const BoxConstraints(maxHeight: 400),
child: SingleChildScrollView(
child: Column(
children: models.map((model) {
final modelId = model["id"] as String;
final modelName = model["name"] as String? ?? modelId;
final isSelected = modelId == currentModel;
return Container(
color: isSelected
? const Color(0xFFF1F5F9)
: Colors.transparent,
child: GestureDetector(
onTap: () {
settingsProvider.updateModel(modelId);
Navigator.pop(ctx);
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
if (isSelected)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Text(
"",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(child: Text(modelName)),
],
),
),
),
);
}).toList(),
),
),
),
),
);
}
}
@@ -0,0 +1,103 @@
import "package:shadcn_flutter/shadcn_flutter.dart";
import "../../constants.dart";
class ModelPickerDialog extends StatefulWidget {
final List<SelectableAiModel> models;
final String? selectedModel;
const ModelPickerDialog({
super.key,
required this.models,
this.selectedModel,
});
@override
State<ModelPickerDialog> createState() => _ModelPickerDialogState();
}
class _ModelPickerDialogState extends State<ModelPickerDialog> {
late TextEditingController _searchController;
String _query = '';
@override
void initState() {
super.initState();
_searchController = TextEditingController();
_searchController.addListener(() {
setState(() => _query = _searchController.text.trim().toLowerCase());
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<SelectableAiModel> get _filtered {
if (_query.isEmpty) return widget.models;
return widget.models.where((m) =>
m.label.toLowerCase().contains(_query) ||
m.id.toLowerCase().contains(_query)
).toList();
}
@override
Widget build(BuildContext context) {
final filtered = _filtered;
return AlertDialog(
title: const Text('Select model'),
content: SizedBox(
width: 340,
height: 380,
child: Column(
children: [
TextField(
controller: _searchController,
autofocus: true,
placeholder: const Text('Search models...'),
features: const [InputFeature.clear()],
),
Gap(8),
Expanded(
child: filtered.isEmpty
? Center(child: Text("No results").muted)
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, i) {
final model = filtered[i];
final isSelected = model.id == widget.selectedModel;
return SizedBox(
width: double.infinity,
child: Button(
style: isSelected ? ButtonStyle.secondary() : ButtonStyle.ghost(),
disableFocusOutline: true,
onPressed: () => Navigator.of(context).pop(model.id),
child: Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(model.label),
Text(model.id).muted.small,
],
),
),
),
);
},
),
),
],
),
),
);
}
}