Add new features and update configurations for improved functionality
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class AgentsPane extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: OutlinedContainer(
|
||||
width: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
|
||||
|
||||
],
|
||||
)
|
||||
),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,21 @@ import "package:flutter/src/material/theme_data.dart";
|
||||
import "package:flutter_markdown/flutter_markdown.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../src/session/session_types.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});
|
||||
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) {
|
||||
@@ -19,23 +28,21 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
|
||||
if (isUser) {
|
||||
return Row(
|
||||
children: [
|
||||
Spacer(),
|
||||
OutlinedContainer(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
backgroundColor: theme.colorScheme.border,
|
||||
child: MarkdownBody(
|
||||
data: message.content,
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
styleSheet: _toolMarkdownStyleSheet(context),
|
||||
),
|
||||
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(
|
||||
@@ -48,27 +55,66 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
final lines = message.content.split("\n");
|
||||
final title = lines.first.trim();
|
||||
final isAdvisor = title.startsWith("Advisor");
|
||||
|
||||
return Row(
|
||||
if (isAdvisor) {
|
||||
final body = lines.skip(1).join("\n").trim();
|
||||
return AdvisorMessage(title: title, body: body);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 10,
|
||||
width: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
if (isPendingPermission) ...[
|
||||
Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Text(
|
||||
title,
|
||||
style: theme.typography.p.copyWith(
|
||||
fontSize: 13
|
||||
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,
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import "package:flutter/material.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
import "../../src/api/openrouter_client.dart";
|
||||
import "../providers/settings_provider.dart";
|
||||
import "../../../src/api/openrouter_client.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
|
||||
class ModelPicker extends StatefulWidget {
|
||||
const ModelPicker();
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/chat_provider.dart";
|
||||
import "message_bubble.dart";
|
||||
|
||||
class ChatView extends StatefulWidget {
|
||||
const ChatView();
|
||||
|
||||
@override
|
||||
State<ChatView> createState() => _ChatViewState();
|
||||
}
|
||||
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
late ScrollController _scrollController;
|
||||
List<String> _previousMessageContents = [];
|
||||
bool _isUserScrolling = false;
|
||||
DateTime? _lastScrollTime;
|
||||
bool _showJumpToBottom = false;
|
||||
bool _hasNewMessagesWhileScrolledAway = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = ScrollController();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_handleScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
_lastScrollTime = DateTime.now();
|
||||
_isUserScrolling = true;
|
||||
|
||||
// Update whether to show jump-to-bottom button
|
||||
if (_scrollController.hasClients) {
|
||||
final position = _scrollController.position;
|
||||
final isFarFromBottom = position.pixels < position.maxScrollExtent - 200;
|
||||
if (isFarFromBottom != _showJumpToBottom) {
|
||||
setState(() {
|
||||
_showJumpToBottom = isFarFromBottom;
|
||||
});
|
||||
}
|
||||
|
||||
// If user scrolls to bottom manually, clear the new messages flag
|
||||
if (!isFarFromBottom) {
|
||||
setState(() {
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if scrolling has stopped (no scroll events for 150ms)
|
||||
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;
|
||||
// Consider user to be "near bottom" if they're within 150 pixels of the bottom
|
||||
// Add a small buffer so we don't trigger on exact bottom
|
||||
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, _) {
|
||||
// Get current messages
|
||||
final currentMessages = chatProvider.messages;
|
||||
|
||||
// Check if messages have actually changed (not just re-renders)
|
||||
bool messagesChanged = false;
|
||||
|
||||
if (currentMessages.length != _previousMessageContents.length) {
|
||||
messagesChanged = true;
|
||||
} else {
|
||||
for (int i = 0; i < currentMessages.length; i++) {
|
||||
if (i >= _previousMessageContents.length ||
|
||||
currentMessages[i].content != _previousMessageContents[i]) {
|
||||
messagesChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messagesChanged && currentMessages.isNotEmpty) {
|
||||
// Check if we're near the bottom
|
||||
final nearBottom = _isNearBottom();
|
||||
|
||||
if (nearBottom && !_isUserScrolling) {
|
||||
// Auto-scroll to bottom if user is near bottom and not scrolling
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
_hasNewMessagesWhileScrolledAway = false;
|
||||
} else if (!nearBottom) {
|
||||
// User is scrolled away from bottom when new messages arrive
|
||||
_hasNewMessagesWhileScrolledAway = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update previous message state for next build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_previousMessageContents = currentMessages.map((m) => m.content).toList();
|
||||
});
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: currentMessages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = currentMessages[index];
|
||||
return Padding(
|
||||
padding: EdgeInsetsGeometry.only(
|
||||
top: index != 0 ? 12 : 0
|
||||
),
|
||||
child: MessageBubble(message: message)
|
||||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AgcGhostButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
AgcGhostButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcGhostButton> createState() => _GhostButtonState();
|
||||
}
|
||||
|
||||
class _GhostButtonState extends State<AgcGhostButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm - 4
|
||||
);
|
||||
|
||||
Color bg = Colors.transparent;
|
||||
if (_pressing) {
|
||||
bg = colorScheme.accent.withOpacity(0.8);
|
||||
} else if (_hovering) {
|
||||
bg = colorScheme.accent.withOpacity(0.5);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: widget.onPressed != null ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: (_) => setState(() => _pressing = true),
|
||||
onTapUp: (_) {
|
||||
setState(() => _pressing = false);
|
||||
if (widget.onPressed != null) widget.onPressed!();
|
||||
},
|
||||
onTapCancel: () => setState(() => _pressing = false),
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
),
|
||||
padding: EdgeInsets.all(4),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgcSecondaryButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
final bool enabled;
|
||||
|
||||
AgcSecondaryButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcSecondaryButton> createState() => _SecondaryButtonState();
|
||||
}
|
||||
|
||||
class _SecondaryButtonState extends State<AgcSecondaryButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm
|
||||
);
|
||||
|
||||
final bool active = widget.enabled && widget.onPressed != null;
|
||||
|
||||
Color bg = colorScheme.secondary;
|
||||
if (!active) {
|
||||
bg = colorScheme.secondary.withOpacity(0.4);
|
||||
} else if (_pressing) {
|
||||
bg = colorScheme.secondary.withOpacity(0.75);
|
||||
} else if (_hovering) {
|
||||
bg = colorScheme.secondary.withOpacity(0.85);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) { if (active) setState(() => _hovering = true); },
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
|
||||
onTapUp: active ? (_) {
|
||||
setState(() => _pressing = false);
|
||||
widget.onPressed!();
|
||||
} : null,
|
||||
onTapCancel: active ? () => setState(() => _pressing = false) : null,
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
),
|
||||
padding: EdgeInsets.all(4),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(color: colorScheme.secondaryForeground),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(color: colorScheme.secondaryForeground),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgcOutlinedButton extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final BorderRadius? borderRadius;
|
||||
final bool enabled;
|
||||
|
||||
AgcOutlinedButton({
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.borderRadius,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AgcOutlinedButton> createState() => _OutlinedButtonState();
|
||||
}
|
||||
|
||||
class _OutlinedButtonState extends State<AgcOutlinedButton> {
|
||||
|
||||
bool _hovering = false;
|
||||
bool _pressing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(
|
||||
Theme.of(context).radiusSm
|
||||
);
|
||||
|
||||
final bool active = widget.enabled && widget.onPressed != null;
|
||||
|
||||
Color bg = Colors.transparent;
|
||||
if (_pressing && active) {
|
||||
bg = colorScheme.accent.withOpacity(0.6);
|
||||
} else if (_hovering && active) {
|
||||
bg = colorScheme.accent.withOpacity(0.35);
|
||||
}
|
||||
|
||||
final borderColor = active
|
||||
? colorScheme.border
|
||||
: colorScheme.border.withOpacity(0.4);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: active ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
onEnter: (_) { if (active) setState(() => _hovering = true); },
|
||||
onExit: (_) => setState(() {
|
||||
_hovering = false;
|
||||
_pressing = false;
|
||||
}),
|
||||
|
||||
child: GestureDetector(
|
||||
onTapDown: active ? (_) => setState(() => _pressing = true) : null,
|
||||
onTapUp: active ? (_) {
|
||||
setState(() => _pressing = false);
|
||||
widget.onPressed!();
|
||||
} : null,
|
||||
onTapCancel: active ? () => setState(() => _pressing = false) : null,
|
||||
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 80),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: TextStyle(
|
||||
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(
|
||||
color: active ? colorScheme.foreground : colorScheme.mutedForeground,
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import "package:flutter/widgets.dart" hide Tooltip;
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart" hide Row, Expanded;
|
||||
|
||||
import "../../providers/chat_provider.dart";
|
||||
import "../../providers/cost_provider.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
class FooterBar extends StatelessWidget {
|
||||
const FooterBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mutedFg = theme.colorScheme.mutedForeground;
|
||||
final borderColor = theme.colorScheme.border;
|
||||
final bg = theme.colorScheme.muted.scaleAlpha(0.3);
|
||||
|
||||
final costProvider = context.watch<CostProvider>();
|
||||
final settingsProvider = context.watch<SettingsProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
|
||||
final model = settingsProvider.settings.model ?? "unknown";
|
||||
final costUsd = costProvider.getTotalCostUsd();
|
||||
final cost = "\$${costUsd.toStringAsFixed(4)}";
|
||||
final inputToks = costProvider.getTotalInputTokens();
|
||||
final outputToks = costProvider.getTotalOutputTokens();
|
||||
final isLoading = chatProvider.isLoading;
|
||||
final contextTokens = chatProvider.contextTokens;
|
||||
|
||||
final textStyle = TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: mutedFg,
|
||||
);
|
||||
|
||||
Widget divider() => const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 5),
|
||||
child: SizedBox(height: 12, child: VerticalDivider(width: 1)),
|
||||
);
|
||||
|
||||
Widget copyrightBlock() {
|
||||
return Text(
|
||||
"© 2026 IMBENJI.NET LTD - The Agency",
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
|
||||
Widget statusBlock() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
Text(
|
||||
isLoading ? "running..." : "idle",
|
||||
style: textStyle.copyWith(
|
||||
color: isLoading
|
||||
? theme.colorScheme.primary
|
||||
: mutedFg,
|
||||
),
|
||||
),
|
||||
|
||||
divider(),
|
||||
|
||||
Text(model.split("/").last, style: textStyle),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget statsBlock() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
if (contextTokens > 0) ...[
|
||||
Text(_fmtTokens(contextTokens), style: textStyle),
|
||||
Text(" tokens", style: textStyle),
|
||||
divider(),
|
||||
],
|
||||
|
||||
Tooltip(
|
||||
tooltip: (_) => TooltipContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Text(
|
||||
"In: $inputToks\nOut: $outputToks",
|
||||
style: const TextStyle(
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(cost, style: textStyle),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
border: Border(top: BorderSide(color: borderColor, width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Expanded(child: Row(children: [copyrightBlock()])),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [statusBlock()],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [statsBlock()],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../providers/settings_provider.dart";
|
||||
import "model_picker.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "../chat/model_picker.dart";
|
||||
|
||||
class SettingsSheet extends StatelessWidget {
|
||||
const SettingsSheet();
|
||||
@@ -0,0 +1,32 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
class AppLogo extends StatelessWidget {
|
||||
const AppLogo({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
"THE AGENCY",
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"by IMBENJI.NET LTD",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../utils/format_relative_time.dart';
|
||||
|
||||
class ProjectButton extends StatefulWidget {
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final DateTime? lastMessage;
|
||||
final bool collapsed;
|
||||
|
||||
ProjectButton({
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.lastMessage,
|
||||
this.collapsed = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProjectButton> createState() => ProjectButtonState();
|
||||
}
|
||||
|
||||
class ProjectButtonState extends State<ProjectButton> with TickerProviderStateMixin {
|
||||
|
||||
bool _isHovering = false;
|
||||
late AnimationController _chevronController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chevronController = AnimationController(
|
||||
duration: Duration(milliseconds: 100),
|
||||
vsync: this,
|
||||
);
|
||||
if (!widget.collapsed) {
|
||||
_chevronController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ProjectButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.collapsed != widget.collapsed) {
|
||||
if (widget.collapsed) {
|
||||
_chevronController.reverse();
|
||||
} else {
|
||||
_chevronController.forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_chevronController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 12,
|
||||
bottom: 8,
|
||||
right: 12
|
||||
);
|
||||
}
|
||||
),
|
||||
disableFocusOutline: true,
|
||||
onPressed: () {
|
||||
if (widget.onPressed != null) {
|
||||
widget.onPressed!();
|
||||
}
|
||||
},
|
||||
onHover: (isHovering) {
|
||||
setState(() {
|
||||
_isHovering = isHovering;
|
||||
});
|
||||
},
|
||||
leading: !_isHovering ? Icon(
|
||||
!widget.collapsed ? LucideIcons.folderOpen : LucideIcons.folderClosed
|
||||
).iconSmall : RotationTransition(
|
||||
turns: Tween(begin: 0.0, end: 0.25).animate(_chevronController),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: colorScheme.mutedForeground,
|
||||
).iconSmall,
|
||||
),
|
||||
trailingGap: 32,
|
||||
trailing: widget.lastMessage != null ?
|
||||
Text(
|
||||
formatRelativeTime(widget.lastMessage!)
|
||||
).muted : null,
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.mutedForeground
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small,
|
||||
),
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import 'project_button.dart';
|
||||
|
||||
class ProjectSection extends StatefulWidget {
|
||||
|
||||
final String projectLabel;
|
||||
final List<Widget> children;
|
||||
final VoidCallback? onHeaderPressed;
|
||||
|
||||
ProjectSection({
|
||||
required this.projectLabel,
|
||||
this.children = const [],
|
||||
this.onHeaderPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProjectSection> createState() => ProjectSectionState();
|
||||
}
|
||||
|
||||
class ProjectSectionState extends State<ProjectSection> with TickerProviderStateMixin {
|
||||
|
||||
bool _isCollapsed = true;
|
||||
late AnimationController _sizeController;
|
||||
late AnimationController _fadeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sizeController = AnimationController(
|
||||
duration: Duration(milliseconds: 150),
|
||||
vsync: this,
|
||||
);
|
||||
_fadeController = AnimationController(
|
||||
duration: Duration(milliseconds: 250),
|
||||
vsync: this,
|
||||
);
|
||||
if (!_isCollapsed) {
|
||||
_sizeController.forward();
|
||||
_fadeController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sizeController.dispose();
|
||||
_fadeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
|
||||
ProjectButton(
|
||||
label: widget.projectLabel,
|
||||
collapsed: _isCollapsed,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isCollapsed = !_isCollapsed;
|
||||
if (_isCollapsed) {
|
||||
_fadeController.reverse();
|
||||
_sizeController.reverse();
|
||||
} else {
|
||||
_fadeController.forward();
|
||||
_sizeController.forward();
|
||||
}
|
||||
});
|
||||
widget.onHeaderPressed?.call();
|
||||
},
|
||||
),
|
||||
|
||||
Gap(2),
|
||||
|
||||
ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: FadeTransition(
|
||||
opacity: _fadeController,
|
||||
child: SizeTransition(
|
||||
sizeFactor: _sizeController,
|
||||
child: Column(
|
||||
spacing: 2,
|
||||
children: [
|
||||
|
||||
...widget.children
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../../src/session/session_types.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/home_coordinator.dart';
|
||||
import '../../providers/projects_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import 'app_logo.dart';
|
||||
import 'project_section.dart';
|
||||
import 'thread_button.dart';
|
||||
|
||||
class Sidebar extends StatelessWidget {
|
||||
|
||||
const Sidebar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final sessionProvider = context.watch<SessionProvider>();
|
||||
final chatProvider = context.watch<ChatProvider>();
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
|
||||
return Container(
|
||||
width: 300,
|
||||
color: Theme.of(context).colorScheme.input.scaleAlpha(0.3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
height: 100,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AppLogo(),
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _fixedSection(context, coordinator, chatProvider),
|
||||
),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Divider(),
|
||||
|
||||
Gap(16),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: _projectsSection(context, projectsProvider, sessionProvider, coordinator),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fixedSection(BuildContext context, HomeCoordinator coordinator, ChatProvider chatProvider) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
right: 10
|
||||
);
|
||||
}
|
||||
),
|
||||
onPressed: chatProvider.isLoading ? null : coordinator.createNewChat,
|
||||
disableFocusOutline: true,
|
||||
leading: Icon(LucideIcons.squarePen).iconSmall,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("New Chat"),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button.ghost(
|
||||
style: ButtonStyle.ghost().copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
right: 10
|
||||
);
|
||||
}
|
||||
),
|
||||
onPressed: coordinator.pickProjectDirectory,
|
||||
disableFocusOutline: true,
|
||||
leading: Icon(LucideIcons.folderPlus).iconSmall,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("New Project"),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _projectsSection(BuildContext context, ProjectsProvider projectsProvider, SessionProvider sessionProvider, HomeCoordinator coordinator) {
|
||||
if (projectsProvider.projects.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Text("No projects yet").textSmall.muted,
|
||||
);
|
||||
}
|
||||
|
||||
// group sessions by working directory
|
||||
final sessionsByProject = <String, List<SessionSummary>>{};
|
||||
for (final session in sessionProvider.sessions) {
|
||||
final dir = session.workingDirectory ?? '';
|
||||
sessionsByProject.putIfAbsent(dir, () => []).add(session);
|
||||
}
|
||||
|
||||
// sort sessions within each project newest first
|
||||
final sorted = <String, List<SessionSummary>>{};
|
||||
sessionsByProject.forEach((dir, sessions) {
|
||||
sorted[dir] = List<SessionSummary>.from(sessions)
|
||||
..sort((a, b) => b.updated.compareTo(a.updated));
|
||||
});
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text("Projects").textMuted,
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
for (final project in projectsProvider.projects) ...[
|
||||
|
||||
ProjectSection(
|
||||
projectLabel: project.name,
|
||||
children: [
|
||||
if (sorted[project.workingDirectory]?.isEmpty ?? true)
|
||||
ThreadButton(
|
||||
label: "No threads yet",
|
||||
muted: true,
|
||||
)
|
||||
else
|
||||
for (final session in sorted[project.workingDirectory]!)
|
||||
ThreadButton(
|
||||
label: session.name,
|
||||
lastMessage: session.updated,
|
||||
selected: sessionProvider.currentSessionId == session.id,
|
||||
onPressed: () => coordinator.openSession(session),
|
||||
onDelete: () => coordinator.deleteSession(session),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Gap(2),
|
||||
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import '../../utils/format_relative_time.dart';
|
||||
|
||||
class ThreadButton extends StatelessWidget {
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final VoidCallback? onDelete;
|
||||
final DateTime? lastMessage;
|
||||
final bool selected;
|
||||
final bool muted;
|
||||
|
||||
ThreadButton({
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.onDelete,
|
||||
this.lastMessage,
|
||||
this.selected = false,
|
||||
this.muted = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
ButtonStyle style = selected ? ButtonStyle.secondary() : ButtonStyle.ghost();
|
||||
|
||||
ColorScheme colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
final button = SizedBox(
|
||||
width: double.infinity,
|
||||
child: Button(
|
||||
style: style.copyWith(
|
||||
padding: (context, state, edgeInsets) {
|
||||
return EdgeInsets.only(
|
||||
top: 8,
|
||||
left: 12,
|
||||
bottom: 8,
|
||||
right: 12
|
||||
);
|
||||
}
|
||||
),
|
||||
disableFocusOutline: true,
|
||||
onPressed: onPressed ?? () {},
|
||||
enabled: onPressed != null,
|
||||
leading: Icon(
|
||||
icon,
|
||||
color: icon == null ? Colors.transparent : (muted ? colorScheme.mutedForeground : null),
|
||||
).iconSmall,
|
||||
|
||||
trailingGap: 32,
|
||||
trailing: lastMessage != null ?
|
||||
Text(
|
||||
formatRelativeTime(lastMessage!)
|
||||
).muted.small.light : null,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: (muted ? colorScheme.mutedForeground : null)
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small.light,
|
||||
),
|
||||
);
|
||||
|
||||
if (onDelete == null) return button;
|
||||
|
||||
return ContextMenu(
|
||||
items: [
|
||||
MenuButton(
|
||||
onPressed: (_) => onDelete!(),
|
||||
child: const Text("Delete"),
|
||||
),
|
||||
],
|
||||
child: button,
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user