Add command files and enhance session management features

This commit is contained in:
ImBenji
2026-04-28 19:00:27 +01:00
parent 3588783001
commit 728c0ffe81
146 changed files with 6854 additions and 7783 deletions
@@ -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;
+39 -13
View File
@@ -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: "",
+134 -6
View File
@@ -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
View File
@@ -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)),
);
}
}
+93 -36
View File
@@ -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
View File
@@ -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";
+175
View File
@@ -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),
],
),
),
);
}