Add command files and enhance session management features
This commit is contained in:
@@ -2,12 +2,24 @@ 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});
|
||||
const AssistantBubble({super.key, required this.content, this.isStreaming = false});
|
||||
|
||||
final String content;
|
||||
final bool isStreaming;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
if (isStreaming) {
|
||||
return SelectableText(
|
||||
content,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.foreground,
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GptMarkdown(content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
|
||||
export "../../../../src/permissions/permission_types.dart" show PermissionDecision, PendingPermission;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "dart:convert";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../src/permissions/permission_types.dart";
|
||||
import "tools/advisor_bubble.dart";
|
||||
import "tools/bash_bubble.dart";
|
||||
import "tools/default_tool_bubble.dart";
|
||||
@@ -17,13 +18,13 @@ class ToolBubble extends StatelessWidget {
|
||||
required this.toolName,
|
||||
this.toolInput,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? toolInput;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
// parse a tool message content string into (toolName, toolInput)
|
||||
// format: "$toolName call\n{json}" or "$toolName result\n..."
|
||||
@@ -41,7 +42,11 @@ class ToolBubble extends StatelessWidget {
|
||||
|
||||
if (firstLine.endsWith(" call") && rest.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(rest);
|
||||
// streamed output may be appended after the json block, so only
|
||||
// decode up to the closing brace — not the whole rest string
|
||||
final jsonEnd = _findJsonEnd(rest);
|
||||
final jsonStr = jsonEnd != -1 ? rest.substring(0, jsonEnd + 1) : rest;
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return (name, decoded);
|
||||
}
|
||||
@@ -51,6 +56,27 @@ class ToolBubble extends StatelessWidget {
|
||||
return (name, null);
|
||||
}
|
||||
|
||||
// find the index of the closing } that ends the top-level json object
|
||||
static int _findJsonEnd(String s) {
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
for (int i = 0; i < s.length; i++) {
|
||||
final c = s[i];
|
||||
if (inString) {
|
||||
if (c == "\\" ) { i++; continue; } // skip escaped char
|
||||
if (c == "\"") inString = false;
|
||||
} else {
|
||||
if (c == "\"") inString = true;
|
||||
else if (c == "{") depth++;
|
||||
else if (c == "}") {
|
||||
depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static String _extractName(String line) {
|
||||
// strip trailing " call" or " result"
|
||||
if (line.endsWith(" call")) return line.substring(0, line.length - 5).trim();
|
||||
@@ -64,26 +90,26 @@ class ToolBubble extends StatelessWidget {
|
||||
|
||||
switch (toolName) {
|
||||
case "Bash":
|
||||
return BashBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return BashBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Edit":
|
||||
return EditBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return EditBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Read":
|
||||
return ReadBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return ReadBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Write":
|
||||
return WriteBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return WriteBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Glob":
|
||||
return GlobBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return GlobBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Grep":
|
||||
return GrepBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return GrepBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "WebSearch":
|
||||
return WebSearchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return WebSearchBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "WebFetch":
|
||||
return WebFetchBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return WebFetchBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
case "Advisor":
|
||||
return AdvisorBubble(input: input, result: result, isPendingPermission: isPendingPermission);
|
||||
return AdvisorBubble(input: input, result: result, pendingPermission: pendingPermission);
|
||||
|
||||
default:
|
||||
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, isPendingPermission: isPendingPermission);
|
||||
return DefaultToolBubble(toolName: toolName, input: toolInput, result: result, pendingPermission: pendingPermission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class AdvisorBubble extends StatelessWidget {
|
||||
@@ -6,12 +7,12 @@ class AdvisorBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,7 +22,7 @@ class AdvisorBubble extends StatelessWidget {
|
||||
toolName: "Advisor",
|
||||
icon: LucideIcons.brain,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: model,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,397 @@
|
||||
import "dart:io";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
import "package:provider/provider.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../common/pane_dialog.dart";
|
||||
|
||||
class BashBubble extends StatelessWidget {
|
||||
class BashBubble extends StatefulWidget {
|
||||
const BashBubble({
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
State<BashBubble> createState() => _BashBubbleState();
|
||||
}
|
||||
|
||||
class _BashBubbleState extends State<BashBubble> {
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
late final TextEditingController _ruleController;
|
||||
bool _overflows = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ruleController = TextEditingController(
|
||||
text: widget.pendingPermission?.suggestionRule ?? "Bash",
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkOverflow());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(BashBubble oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final newRule = widget.pendingPermission?.suggestionRule ?? "Bash";
|
||||
final oldRule = oldWidget.pendingPermission?.suggestionRule ?? "Bash";
|
||||
if (newRule != oldRule) _ruleController.text = newRule;
|
||||
if (oldWidget.result != widget.result) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkOverflow());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_ruleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _openFullscreen(
|
||||
BuildContext context,
|
||||
String command,
|
||||
Color termBg,
|
||||
Color termTitleBg,
|
||||
Color termBorder,
|
||||
Color outputGray,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: PaneDialog(
|
||||
title: "Terminal",
|
||||
fillHeight: true,
|
||||
onClose: () => Navigator.of(ctx).pop(),
|
||||
child: Flexible(
|
||||
child: Container(
|
||||
color: termBg,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 6),
|
||||
child: _TerminalPrompt(
|
||||
command: command,
|
||||
cwd: widget.input["cwd"] as String? ??
|
||||
context.read<ChatProvider>().workingDirectory,
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 16),
|
||||
child: SelectableText(
|
||||
widget.result ?? "",
|
||||
style: theme.typography.mono.copyWith(
|
||||
color: outputGray,
|
||||
fontSize: 12,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _checkOverflow() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final overflows = _scrollController.position.maxScrollExtent > 0;
|
||||
if (overflows != _overflows) setState(() => _overflows = overflows);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final command = input["command"] as String? ?? "";
|
||||
final command = widget.input["command"] as String? ?? "";
|
||||
final hasResult = widget.result != null && widget.result!.isNotEmpty;
|
||||
|
||||
return ToolBubbleBase(
|
||||
toolName: "Bash",
|
||||
icon: LucideIcons.terminal,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
detail: command,
|
||||
final theme = Theme.of(context);
|
||||
final dark = theme.brightness == Brightness.dark;
|
||||
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
|
||||
final s = HSLColor.fromColor(theme.colorScheme.border).saturation;
|
||||
|
||||
final termBg = dark
|
||||
? HSLColor.fromAHSL(1, h, s.clamp(0, 0.35), 0.10).toColor()
|
||||
: HSLColor.fromAHSL(1, h, s.clamp(0, 0.30), 0.92).toColor();
|
||||
|
||||
final termTitleBg = dark
|
||||
? HSLColor.fromAHSL(1, h, s.clamp(0, 0.35), 0.14).toColor()
|
||||
: HSLColor.fromAHSL(1, h, s.clamp(0, 0.30), 0.87).toColor();
|
||||
|
||||
final termBorder = theme.colorScheme.border;
|
||||
final outputGray = theme.colorScheme.mutedForeground;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: termBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: termBorder, width: 1),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
// title bar
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: termTitleBg,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: termBorder, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
"Terminal",
|
||||
style: theme.typography.p.copyWith(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
if (hasResult)
|
||||
GestureDetector(
|
||||
onTap: () => _openFullscreen(context, command, termBg, termTitleBg, termBorder, outputGray),
|
||||
child: Icon(
|
||||
LucideIcons.maximize2,
|
||||
size: 13,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// prompt always visible
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 6),
|
||||
child: _TerminalPrompt(
|
||||
command: command,
|
||||
cwd: widget.input["cwd"] as String? ??
|
||||
context.read<ChatProvider>().workingDirectory,
|
||||
),
|
||||
),
|
||||
|
||||
// output — shrinks to content, caps at maxHeight, gradient only when overflowing
|
||||
if (hasResult)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
child: ShaderMask(
|
||||
shaderCallback: (rect) => LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
_overflows ? Colors.transparent : Colors.white,
|
||||
Colors.white,
|
||||
],
|
||||
stops: const [0.0, 0.15],
|
||||
).createShader(rect),
|
||||
blendMode: BlendMode.dstIn,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: SelectableText(
|
||||
widget.result!,
|
||||
style: theme.typography.mono.copyWith(
|
||||
color: outputGray,
|
||||
fontSize: 11,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!hasResult) const SizedBox(height: 10),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (widget.pendingPermission != null) ...[
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text("Don't ask again for:").xSmall,
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ruleController,
|
||||
style: const TextStyle(fontSize: 12, fontFamily: "monospace"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Yes").small,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(
|
||||
PermissionDecision.allowAlways,
|
||||
persistRule: _ruleController.text.trim(),
|
||||
),
|
||||
child: Text("Yes, don't ask again").small,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
|
||||
child: Text("No").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _TerminalPrompt extends StatelessWidget {
|
||||
const _TerminalPrompt({required this.command, this.cwd});
|
||||
final String command;
|
||||
final String? cwd;
|
||||
|
||||
String _shortCwd() {
|
||||
try {
|
||||
final home = Platform.environment["HOME"] ?? "";
|
||||
var resolved = cwd ?? Directory.current.path;
|
||||
if (home.isNotEmpty && resolved.startsWith(home)) {
|
||||
resolved = "~${resolved.substring(home.length)}";
|
||||
}
|
||||
return resolved;
|
||||
} catch (_) {
|
||||
return "~";
|
||||
}
|
||||
}
|
||||
|
||||
String _hostname() {
|
||||
try {
|
||||
return Platform.localHostname.split(".").first;
|
||||
} catch (_) {
|
||||
return "localhost";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hostname = _hostname();
|
||||
final cwd = _shortCwd();
|
||||
final theme = Theme.of(context);
|
||||
final monoBase = theme.typography.mono.copyWith(
|
||||
fontSize: 11,
|
||||
height: 1.25,
|
||||
);
|
||||
|
||||
final fg = theme.colorScheme.foreground;
|
||||
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style: monoBase,
|
||||
children: [
|
||||
|
||||
// the_agency@hostname — green
|
||||
TextSpan(
|
||||
text: "the_agency@$hostname",
|
||||
style: const TextStyle(color: Color(0xFF4EC94E), fontWeight: FontWeight.w600),
|
||||
),
|
||||
|
||||
TextSpan(text: ":", style: TextStyle(color: fg)),
|
||||
|
||||
// cwd — blue
|
||||
TextSpan(
|
||||
text: cwd,
|
||||
style: const TextStyle(color: Color(0xFF5BB8FF)),
|
||||
),
|
||||
|
||||
TextSpan(text: "\$ ", style: TextStyle(color: fg)),
|
||||
|
||||
TextSpan(text: command, style: TextStyle(color: fg)),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _Dot extends StatelessWidget {
|
||||
const _Dot({required this.color});
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "dart:convert";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class DefaultToolBubble extends StatelessWidget {
|
||||
@@ -8,13 +9,13 @@ class DefaultToolBubble extends StatelessWidget {
|
||||
required this.toolName,
|
||||
this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
final Map<String, dynamic>? input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -24,7 +25,7 @@ class DefaultToolBubble extends StatelessWidget {
|
||||
toolName: toolName,
|
||||
icon: LucideIcons.wrench,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
body: input != null && input!.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "../../diff_view.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
@@ -10,12 +11,12 @@ class EditBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -28,7 +29,7 @@ class EditBubble extends StatelessWidget {
|
||||
toolName: "Edit",
|
||||
icon: LucideIcons.filePen,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
body: DiffView(
|
||||
oldString: oldString,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
@@ -9,12 +10,12 @@ class GlobBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -30,7 +31,7 @@ class GlobBubble extends StatelessWidget {
|
||||
toolName: "Glob",
|
||||
icon: LucideIcons.folderSearch,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
@@ -9,12 +10,12 @@ class GrepBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -30,7 +31,7 @@ class GrepBubble extends StatelessWidget {
|
||||
toolName: "Grep",
|
||||
icon: LucideIcons.search,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: detail,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
@@ -9,12 +10,12 @@ class ReadBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -25,7 +26,7 @@ class ReadBubble extends StatelessWidget {
|
||||
toolName: "Read",
|
||||
icon: LucideIcons.fileText,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
|
||||
class ToolBubbleBase extends StatelessWidget {
|
||||
class ToolBubbleBase extends StatefulWidget {
|
||||
const ToolBubbleBase({
|
||||
super.key,
|
||||
required this.toolName,
|
||||
@@ -11,7 +11,7 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
this.detail,
|
||||
this.body,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final String toolName;
|
||||
@@ -19,17 +19,77 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
final String? detail;
|
||||
final Widget? body;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
State<ToolBubbleBase> createState() => _ToolBubbleBaseState();
|
||||
}
|
||||
|
||||
class _ToolBubbleBaseState extends State<ToolBubbleBase> {
|
||||
|
||||
late final TextEditingController _ruleController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ruleController = TextEditingController(
|
||||
text: widget.pendingPermission?.suggestionRule ?? widget.toolName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ToolBubbleBase old) {
|
||||
super.didUpdateWidget(old);
|
||||
final newRule = widget.pendingPermission?.suggestionRule ?? widget.toolName;
|
||||
final oldRule = old.pendingPermission?.suggestionRule ?? old.toolName;
|
||||
if (newRule != oldRule) {
|
||||
_ruleController.text = newRule;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ruleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _isFileTool {
|
||||
const ft = {"Edit", "Write", "Read", "MultiEdit"};
|
||||
return ft.contains(widget.toolName);
|
||||
}
|
||||
|
||||
bool get _isBash => widget.toolName == "Bash";
|
||||
|
||||
bool get _isWebFetch => widget.toolName == "WebFetch" || widget.toolName == "WebSearch";
|
||||
|
||||
// extract hostname from a url for the WebFetch "dont ask again" label
|
||||
String _hostnameFromPending() {
|
||||
final pp = widget.pendingPermission;
|
||||
if (pp == null) return "";
|
||||
final url = pp.input["url"] as String? ?? pp.input["query"] as String? ?? "";
|
||||
try {
|
||||
return Uri.parse(url).host;
|
||||
} catch (_) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// rule to persist for webfetch — "WebFetch(hostname:*)"
|
||||
String _webFetchPersistRule() {
|
||||
final host = _hostnameFromPending();
|
||||
if (host.isEmpty) return widget.toolName;
|
||||
return "WebFetch($host:*)";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final pp = widget.pendingPermission;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
|
||||
OutlinedContainer(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
@@ -47,24 +107,17 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon).iconSmall,
|
||||
|
||||
Icon(widget.icon).iconSmall,
|
||||
Gap(8),
|
||||
|
||||
Text(
|
||||
toolName,
|
||||
).textSmall,
|
||||
|
||||
Text(widget.toolName).textSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
VerticalDivider(),
|
||||
|
||||
if (detail != null)...[
|
||||
if (widget.detail != null)...[
|
||||
Gap(16),
|
||||
Text(
|
||||
detail!,
|
||||
).mono.xSmall
|
||||
Text(widget.detail!).mono.xSmall
|
||||
]
|
||||
|
||||
],
|
||||
@@ -72,14 +125,12 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
),
|
||||
|
||||
|
||||
|
||||
if (body != null) ...[
|
||||
if (widget.body != null) ...[
|
||||
Divider(),
|
||||
|
||||
body!,
|
||||
widget.body!,
|
||||
],
|
||||
|
||||
if (result != null) ...[
|
||||
if (widget.result != null) ...[
|
||||
Divider(),
|
||||
|
||||
Padding(
|
||||
@@ -88,7 +139,7 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
vertical: 8,
|
||||
),
|
||||
child: SelectableText(
|
||||
"\u200B${result!}",
|
||||
"\u200B${widget.result!}",
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
@@ -96,50 +147,253 @@ class ToolBubbleBase extends StatelessWidget {
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (isPendingPermission) ...[
|
||||
|
||||
if (pp != null) ...[
|
||||
|
||||
Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Yes").small,
|
||||
),
|
||||
),
|
||||
if (_isBash) ...[
|
||||
// bash: editable rule field + buttons
|
||||
_BashPermissionButtons(ruleController: _ruleController),
|
||||
] else if (_isWebFetch) ...[
|
||||
// webfetch: domain-scoped dont-ask-again
|
||||
_WebFetchPermissionButtons(
|
||||
hostname: _hostnameFromPending(),
|
||||
persistRule: _webFetchPersistRule(),
|
||||
),
|
||||
] else if (_isFileTool) ...[
|
||||
// file tools: session-scoped only, no persist
|
||||
_FilePermissionButtons(),
|
||||
] else ...[
|
||||
// default: show rule in button
|
||||
_DefaultPermissionButtons(
|
||||
persistRule: pp.suggestionRule ?? widget.toolName,
|
||||
),
|
||||
],
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.allowAlways),
|
||||
child: Text("Yes, for this session").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => context.read<ChatProvider>().resolvePermission(PermissionDecision.reject),
|
||||
child: Text("No").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── permission button sets ──────────────────────────────────────────────────
|
||||
|
||||
class _BashPermissionButtons extends StatelessWidget {
|
||||
const _BashPermissionButtons({required this.ruleController});
|
||||
final TextEditingController ruleController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chat = context.read<ChatProvider>();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
Row(
|
||||
children: [
|
||||
Text("Don't ask again for:").xSmall,
|
||||
Gap(8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: ruleController,
|
||||
style: const TextStyle(fontSize: 12, fontFamily: "monospace"),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Gap(6),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Yes").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(
|
||||
PermissionDecision.allowAlways,
|
||||
persistRule: ruleController.text.trim(),
|
||||
),
|
||||
child: Text("Yes, don't ask again").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.reject),
|
||||
child: Text("No").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _WebFetchPermissionButtons extends StatelessWidget {
|
||||
const _WebFetchPermissionButtons({required this.hostname, required this.persistRule});
|
||||
final String hostname;
|
||||
final String persistRule;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chat = context.read<ChatProvider>();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Yes").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(
|
||||
PermissionDecision.allowAlways,
|
||||
persistRule: persistRule,
|
||||
),
|
||||
child: hostname.isNotEmpty
|
||||
? Text("Yes, always for $hostname").small
|
||||
: Text("Yes, don't ask again").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.reject),
|
||||
child: Text("No").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _FilePermissionButtons extends StatelessWidget {
|
||||
const _FilePermissionButtons();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chat = context.read<ChatProvider>();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Yes").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
// no persistRule — session-scoped only
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.allowAlways),
|
||||
child: Text("Yes, for this session").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.reject),
|
||||
child: Text("No").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _DefaultPermissionButtons extends StatelessWidget {
|
||||
const _DefaultPermissionButtons({required this.persistRule});
|
||||
final String persistRule;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chat = context.read<ChatProvider>();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.check).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.allowOnce),
|
||||
child: Text("Yes").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.outline(
|
||||
leading: Icon(LucideIcons.checkCheck).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(
|
||||
PermissionDecision.allowAlways,
|
||||
persistRule: persistRule,
|
||||
),
|
||||
child: Text("Yes, don't ask again").small,
|
||||
),
|
||||
),
|
||||
|
||||
Gap(8),
|
||||
|
||||
Expanded(
|
||||
child: Button.destructive(
|
||||
leading: Icon(LucideIcons.x).iconSmall,
|
||||
onPressed: () => chat.resolvePermission(PermissionDecision.reject),
|
||||
child: Text("No").small,
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WebFetchBubble extends StatelessWidget {
|
||||
@@ -6,12 +7,12 @@ class WebFetchBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,7 +22,7 @@ class WebFetchBubble extends StatelessWidget {
|
||||
toolName: "WebFetch",
|
||||
icon: LucideIcons.link,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: url,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
|
||||
class WebSearchBubble extends StatelessWidget {
|
||||
@@ -6,12 +7,12 @@ class WebSearchBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -21,7 +22,7 @@ class WebSearchBubble extends StatelessWidget {
|
||||
toolName: "WebSearch",
|
||||
icon: LucideIcons.globe,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: query,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../../../providers/chat_provider.dart";
|
||||
import "../permission_decision.dart";
|
||||
import "../../../../utils/path_utils.dart";
|
||||
import "../../diff_view.dart";
|
||||
import "tool_bubble_base.dart";
|
||||
@@ -10,12 +11,12 @@ class WriteBubble extends StatelessWidget {
|
||||
super.key,
|
||||
required this.input,
|
||||
this.result,
|
||||
this.isPendingPermission = false,
|
||||
this.pendingPermission,
|
||||
});
|
||||
|
||||
final Map<String, dynamic> input;
|
||||
final String? result;
|
||||
final bool isPendingPermission;
|
||||
final PendingPermission? pendingPermission;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -27,7 +28,7 @@ class WriteBubble extends StatelessWidget {
|
||||
toolName: "Write",
|
||||
icon: LucideIcons.filePlus,
|
||||
result: result,
|
||||
isPendingPermission: isPendingPermission,
|
||||
pendingPermission: pendingPermission,
|
||||
detail: shortenPath(filePath, projectRoot),
|
||||
body: DiffView(
|
||||
oldString: "",
|
||||
|
||||
@@ -1,24 +1,68 @@
|
||||
import "dart:typed_data";
|
||||
import "package:clawd_code/ui/widgets/common/button.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "package:flutter/services.dart";
|
||||
import "../../../models/attachment.dart";
|
||||
import "../attachment_preview.dart";
|
||||
import "../../../../src/session/session_types.dart";
|
||||
|
||||
class UserBubble extends StatelessWidget {
|
||||
Color _msgColour(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dark = theme.brightness == Brightness.dark;
|
||||
final h = HSLColor.fromColor(theme.colorScheme.border).hue;
|
||||
return dark
|
||||
? HSLColor.fromAHSL(1, h, 0.25, 0.25).toColor()
|
||||
: HSLColor.fromAHSL(1, h, 0.30, 0.6).toColor();
|
||||
}
|
||||
|
||||
class UserBubble extends StatefulWidget {
|
||||
const UserBubble({
|
||||
super.key,
|
||||
required this.content,
|
||||
this.attachments,
|
||||
this.timestamp,
|
||||
this.onRetry,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
final String content;
|
||||
final List<MessageAttachment>? attachments;
|
||||
final DateTime? timestamp;
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
@override
|
||||
State<UserBubble> createState() => _UserBubbleState();
|
||||
}
|
||||
|
||||
class _UserBubbleState extends State<UserBubble> {
|
||||
bool _hovered = false;
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final local = dt.toLocal();
|
||||
final now = DateTime.now();
|
||||
final h = local.hour.toString().padLeft(2, "0");
|
||||
final m = local.minute.toString().padLeft(2, "0");
|
||||
final time = "$h:$m";
|
||||
|
||||
final isToday = local.year == now.year && local.month == now.month && local.day == now.day;
|
||||
if (isToday) return "Today at $time";
|
||||
|
||||
// show date for anything older
|
||||
final day = local.day.toString().padLeft(2, "0");
|
||||
final month = local.month.toString().padLeft(2, "0");
|
||||
return "${local.year == now.year ? "" : "${local.year}/"}$month/$day at $time";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final atts = attachments;
|
||||
final atts = widget.attachments;
|
||||
final muted = Theme.of(context).colorScheme.mutedForeground;
|
||||
|
||||
return Align(
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
@@ -49,13 +93,97 @@ class UserBubble extends StatelessWidget {
|
||||
],
|
||||
|
||||
OutlinedContainer(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
backgroundColor: Theme.of(context).colorScheme.border,
|
||||
child: SelectableText(content),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
borderColor: _msgColour(context).scaleAlpha(0.9),
|
||||
backgroundColor: _msgColour(context),
|
||||
boxShadow: [],
|
||||
child: SelectableText(widget.content),
|
||||
),
|
||||
|
||||
const Gap(6),
|
||||
|
||||
AnimatedOpacity(
|
||||
opacity: _hovered ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
if (widget.timestamp != null)
|
||||
Text(
|
||||
_formatTime(widget.timestamp!),
|
||||
style: TextStyle(color: muted),
|
||||
).xSmall,
|
||||
|
||||
const Gap(10),
|
||||
|
||||
_ActionBtn(
|
||||
icon: LucideIcons.refreshCw,
|
||||
tooltip: "Retry",
|
||||
onTap: widget.onRetry,
|
||||
color: muted,
|
||||
),
|
||||
_ActionBtn(
|
||||
icon: LucideIcons.pencil,
|
||||
tooltip: "Edit",
|
||||
onTap: widget.onEdit,
|
||||
color: muted,
|
||||
),
|
||||
_ActionBtn(
|
||||
icon: LucideIcons.copy,
|
||||
tooltip: "Copy",
|
||||
color: muted,
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: widget.content));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _ActionBtn extends StatelessWidget {
|
||||
const _ActionBtn({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.color,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final Color color;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
if (true) {
|
||||
return IconButton.ghost(
|
||||
onPressed: () {
|
||||
if (onTap != null) onTap!();
|
||||
},
|
||||
icon: Icon(
|
||||
icon,
|
||||
).iconSmall.iconMutedForeground,
|
||||
);
|
||||
}
|
||||
|
||||
return Tooltip(
|
||||
tooltip: TooltipContainer(child: Text(tooltip)),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 2),
|
||||
child: Icon(icon, size: 14, color: color),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+204
-122
@@ -1,18 +1,22 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:ui';
|
||||
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/projects_provider.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../../src/project_store.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import 'attachment_preview.dart';
|
||||
import '../common/button.dart';
|
||||
import 'model_picker_dialog.dart';
|
||||
import '../../constants.dart';
|
||||
import 'models_panel.dart';
|
||||
import '../common/pane_dialog.dart';
|
||||
|
||||
class ChatBox extends StatefulWidget {
|
||||
const ChatBox({super.key});
|
||||
@@ -178,6 +182,49 @@ class _ChatBoxState extends State<ChatBox> {
|
||||
}
|
||||
}
|
||||
|
||||
void _openModelDialog(BuildContext context) {
|
||||
final bgColor = Theme.of(context).colorScheme.background;
|
||||
|
||||
showGeneralDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: "models",
|
||||
barrierColor: Colors.transparent,
|
||||
pageBuilder: (ctx, animation, _) {
|
||||
return Stack(
|
||||
children: [
|
||||
// blurred tinted backdrop
|
||||
Positioned.fill(
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
|
||||
child: GestureDetector(
|
||||
onTap: () => Navigator.of(ctx).pop(),
|
||||
child: ColoredBox(
|
||||
color: bgColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 420, maxWidth: 420),
|
||||
child: PaneDialog(
|
||||
title: "Models",
|
||||
onClose: () => Navigator.of(ctx).pop(),
|
||||
child: const ModelsPanel(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _left(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 38,
|
||||
@@ -192,25 +239,6 @@ class _ChatBoxState extends State<ChatBox> {
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -220,15 +248,14 @@ class _ChatBoxState extends State<ChatBox> {
|
||||
height: 38,
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 150,
|
||||
minHeight: double.infinity,
|
||||
),
|
||||
child: AgcGhostButton(
|
||||
borderRadius: BorderRadius.circular(
|
||||
Theme.of(context).radiusLg - 4,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(Theme.of(context).radiusLg - 4),
|
||||
onPressed: () => _openModelDialog(context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
@@ -245,8 +272,8 @@ class _ChatBoxState extends State<ChatBox> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small,
|
||||
),
|
||||
Gap(8),
|
||||
Icon(LucideIcons.chevronsUpDown),
|
||||
const Gap(8),
|
||||
const Icon(LucideIcons.chevronsUpDown),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -335,6 +362,7 @@ class _ChatBoxState extends State<ChatBox> {
|
||||
final contextTokens = chat.contextTokens;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -425,32 +453,37 @@ class _ChatBoxState extends State<ChatBox> {
|
||||
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,
|
||||
Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
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.leading(
|
||||
_buildLeading(context, numberOfLines),
|
||||
),
|
||||
InputFeature.trailing(_buildTrailing(numberOfLines)),
|
||||
|
||||
InputFeature.trailing(_buildTrailing(numberOfLines)),
|
||||
|
||||
InputFeature.below(
|
||||
_buildBottom(context, numberOfLines),
|
||||
InputFeature.below(
|
||||
_buildBottom(context, numberOfLines),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -468,9 +501,24 @@ class _ChatBoxState extends State<ChatBox> {
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
Gap(10),
|
||||
|
||||
Row(
|
||||
children: [
|
||||
|
||||
SizedBox(
|
||||
// width: 130,
|
||||
child: _PermissionModeSelector()
|
||||
),
|
||||
|
||||
Gap(10),
|
||||
|
||||
_ProjectSelector(),
|
||||
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_PermissionModeSelector(),
|
||||
|
||||
],
|
||||
);
|
||||
@@ -490,81 +538,115 @@ class _PermissionModeSelector extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final chat = context.watch<ChatProvider>();
|
||||
final current = chat.threadPermissionMode;
|
||||
final theme = Theme.of(context);
|
||||
final mutedFg = theme.colorScheme.mutedForeground;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
for (int i = 0; i < _modes.length; i++) ...[
|
||||
|
||||
if (i > 0) const SizedBox(width: 4),
|
||||
|
||||
_ModeChip(
|
||||
label: _modes[i].$2,
|
||||
selected: current == _modes[i].$1,
|
||||
mutedFg: mutedFg,
|
||||
onTap: () => chat.setThreadPermissionMode(_modes[i].$1),
|
||||
),
|
||||
|
||||
],
|
||||
|
||||
],
|
||||
final currentEntry = _modes.firstWhere(
|
||||
(m) => m.$1 == current,
|
||||
orElse: () => ("default", "Ask Always"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeChip extends StatefulWidget {
|
||||
const _ModeChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.mutedFg,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final Color mutedFg;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_ModeChip> createState() => _ModeChipState();
|
||||
}
|
||||
|
||||
class _ModeChipState extends State<_ModeChip> {
|
||||
bool _hovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final fg = widget.selected
|
||||
? theme.colorScheme.foreground
|
||||
: widget.mutedFg;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovering = true),
|
||||
onExit: (_) => setState(() => _hovering = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.selected
|
||||
? theme.colorScheme.secondary
|
||||
: _hovering
|
||||
? theme.colorScheme.secondary.withValues(alpha: 0.5)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(theme.radiusSm),
|
||||
),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: TextStyle(fontSize: 11, color: fg, fontWeight: widget.selected ? FontWeight.w600 : FontWeight.normal),
|
||||
),
|
||||
return Select<(String, String)>(
|
||||
itemBuilder: (ctx, item) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_iconFor(item.$1), size: 12),
|
||||
Gap(8),
|
||||
Text(item.$2, style: const TextStyle(fontSize: 11)),
|
||||
],
|
||||
),
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
chat.setThreadPermissionMode(v.$1);
|
||||
},
|
||||
value: currentEntry,
|
||||
popupConstraints: const BoxConstraints(maxWidth: 300, minWidth: 300),
|
||||
popup: SelectPopup(
|
||||
items: SelectItemList(
|
||||
children: [
|
||||
for (final mode in _modes)
|
||||
SelectItemButton(
|
||||
value: mode,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_iconFor(mode.$1), size: 12),
|
||||
Gap(8),
|
||||
Text(mode.$2, style: const TextStyle(fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static IconData _iconFor(String mode) {
|
||||
switch (mode) {
|
||||
case "acceptEdits": return LucideIcons.checkCheck;
|
||||
default: return LucideIcons.shieldQuestion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _ProjectSelector extends StatelessWidget {
|
||||
const _ProjectSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectsProvider = context.watch<ProjectsProvider>();
|
||||
final projects = projectsProvider.projects;
|
||||
final selected = projectsProvider.selectedProject;
|
||||
final coordinator = context.read<HomeCoordinator>();
|
||||
final hasMessages = context.watch<ChatProvider>().messageCount > 0;
|
||||
|
||||
return Select<ProjectRecord>(
|
||||
enabled: !hasMessages,
|
||||
itemBuilder: (context, item) => Row(
|
||||
children: [
|
||||
Icon(LucideIcons.folder).iconSmall,
|
||||
Gap(8),
|
||||
Text(item.name, style: const TextStyle(fontSize: 11)),
|
||||
],
|
||||
),
|
||||
popupConstraints: BoxConstraints(
|
||||
maxWidth: 320,
|
||||
minWidth: 320,
|
||||
maxHeight: 200
|
||||
),
|
||||
popupWidthConstraint: PopoverConstraint.flexible,
|
||||
popup: SelectPopup.builder(
|
||||
searchPlaceholder: const Text("Search projects"),
|
||||
builder: (context, searchQuery) {
|
||||
final filtered = searchQuery == null || searchQuery.isEmpty
|
||||
? projects
|
||||
: projects.where((p) =>
|
||||
p.name.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
||||
p.workingDirectory.toLowerCase().contains(searchQuery.toLowerCase())
|
||||
).toList();
|
||||
|
||||
return SelectItemList(
|
||||
children: [
|
||||
for (final project in filtered)
|
||||
SelectItemButton(
|
||||
value: project,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(project.name).small,
|
||||
Text(project.workingDirectory).muted.xSmall,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
onChanged: (project) {
|
||||
if (project != null) coordinator.selectProject(project);
|
||||
},
|
||||
constraints: const BoxConstraints(minWidth: 180, maxWidth: 240),
|
||||
value: selected,
|
||||
placeholder: const Text("Select project", style: TextStyle(fontSize: 11)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,14 @@ class ChatView extends StatefulWidget {
|
||||
class _ChatViewState extends State<ChatView> {
|
||||
ScrollController get _scrollController => widget.scrollController;
|
||||
bool _autoScrollQueued = false;
|
||||
|
||||
// cached entries — only rebuilt when message list actually changes
|
||||
List<Message> _cachedMessages = const [];
|
||||
List<_ChatEntry> _cachedEntries = const [];
|
||||
|
||||
// track last message identity + count to detect changes cheaply
|
||||
int _lastMessageCount = 0;
|
||||
List<String> _lastMessageSigs = const [];
|
||||
Object? _lastMessageTail; // identity of the last message object
|
||||
|
||||
static const double _bottomThreshold = 56;
|
||||
|
||||
@@ -40,30 +46,33 @@ class _ChatViewState extends State<ChatView> {
|
||||
});
|
||||
}
|
||||
|
||||
List<_ChatEntry> _getEntries(List<Message> messages) {
|
||||
final tail = messages.isEmpty ? null : messages.last;
|
||||
final changed = messages.length != _lastMessageCount || !identical(tail, _lastMessageTail);
|
||||
|
||||
if (changed) {
|
||||
_cachedMessages = messages;
|
||||
_cachedEntries = _buildEntries(messages);
|
||||
_lastMessageCount = messages.length;
|
||||
_lastMessageTail = tail;
|
||||
}
|
||||
return _cachedEntries;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<ChatProvider>(
|
||||
builder: (context, chatProvider, _) {
|
||||
final currentMessages = chatProvider.messages;
|
||||
final currentSigs = currentMessages.map((m) => "${m.role}:${m.content}").toList(growable: false);
|
||||
final messagesChanged = currentMessages.length != _lastMessageCount ||
|
||||
currentSigs.length != _lastMessageSigs.length ||
|
||||
!_listEquals(currentSigs, _lastMessageSigs);
|
||||
|
||||
final prevCount = _lastMessageCount;
|
||||
final entries = _getEntries(currentMessages);
|
||||
final messagesChanged = currentMessages.length != prevCount;
|
||||
|
||||
if (messagesChanged && currentMessages.isNotEmpty) {
|
||||
if (_isAtBottom()) _scheduleAutoScroll();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_lastMessageCount = currentMessages.length;
|
||||
_lastMessageSigs = currentSigs;
|
||||
});
|
||||
} else if (currentMessages.isEmpty) {
|
||||
_lastMessageCount = 0;
|
||||
_lastMessageSigs = const [];
|
||||
}
|
||||
|
||||
final entries = _buildEntries(currentMessages);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ScrollConfiguration(
|
||||
@@ -71,16 +80,23 @@ class _ChatViewState extends State<ChatView> {
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var index = 0; index < entries.length; index++)
|
||||
Padding(
|
||||
key: ValueKey('${entries[index].stableKey}#$index'),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildBubble(context, chatProvider, entries[index], index, entries.length),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Gap(12),
|
||||
|
||||
for (var i = 0; i < entries.length; i++)
|
||||
RepaintBoundary(
|
||||
key: ValueKey('${entries[i].stableKey}#$i'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: _buildBubble(context, chatProvider, entries[i], i, entries.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -97,13 +113,16 @@ class _ChatViewState extends State<ChatView> {
|
||||
|
||||
if (entry is _MessageEntry) {
|
||||
final msg = entry.message;
|
||||
if (msg.role == "user") return UserBubble(content: msg.content, attachments: msg.attachments);
|
||||
if (msg.role == "assistant") return AssistantBubble(content: msg.content);
|
||||
if (msg.role == "user") return UserBubble(content: msg.content, attachments: msg.attachments, timestamp: msg.timestamp);
|
||||
if (msg.role == "assistant") {
|
||||
final streaming = chatProvider.isLoading && index == total - 1;
|
||||
return AssistantBubble(content: msg.content, isStreaming: streaming);
|
||||
}
|
||||
return Text(msg.content);
|
||||
}
|
||||
|
||||
if (entry is _ToolEntry) {
|
||||
return ToolBubble(toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, isPendingPermission: isThisPending);
|
||||
return ToolBubble(toolName: entry.toolName, toolInput: entry.toolInput, result: entry.result, pendingPermission: isThisPending ? pending : null);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
@@ -119,7 +138,14 @@ class _ChatViewState extends State<ChatView> {
|
||||
if (firstLine.endsWith(" call")) {
|
||||
final (toolName, toolInput) = ToolBubble.parseContent(msg.content);
|
||||
String? toolResult;
|
||||
if (i + 1 < messages.length) {
|
||||
|
||||
// check if streamed output was appended directly to this call message
|
||||
// format: "Name call\n{json}\nchunks..."
|
||||
final inlineResult = _extractInlineResult(msg.content);
|
||||
if (inlineResult != null) {
|
||||
toolResult = inlineResult;
|
||||
} else if (i + 1 < messages.length) {
|
||||
// normal non-streaming path: result is in the next message
|
||||
final next = messages[i + 1];
|
||||
final nextFirst = next.content.split("\n").first.trim();
|
||||
if (next.role == "tool" && nextFirst == "$toolName result") {
|
||||
@@ -135,6 +161,9 @@ class _ChatViewState extends State<ChatView> {
|
||||
final (toolName, _) = ToolBubble.parseContent(msg.content);
|
||||
result.add(_ToolEntry(toolName: toolName));
|
||||
i++;
|
||||
} else if (msg.role == "compact_boundary") {
|
||||
// internal marker — not rendered, the assistant note after it says "compacted"
|
||||
i++;
|
||||
} else {
|
||||
result.add(_MessageEntry(msg));
|
||||
i++;
|
||||
@@ -143,14 +172,42 @@ class _ChatViewState extends State<ChatView> {
|
||||
return result;
|
||||
}
|
||||
|
||||
bool _listEquals(List<String> a, List<String> b) {
|
||||
if (identical(a, b)) return true;
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
// if a call message has streamed output appended after its JSON body,
|
||||
// extract it. format is: "Name call\n{json}\noutput..."
|
||||
// returns null if there's no trailing content beyond the json block.
|
||||
String? _extractInlineResult(String content) {
|
||||
final newline = content.indexOf("\n");
|
||||
if (newline == -1) return null;
|
||||
|
||||
final body = content.substring(newline + 1);
|
||||
final jsonEnd = _jsonObjectEnd(body);
|
||||
if (jsonEnd == -1) return null;
|
||||
|
||||
final trailing = body.substring(jsonEnd + 1).trimLeft();
|
||||
return trailing.isEmpty ? null : trailing;
|
||||
}
|
||||
|
||||
// walk the json object to find its true closing brace index
|
||||
int _jsonObjectEnd(String s) {
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
for (int i = 0; i < s.length; i++) {
|
||||
final c = s[i];
|
||||
if (inString) {
|
||||
if (c == "\\") { i++; continue; }
|
||||
if (c == "\"") inString = false;
|
||||
} else {
|
||||
if (c == "\"") inString = true;
|
||||
else if (c == "{") depth++;
|
||||
else if (c == "}") {
|
||||
depth--;
|
||||
if (depth == 0) return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
sealed class _ChatEntry {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// PARITY GAP: legacy model picker kept around while the new models panel takes over.
|
||||
import "package:flutter/material.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// PARITY GAP: legacy dialog replaced by the settings-style models panel.
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
import "../../constants.dart";
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart" as shad;
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
import "../../constants.dart";
|
||||
import "../../providers/session_provider.dart";
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "../common/panel_layout.dart";
|
||||
import "../../../src/local_state.dart";
|
||||
|
||||
|
||||
class ModelsPanel extends shad.StatelessWidget {
|
||||
const ModelsPanel({super.key});
|
||||
|
||||
@override
|
||||
shad.Widget build(shad.BuildContext context) {
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
final currentModel = settings.normalizeModelId(settings.settings.model);
|
||||
final advisorModel = settings.settings.advisorModel;
|
||||
final effortLevel = settings.settings.effortLevel;
|
||||
final advisorEffortLevel = settings.settings.advisorEffortLevel;
|
||||
|
||||
return shad.SizedBox(
|
||||
height: 300,
|
||||
child: shad.SingleChildScrollView(
|
||||
child: PanelList(
|
||||
fields: [
|
||||
PanelField(
|
||||
section: "Conversation",
|
||||
label: const shad.Text("Model"),
|
||||
child: _modelSelect(
|
||||
context: context,
|
||||
value: currentModel,
|
||||
onChanged: (model) async {
|
||||
await context.read<SettingsProvider>().updateModel(model);
|
||||
await context.read<SessionProvider>().updateSessionModel(model);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
PanelField(
|
||||
section: "Conversation",
|
||||
label: const shad.Text("Reasoning"),
|
||||
child: _effortSelect(
|
||||
context: context,
|
||||
value: effortLevel,
|
||||
onChanged: (level) async {
|
||||
await context.read<SettingsProvider>().updateEffortLevel(level ?? "medium");
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
PanelField(
|
||||
section: "Advisor",
|
||||
label: const shad.Text("Model"),
|
||||
child: _modelSelect(
|
||||
context: context,
|
||||
value: advisorModel ?? "",
|
||||
placeholder: "None",
|
||||
onChanged: (model) async {
|
||||
await context.read<SettingsProvider>().updateAdvisorModel(model);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
PanelField(
|
||||
section: "Advisor",
|
||||
label: const shad.Text("Reasoning"),
|
||||
child: _effortSelect(
|
||||
context: context,
|
||||
value: advisorEffortLevel,
|
||||
onChanged: (level) async {
|
||||
await context.read<SettingsProvider>().updateAdvisorEffortLevel(level);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Map<String, List<SelectableAiModel>> _groupedModels() {
|
||||
final map = <String, List<SelectableAiModel>>{};
|
||||
for (final m in selectableAiModels) {
|
||||
map.putIfAbsent(m.group, () => []).add(m);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
Iterable<MapEntry<String, List<SelectableAiModel>>> _filteredGroups(String query) sync* {
|
||||
for (final entry in _groupedModels().entries) {
|
||||
final matched = entry.value
|
||||
.where((m) => m.label.toLowerCase().contains(query) || m.id.toLowerCase().contains(query))
|
||||
.toList();
|
||||
if (matched.isNotEmpty) {
|
||||
yield MapEntry(entry.key, matched);
|
||||
} else if (entry.key.toLowerCase().contains(query)) {
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shad.Widget _modelSelect({
|
||||
required shad.BuildContext context,
|
||||
required String value,
|
||||
String? placeholder,
|
||||
required Future<void> Function(String model) onChanged,
|
||||
}) {
|
||||
final current = value.isEmpty
|
||||
? null
|
||||
: selectableAiModels.firstWhere(
|
||||
(m) => m.id == value,
|
||||
orElse: () => SelectableAiModel(group: "", id: value, label: value),
|
||||
);
|
||||
|
||||
return shad.Select<SelectableAiModel>(
|
||||
itemBuilder: (ctx, item) => _modelOption(item),
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
onChanged(v.id);
|
||||
},
|
||||
value: current,
|
||||
placeholder: current != null
|
||||
? _modelOption(current)
|
||||
: shad.Text(placeholder ?? "Select...").muted,
|
||||
popup: shad.SelectPopup.builder(
|
||||
searchPlaceholder: const shad.Text("Search models"),
|
||||
builder: (context, searchQuery) {
|
||||
final groups = searchQuery == null
|
||||
? _groupedModels().entries
|
||||
: _filteredGroups(searchQuery.toLowerCase());
|
||||
|
||||
return shad.SelectItemList(
|
||||
children: [
|
||||
for (final entry in groups)
|
||||
shad.SelectGroup(
|
||||
headers: [shad.SelectLabel(child: shad.Text(entry.key).xSmall.muted)],
|
||||
children: [
|
||||
for (final m in entry.value)
|
||||
shad.SelectItemButton(value: m, child: _modelOption(m)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
shad.Widget _modelOption(SelectableAiModel m) => shad.Text(m.label).xSmall;
|
||||
|
||||
|
||||
shad.Widget _effortSelect({
|
||||
required shad.BuildContext context,
|
||||
required String? value,
|
||||
required Future<void> Function(String? level) onChanged,
|
||||
}) {
|
||||
return shad.Select<String>(
|
||||
itemBuilder: (ctx, item) => shad.Text(item).xSmall,
|
||||
onChanged: (v) => onChanged(v),
|
||||
value: value,
|
||||
placeholder: shad.Text(value ?? "none").xSmall.muted,
|
||||
popup: shad.SelectPopup(
|
||||
items: shad.SelectItemList(
|
||||
children: [
|
||||
shad.SelectItemButton(value: null, child: shad.Text("none").xSmall),
|
||||
for (final level in supportedEffortLevels)
|
||||
shad.SelectItemButton(value: level, child: shad.Text(level).xSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart" as shad;
|
||||
|
||||
|
||||
// floating pane visual — use inside showDialog, backdrop handled by caller
|
||||
class PaneDialog extends StatelessWidget {
|
||||
const PaneDialog({
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.onClose,
|
||||
this.fillHeight = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final VoidCallback? onClose;
|
||||
final bool fillHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = shad.Theme.of(context).colorScheme;
|
||||
final borderColor = Color.lerp(scheme.border, scheme.foreground, 0.1)!;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.background,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0x26888888), blurRadius: 4, spreadRadius: 2),
|
||||
],
|
||||
),
|
||||
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_TitleBar(title: title, borderColor: borderColor, onClose: onClose),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _TitleBar extends StatelessWidget {
|
||||
const _TitleBar({
|
||||
required this.title,
|
||||
required this.borderColor,
|
||||
this.onClose,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Color borderColor;
|
||||
final VoidCallback? onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = shad.Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
height: 34,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.secondary,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
topRight: Radius.circular(8),
|
||||
),
|
||||
border: Border(bottom: BorderSide(color: borderColor, width: 1)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: shad.TextStyle(
|
||||
color: scheme.secondaryForeground,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (onClose != null) _CloseBtn(onTap: onClose!),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _CloseBtn extends StatefulWidget {
|
||||
const _CloseBtn({required this.onTap});
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
State<_CloseBtn> createState() => _CloseBtnState();
|
||||
}
|
||||
|
||||
class _CloseBtnState extends State<_CloseBtn> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = shad.Theme.of(context).colorScheme;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: _hovered ? 1.0 : 0.0),
|
||||
duration: const Duration(milliseconds: 20),
|
||||
builder: (context, t, _) {
|
||||
return Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(
|
||||
const Color(0x00000000),
|
||||
scheme.destructive.withValues(alpha: 0.12),
|
||||
t,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: shad.Icon(
|
||||
shad.LucideIcons.x,
|
||||
size: 13,
|
||||
color: Color.lerp(scheme.mutedForeground, scheme.destructive, t),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import "dart:math" as math;
|
||||
|
||||
import "package:flutter/rendering.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart" as shad;
|
||||
|
||||
|
||||
bool _isCompactTouch(shad.BuildContext context) {
|
||||
return shad.MediaQuery.sizeOf(context).width < 900;
|
||||
}
|
||||
|
||||
class PanelField {
|
||||
const PanelField({
|
||||
required this.section,
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String section;
|
||||
final shad.Widget label;
|
||||
final shad.Widget child;
|
||||
final bool enabled;
|
||||
}
|
||||
|
||||
// auto-groups fields by section and renders them as PanelSection > PanelRow
|
||||
class PanelList extends shad.StatelessWidget {
|
||||
const PanelList({super.key, required this.fields});
|
||||
|
||||
final List<PanelField> fields;
|
||||
|
||||
@override
|
||||
shad.Widget build(shad.BuildContext context) {
|
||||
final theme = shad.Theme.of(context);
|
||||
|
||||
final Map<String, List<PanelField>> grouped = {};
|
||||
for (final f in fields) {
|
||||
grouped.putIfAbsent(f.section, () => []).add(f);
|
||||
}
|
||||
|
||||
final entries = grouped.entries.toList();
|
||||
|
||||
return shad.Column(
|
||||
crossAxisAlignment: shad.CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < entries.length; i++) ...[
|
||||
PanelSection(
|
||||
title: entries[i].key,
|
||||
children: entries[i]
|
||||
.value
|
||||
.map((f) => PanelRow(label: f.label, child: f.child, enabled: f.enabled))
|
||||
.toList(),
|
||||
),
|
||||
|
||||
if (i < entries.length - 1)
|
||||
shad.Divider(color: theme.colorScheme.border, height: 1),
|
||||
],
|
||||
shad.Divider(color: theme.colorScheme.border, height: 1),
|
||||
const shad.SizedBox(height: 10),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PanelSection extends shad.StatefulWidget {
|
||||
const PanelSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<shad.Widget> children;
|
||||
|
||||
@override
|
||||
shad.State<PanelSection> createState() => _PanelSectionState();
|
||||
}
|
||||
|
||||
class _PanelSectionState extends shad.State<PanelSection> {
|
||||
bool _expanded = true;
|
||||
|
||||
@override
|
||||
shad.Widget build(shad.BuildContext context) {
|
||||
final theme = shad.Theme.of(context);
|
||||
|
||||
return shad.Column(
|
||||
crossAxisAlignment: shad.CrossAxisAlignment.start,
|
||||
children: [
|
||||
shad.SizedBox(
|
||||
child: shad.GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
behavior: shad.HitTestBehavior.opaque,
|
||||
child: shad.ColoredBox(
|
||||
color: theme.colorScheme.secondary,
|
||||
child: shad.Padding(
|
||||
padding: const shad.EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: shad.Row(
|
||||
children: [
|
||||
shad.AnimatedRotation(
|
||||
turns: _expanded ? 0.0 : -0.25,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
child: shad.Icon(
|
||||
shad.LucideIcons.chevronDown,
|
||||
size: 12,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
const shad.SizedBox(width: 4),
|
||||
shad.Text(
|
||||
widget.title,
|
||||
style: shad.TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: shad.FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
shad.Divider(color: theme.colorScheme.border, height: 1),
|
||||
if (_expanded)
|
||||
shad.Column(
|
||||
crossAxisAlignment: shad.CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < widget.children.length; i++) ...[
|
||||
widget.children[i],
|
||||
if (i < widget.children.length - 1)
|
||||
shad.Divider(color: theme.colorScheme.border, height: 1),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PanelRow extends shad.StatelessWidget {
|
||||
const PanelRow({super.key, required this.label, required this.child, this.enabled = true});
|
||||
|
||||
final shad.Widget label;
|
||||
final shad.Widget child;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
shad.Widget build(shad.BuildContext context) {
|
||||
final isCompactTouch = _isCompactTouch(context);
|
||||
final theme = shad.Theme.of(context);
|
||||
|
||||
final row = shad.ConstrainedBox(
|
||||
constraints: shad.BoxConstraints(minHeight: isCompactTouch ? 52 : 38),
|
||||
child: shad.IntrinsicHeight(
|
||||
child: shad.Padding(
|
||||
padding: const shad.EdgeInsets.only(left: 12),
|
||||
child: shad.Row(
|
||||
crossAxisAlignment: shad.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
shad.SizedBox(
|
||||
width: 100,
|
||||
child: shad.Align(
|
||||
alignment: shad.Alignment.centerLeft,
|
||||
child: shad.Padding(
|
||||
padding: shad.EdgeInsets.symmetric(vertical: isCompactTouch ? 6 : 4),
|
||||
child: shad.DefaultTextStyle.merge(
|
||||
style: shad.TextStyle(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
decoration: !enabled ? shad.TextDecoration.lineThrough : null,
|
||||
decorationThickness: !enabled ? 3 : null,
|
||||
),
|
||||
child: label,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
shad.VerticalDivider(color: theme.colorScheme.border, width: 1),
|
||||
shad.Expanded(
|
||||
child: shad.Padding(
|
||||
padding: shad.EdgeInsets.zero,
|
||||
child: shad.Stack(
|
||||
children: [
|
||||
shad.Positioned.fill(
|
||||
child: shad.Padding(
|
||||
padding: shad.EdgeInsets.symmetric(vertical: isCompactTouch ? 6 : 4, horizontal: 8),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (!enabled)
|
||||
shad.Positioned.fill(
|
||||
child: shad.GestureDetector(
|
||||
onTap: () {},
|
||||
behavior: shad.HitTestBehavior.opaque,
|
||||
child: shad.CustomPaint(
|
||||
painter: _DisabledStripePainter(theme.colorScheme.border),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
class _DisabledStripePainter extends CustomPainter {
|
||||
const _DisabledStripePainter(this.color);
|
||||
|
||||
final shad.Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color.withValues(alpha: 1)
|
||||
..strokeWidth = 1.0
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
const spacing = 4.0;
|
||||
final diag = math.sqrt(size.width * size.width + size.height * size.height);
|
||||
final count = (diag / spacing).ceil() + 2;
|
||||
|
||||
canvas.save();
|
||||
canvas.clipRect(Offset.zero & size);
|
||||
|
||||
canvas.drawRect(Rect.fromLTWH(1, 1, size.width - 3, size.height - 2), paint);
|
||||
|
||||
canvas.translate(0, size.height);
|
||||
canvas.rotate(-math.pi / 4);
|
||||
|
||||
for (int i = -count; i <= count; i++) {
|
||||
final x = i * spacing;
|
||||
canvas.drawLine(Offset(x, -diag), Offset(x, diag), paint);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DisabledStripePainter old) => old.color != color;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
import "../../providers/settings_provider.dart";
|
||||
import "../chat/model_picker.dart";
|
||||
import "../chat/models_panel.dart";
|
||||
|
||||
class SettingsSheet extends StatelessWidget {
|
||||
const SettingsSheet();
|
||||
@@ -23,13 +23,7 @@ class SettingsSheet extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// model picker
|
||||
const Text(
|
||||
"Model",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const ModelPicker(),
|
||||
const ModelsPanel(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// API key setting
|
||||
@@ -64,7 +58,7 @@ class SettingsSheet extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SimpleDropdown<String>(
|
||||
value: settingsProvider.settings.effortLevel ?? "medium",
|
||||
value: settingsProvider.settings.effortLevel,
|
||||
items: const ["low", "medium", "high", "max"],
|
||||
onChanged: (newLevel) {
|
||||
settingsProvider.updateEffortLevel(newLevel);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
import 'package:clawd_code/ui/widgets/common/button.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
class AccountButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO: implement build
|
||||
return AgcGhostButton(
|
||||
onPressed: () {
|
||||
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Avatar(
|
||||
initials: "BW",
|
||||
),
|
||||
|
||||
Gap(12),
|
||||
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Benjamin Watt"
|
||||
).small,
|
||||
|
||||
Text(
|
||||
"Pro plan"
|
||||
).xSmall.muted
|
||||
|
||||
],
|
||||
),
|
||||
|
||||
Spacer(),
|
||||
|
||||
Icon(
|
||||
LucideIcons.ellipsisVertical
|
||||
),
|
||||
|
||||
Gap(10),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import "package:clawd_code/ui/widgets/sidebar/account_button.dart";
|
||||
import "package:flutter/services.dart" show Clipboard, ClipboardData;
|
||||
import "package:provider/provider.dart";
|
||||
import "package:shadcn_flutter/shadcn_flutter.dart";
|
||||
|
||||
@@ -11,29 +13,69 @@ import "app_logo.dart";
|
||||
import "../common/button.dart";
|
||||
|
||||
class SidebarV2 extends StatelessWidget {
|
||||
const SidebarV2({super.key});
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const SidebarV2({super.key, this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
width: 300,
|
||||
width: 320,
|
||||
color: theme.colorScheme.input.scaleAlpha(0.3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
||||
if (onClose != null) ...[
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
padding: EdgeInsets.all(8),
|
||||
child: IconButton.ghost(
|
||||
onPressed: onClose,
|
||||
icon: Icon(
|
||||
LucideIcons.panelLeftClose,
|
||||
size: 16,
|
||||
color: theme.colorScheme.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28),
|
||||
child: AppLogo(),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16, bottom: 22
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
Expanded(child: AppLogo()),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Divider(color: theme.colorScheme.border, height: 1),
|
||||
_ActionsSection(),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
// Divider(color: theme.colorScheme.border, height: 1),
|
||||
|
||||
Expanded(child: _ProjectsSection()),
|
||||
Divider(),
|
||||
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: OutlinedContainer(
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: _ProjectsSection()
|
||||
),
|
||||
)
|
||||
),
|
||||
|
||||
Divider(),
|
||||
|
||||
AccountButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -68,7 +110,7 @@ class _ActionsSection extends StatelessWidget {
|
||||
onTap: coordinator.pickProjectDirectory,
|
||||
),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
// Divider(color: theme.colorScheme.border, height: 1),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -132,6 +174,11 @@ class _ProjectsSection extends StatelessWidget {
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
|
||||
// _SectionHeader(title: "PROJECTS", large: true),
|
||||
//
|
||||
// Divider(color: theme.colorScheme.background, height: 1),
|
||||
|
||||
for (final project in projects) ...[
|
||||
_CollapsibleProjectSection(
|
||||
projectName: project.name,
|
||||
@@ -224,7 +271,9 @@ class _CollapsibleProjectSectionState
|
||||
),
|
||||
),
|
||||
|
||||
Divider(color: theme.colorScheme.border, height: 1),
|
||||
if (_expanded) ...[
|
||||
Divider(color: theme.colorScheme.background, height: 1),
|
||||
],
|
||||
|
||||
if (_expanded) ...[
|
||||
if (widget.sessions.isEmpty)
|
||||
@@ -258,25 +307,38 @@ class _CollapsibleProjectSectionState
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final bool large;
|
||||
|
||||
const _SectionHeader({required this.title});
|
||||
const _SectionHeader({required this.title, this.large = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
TextStyle style;
|
||||
if (large) {
|
||||
style = TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
letterSpacing: 0.4,
|
||||
);
|
||||
} else {
|
||||
style = TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
letterSpacing: 0.4,
|
||||
);
|
||||
}
|
||||
|
||||
return ColoredBox(
|
||||
color: theme.colorScheme.secondary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.foreground,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
style: style
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -296,12 +358,12 @@ class _PanelItem extends StatelessWidget {
|
||||
final muted = onTap == null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(1),
|
||||
padding: const EdgeInsets.all(0),
|
||||
child: AgcGhostButton(
|
||||
onPressed: onTap,
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -384,6 +446,16 @@ class _ThreadItem extends StatelessWidget {
|
||||
|
||||
return ContextMenu(
|
||||
items: [
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.copy).iconSmall,
|
||||
onPressed: (_) {
|
||||
final dir = session.workingDirectory ?? "";
|
||||
final path = "$dir/.the_agency/sessions/${session.id}.json";
|
||||
Clipboard.setData(ClipboardData(text: path));
|
||||
},
|
||||
child: const Text("Copy session path"),
|
||||
),
|
||||
|
||||
MenuButton(
|
||||
leading: const Icon(LucideIcons.trash2).iconSmall,
|
||||
onPressed: (_) => onDelete(),
|
||||
@@ -391,7 +463,7 @@ class _ThreadItem extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
margin: EdgeInsets.all(1),
|
||||
// margin: EdgeInsets.all(1),
|
||||
decoration: BoxDecoration(
|
||||
color: glowColor != null
|
||||
? glowColor.withAlpha(selected ? 55 : 30)
|
||||
|
||||
Reference in New Issue
Block a user