Scaffold agent shell package
This commit is contained in:
commit
09080ca7f6
18 changed files with 738 additions and 0 deletions
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
pubspec.lock
|
||||||
|
|
||||||
|
# IDE/local
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Generated by local experiments
|
||||||
|
.tmp/
|
||||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# agent-shell 프로젝트 규칙
|
||||||
|
|
||||||
|
## 응답 언어
|
||||||
|
|
||||||
|
- 기본 응답은 한국어로 한다.
|
||||||
|
- 코드, 식별자, 명령어, 에러 메시지는 원문 표기를 유지한다.
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
`agent-shell`은 제품 앱이 아니라 Flutter 앱들이 공통으로 가져다 쓰는 agent interaction package다.
|
||||||
|
채팅 메시지, tool call 표시, 승인 UI, capability registry, host integration boundary를 제공하되 IOP, NomadCode, OTO 같은 제품 도메인을 직접 알지 않는다.
|
||||||
|
|
||||||
|
## 작업 규칙
|
||||||
|
|
||||||
|
- `lib/` 안에는 `MaterialApp`, `runApp`, Firebase/SystemChrome 같은 application bootstrap을 넣지 않는다.
|
||||||
|
- 제품별 의존성(`iop_*`, `nomadcode_*`)은 이 패키지에 import하지 않는다.
|
||||||
|
- 백엔드 transport, 인증, endpoint, theme, navigation은 host 앱이 주입한다.
|
||||||
|
- Agent capability는 interface와 event/command model만 제공하고, 제품별 capability pack은 각 제품 repo나 optional composition package가 소유한다.
|
||||||
|
- 새 public API를 추가하면 `lib/agent_shell.dart` export와 README 예시를 함께 확인한다.
|
||||||
|
- UI 위젯은 host layout에 embedded될 수 있게 fixed app chrome을 전제하지 않는다.
|
||||||
|
- 테스트는 변경 범위에 맞춰 `flutter test`를 우선 실행한다.
|
||||||
|
|
||||||
|
## 진입 파일
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `pubspec.yaml`
|
||||||
|
- `lib/agent_shell.dart`
|
||||||
|
- `agent-ops/rules/project/rules.md`
|
||||||
70
README.md
Normal file
70
README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# agent-shell
|
||||||
|
|
||||||
|
`agent-shell` is a Flutter package for embeddable agent interaction surfaces.
|
||||||
|
It is not a product app. It provides the shared shell that IOP, NomadCode, and future host apps can compose with their own capability packs.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
- Render agent messages, tool calls, and approval prompts.
|
||||||
|
- Provide capability and event interfaces that host apps can wire to their own backends.
|
||||||
|
- Stay product-agnostic: no IOP, NomadCode, WebView, workflow, or Control Plane dependency lives here.
|
||||||
|
- Fit into different workbench layouts, including NomadCode's right rail and IOP's left rail.
|
||||||
|
|
||||||
|
## Package Boundary
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_shell
|
||||||
|
AgentShell widget
|
||||||
|
AgentMessage / AgentToolCall models
|
||||||
|
AgentCapability / AgentCommand / AgentEvent interfaces
|
||||||
|
Tool call and approval UI primitives
|
||||||
|
|
||||||
|
iop_console_flutter
|
||||||
|
IOP-specific capability pack
|
||||||
|
IOP Control Plane / Edge / Node integration
|
||||||
|
|
||||||
|
nomadcode_app
|
||||||
|
NomadCode workbench shell
|
||||||
|
NomadCode WebView/workflow capability pack
|
||||||
|
optional IOP composition package
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```dart
|
||||||
|
AgentShell(
|
||||||
|
messages: messages,
|
||||||
|
busy: isRunning,
|
||||||
|
onSubmit: (text) => agentRuntime.send(text),
|
||||||
|
onToolAction: (action) => agentRuntime.handleToolAction(action),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intended Composition
|
||||||
|
|
||||||
|
IOP standalone:
|
||||||
|
|
||||||
|
```text
|
||||||
|
iop/apps/client
|
||||||
|
-> iop_console_flutter
|
||||||
|
-> agent_shell
|
||||||
|
```
|
||||||
|
|
||||||
|
NomadCode without IOP:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nomadcode/apps/client
|
||||||
|
-> nomadcode workbench shell
|
||||||
|
-> agent_shell
|
||||||
|
```
|
||||||
|
|
||||||
|
NomadCode with IOP:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nomadcode optional composition layer
|
||||||
|
-> nomadcode workbench shell
|
||||||
|
-> iop_console_flutter
|
||||||
|
-> agent_shell
|
||||||
|
```
|
||||||
|
|
||||||
|
The IOP import must stay in the optional composition layer so the default NomadCode build can succeed without IOP dependencies.
|
||||||
32
agent-ops/rules/project/rules.md
Normal file
32
agent-ops/rules/project/rules.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# agent-shell project rules
|
||||||
|
|
||||||
|
## 응답 언어
|
||||||
|
|
||||||
|
- 기본 응답은 한국어로 한다.
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
`agent-shell`은 Flutter 기반 agent interaction package다.
|
||||||
|
제품 앱, backend, runtime, WebView 조작, IOP Control Plane, NomadCode workflow를 직접 소유하지 않고, host 앱이 주입하는 메시지와 capability를 표시하고 상호작용하는 공통 UI/인터페이스만 제공한다.
|
||||||
|
|
||||||
|
## 주요 구조
|
||||||
|
|
||||||
|
- `lib/agent_shell.dart` - public export entrypoint.
|
||||||
|
- `lib/src/domain/` - message, tool call, command, event, capability model.
|
||||||
|
- `lib/src/widgets/` - embeddable chat surface와 UI primitives.
|
||||||
|
- `test/` - widget/model tests.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- Flutter package
|
||||||
|
- Dart SDK `^3.11.0`
|
||||||
|
- `flutter_test`
|
||||||
|
|
||||||
|
## 프로젝트 특화 컨벤션
|
||||||
|
|
||||||
|
- `lib/`는 package 코드만 둔다. `runApp`, `MaterialApp`, Firebase/SystemChrome bootstrap은 host 앱 책임이다.
|
||||||
|
- 제품별 package를 import하지 않는다. IOP/NomadCode 연결은 각 제품의 capability pack 또는 optional composition layer에서 구현한다.
|
||||||
|
- Public API는 `lib/agent_shell.dart`에서 export한다.
|
||||||
|
- Host가 theme, navigation, transport, auth, endpoint, persistence를 주입한다.
|
||||||
|
- UI는 좌측 rail, 우측 rail, center content, modal 등 다양한 host layout에 들어갈 수 있어야 한다.
|
||||||
|
- 변경 후 가능한 경우 `flutter test`를 실행한다.
|
||||||
6
analysis_options.yaml
Normal file
6
analysis_options.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_const_constructors: true
|
||||||
|
prefer_final_fields: true
|
||||||
10
lib/agent_shell.dart
Normal file
10
lib/agent_shell.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export 'src/domain/agent_capability.dart';
|
||||||
|
export 'src/domain/agent_command.dart';
|
||||||
|
export 'src/domain/agent_event.dart';
|
||||||
|
export 'src/domain/agent_message.dart';
|
||||||
|
export 'src/domain/agent_tool_call.dart';
|
||||||
|
export 'src/widgets/agent_composer.dart';
|
||||||
|
export 'src/widgets/agent_message_list.dart';
|
||||||
|
export 'src/widgets/agent_shell.dart';
|
||||||
|
export 'src/widgets/approval_prompt.dart';
|
||||||
|
export 'src/widgets/tool_call_card.dart';
|
||||||
35
lib/src/domain/agent_capability.dart
Normal file
35
lib/src/domain/agent_capability.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'agent_command.dart';
|
||||||
|
import 'agent_event.dart';
|
||||||
|
|
||||||
|
abstract interface class AgentCapability {
|
||||||
|
String get id;
|
||||||
|
String get label;
|
||||||
|
String get category;
|
||||||
|
|
||||||
|
Future<AgentCapabilityDescriptor> describe();
|
||||||
|
|
||||||
|
Stream<AgentEvent> invoke(AgentCommand command, AgentContext context);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentCapabilityDescriptor {
|
||||||
|
final String id;
|
||||||
|
final String label;
|
||||||
|
final String category;
|
||||||
|
final String description;
|
||||||
|
final bool requiresApproval;
|
||||||
|
|
||||||
|
const AgentCapabilityDescriptor({
|
||||||
|
required this.id,
|
||||||
|
required this.label,
|
||||||
|
required this.category,
|
||||||
|
this.description = '',
|
||||||
|
this.requiresApproval = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentContext {
|
||||||
|
final String? conversationId;
|
||||||
|
final Map<String, Object?> values;
|
||||||
|
|
||||||
|
const AgentContext({this.conversationId, this.values = const {}});
|
||||||
|
}
|
||||||
22
lib/src/domain/agent_command.dart
Normal file
22
lib/src/domain/agent_command.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
class AgentCommand {
|
||||||
|
final String id;
|
||||||
|
final String text;
|
||||||
|
final String? capabilityId;
|
||||||
|
final Map<String, Object?> payload;
|
||||||
|
|
||||||
|
const AgentCommand({
|
||||||
|
required this.id,
|
||||||
|
required this.text,
|
||||||
|
this.capabilityId,
|
||||||
|
this.payload = const {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentToolAction {
|
||||||
|
final String toolCallId;
|
||||||
|
final AgentToolActionType type;
|
||||||
|
|
||||||
|
const AgentToolAction({required this.toolCallId, required this.type});
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AgentToolActionType { approve, reject, cancel, retry }
|
||||||
32
lib/src/domain/agent_event.dart
Normal file
32
lib/src/domain/agent_event.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import 'agent_message.dart';
|
||||||
|
import 'agent_tool_call.dart';
|
||||||
|
|
||||||
|
sealed class AgentEvent {
|
||||||
|
const AgentEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentMessageEvent extends AgentEvent {
|
||||||
|
final AgentMessage message;
|
||||||
|
|
||||||
|
const AgentMessageEvent(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentToolCallEvent extends AgentEvent {
|
||||||
|
final AgentToolCall toolCall;
|
||||||
|
|
||||||
|
const AgentToolCallEvent(this.toolCall);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentStatusEvent extends AgentEvent {
|
||||||
|
final String label;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
const AgentStatusEvent({required this.label, this.busy = false});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentErrorEvent extends AgentEvent {
|
||||||
|
final String message;
|
||||||
|
final Object? details;
|
||||||
|
|
||||||
|
const AgentErrorEvent(this.message, {this.details});
|
||||||
|
}
|
||||||
19
lib/src/domain/agent_message.dart
Normal file
19
lib/src/domain/agent_message.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import 'agent_tool_call.dart';
|
||||||
|
|
||||||
|
enum AgentMessageRole { user, assistant, system, tool }
|
||||||
|
|
||||||
|
class AgentMessage {
|
||||||
|
final String id;
|
||||||
|
final AgentMessageRole role;
|
||||||
|
final String text;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final List<AgentToolCall> toolCalls;
|
||||||
|
|
||||||
|
const AgentMessage({
|
||||||
|
required this.id,
|
||||||
|
required this.role,
|
||||||
|
required this.text,
|
||||||
|
this.createdAt,
|
||||||
|
this.toolCalls = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
30
lib/src/domain/agent_tool_call.dart
Normal file
30
lib/src/domain/agent_tool_call.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
enum AgentToolCallStatus {
|
||||||
|
pending,
|
||||||
|
awaitingApproval,
|
||||||
|
running,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
canceled,
|
||||||
|
}
|
||||||
|
|
||||||
|
class AgentToolCall {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final AgentToolCallStatus status;
|
||||||
|
final String summary;
|
||||||
|
final Map<String, Object?> arguments;
|
||||||
|
final String? resultText;
|
||||||
|
final String? errorText;
|
||||||
|
|
||||||
|
const AgentToolCall({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.status,
|
||||||
|
this.summary = '',
|
||||||
|
this.arguments = const {},
|
||||||
|
this.resultText,
|
||||||
|
this.errorText,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get needsApproval => status == AgentToolCallStatus.awaitingApproval;
|
||||||
|
}
|
||||||
76
lib/src/widgets/agent_composer.dart
Normal file
76
lib/src/widgets/agent_composer.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AgentComposer extends StatefulWidget {
|
||||||
|
final ValueChanged<String>? onSubmit;
|
||||||
|
final bool enabled;
|
||||||
|
final bool busy;
|
||||||
|
final String placeholder;
|
||||||
|
|
||||||
|
const AgentComposer({
|
||||||
|
super.key,
|
||||||
|
this.onSubmit,
|
||||||
|
this.enabled = true,
|
||||||
|
this.busy = false,
|
||||||
|
this.placeholder = 'Message the agent',
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AgentComposer> createState() => _AgentComposerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AgentComposerState extends State<AgentComposer> {
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _submit() {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty || !widget.enabled || widget.onSubmit == null) return;
|
||||||
|
widget.onSubmit!(text);
|
||||||
|
_controller.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
enabled: widget.enabled,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 5,
|
||||||
|
textInputAction: TextInputAction.newline,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: widget.placeholder,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _submit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton.filled(
|
||||||
|
tooltip: widget.busy ? 'Agent is running' : 'Send',
|
||||||
|
onPressed: widget.enabled ? _submit : null,
|
||||||
|
icon: widget.busy
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.send),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
lib/src/widgets/agent_message_list.dart
Normal file
94
lib/src/widgets/agent_message_list.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../domain/agent_command.dart';
|
||||||
|
import '../domain/agent_message.dart';
|
||||||
|
import 'tool_call_card.dart';
|
||||||
|
|
||||||
|
class AgentMessageList extends StatelessWidget {
|
||||||
|
final List<AgentMessage> messages;
|
||||||
|
final ValueChanged<AgentToolAction>? onToolAction;
|
||||||
|
final Widget? emptyState;
|
||||||
|
|
||||||
|
const AgentMessageList({
|
||||||
|
super.key,
|
||||||
|
required this.messages,
|
||||||
|
this.onToolAction,
|
||||||
|
this.emptyState,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (messages.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child:
|
||||||
|
emptyState ??
|
||||||
|
Text(
|
||||||
|
'No messages yet',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: messages.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = messages[index];
|
||||||
|
return _AgentMessageBubble(
|
||||||
|
message: message,
|
||||||
|
onToolAction: onToolAction,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AgentMessageBubble extends StatelessWidget {
|
||||||
|
final AgentMessage message;
|
||||||
|
final ValueChanged<AgentToolAction>? onToolAction;
|
||||||
|
|
||||||
|
const _AgentMessageBubble({required this.message, this.onToolAction});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isUser = message.role == AgentMessageRole.user;
|
||||||
|
final alignment = isUser ? Alignment.centerRight : Alignment.centerLeft;
|
||||||
|
final color = isUser
|
||||||
|
? theme.colorScheme.primaryContainer
|
||||||
|
: theme.colorScheme.surfaceContainerHighest;
|
||||||
|
final textColor = isUser
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: alignment,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
message.text,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: textColor),
|
||||||
|
),
|
||||||
|
for (final toolCall in message.toolCalls) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
ToolCallCard(toolCall: toolCall, onAction: onToolAction),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/src/widgets/agent_shell.dart
Normal file
47
lib/src/widgets/agent_shell.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../domain/agent_command.dart';
|
||||||
|
import '../domain/agent_message.dart';
|
||||||
|
import 'agent_composer.dart';
|
||||||
|
import 'agent_message_list.dart';
|
||||||
|
|
||||||
|
class AgentShell extends StatelessWidget {
|
||||||
|
final List<AgentMessage> messages;
|
||||||
|
final ValueChanged<String>? onSubmit;
|
||||||
|
final ValueChanged<AgentToolAction>? onToolAction;
|
||||||
|
final bool busy;
|
||||||
|
final String placeholder;
|
||||||
|
final Widget? emptyState;
|
||||||
|
|
||||||
|
const AgentShell({
|
||||||
|
super.key,
|
||||||
|
required this.messages,
|
||||||
|
this.onSubmit,
|
||||||
|
this.onToolAction,
|
||||||
|
this.busy = false,
|
||||||
|
this.placeholder = 'Message the agent',
|
||||||
|
this.emptyState,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: AgentMessageList(
|
||||||
|
messages: messages,
|
||||||
|
onToolAction: onToolAction,
|
||||||
|
emptyState: emptyState,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
AgentComposer(
|
||||||
|
enabled: !busy && onSubmit != null,
|
||||||
|
busy: busy,
|
||||||
|
placeholder: placeholder,
|
||||||
|
onSubmit: onSubmit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
lib/src/widgets/approval_prompt.dart
Normal file
45
lib/src/widgets/approval_prompt.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../domain/agent_command.dart';
|
||||||
|
import '../domain/agent_tool_call.dart';
|
||||||
|
|
||||||
|
class ApprovalPrompt extends StatelessWidget {
|
||||||
|
final AgentToolCall toolCall;
|
||||||
|
final ValueChanged<AgentToolAction>? onAction;
|
||||||
|
|
||||||
|
const ApprovalPrompt({super.key, required this.toolCall, this.onAction});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onAction == null
|
||||||
|
? null
|
||||||
|
: () => onAction!(
|
||||||
|
AgentToolAction(
|
||||||
|
toolCallId: toolCall.id,
|
||||||
|
type: AgentToolActionType.reject,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
label: const Text('Reject'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onAction == null
|
||||||
|
? null
|
||||||
|
: () => onAction!(
|
||||||
|
AgentToolAction(
|
||||||
|
toolCallId: toolCall.id,
|
||||||
|
type: AgentToolActionType.approve,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('Approve'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/src/widgets/tool_call_card.dart
Normal file
99
lib/src/widgets/tool_call_card.dart
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../domain/agent_command.dart';
|
||||||
|
import '../domain/agent_tool_call.dart';
|
||||||
|
import 'approval_prompt.dart';
|
||||||
|
|
||||||
|
class ToolCallCard extends StatelessWidget {
|
||||||
|
final AgentToolCall toolCall;
|
||||||
|
final ValueChanged<AgentToolAction>? onAction;
|
||||||
|
|
||||||
|
const ToolCallCard({super.key, required this.toolCall, this.onAction});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(_statusIcon(toolCall.status), size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
toolCall.name,
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_StatusLabel(status: toolCall.status),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (toolCall.summary.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(toolCall.summary),
|
||||||
|
],
|
||||||
|
if (toolCall.arguments.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
toolCall.arguments.toString(),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (toolCall.resultText case final result?) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(result, style: theme.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
if (toolCall.errorText case final error?) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
error,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (toolCall.needsApproval) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ApprovalPrompt(toolCall: toolCall, onAction: onAction),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusLabel extends StatelessWidget {
|
||||||
|
final AgentToolCallStatus status;
|
||||||
|
|
||||||
|
const _StatusLabel({required this.status});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(switch (status) {
|
||||||
|
AgentToolCallStatus.pending => 'Pending',
|
||||||
|
AgentToolCallStatus.awaitingApproval => 'Approval',
|
||||||
|
AgentToolCallStatus.running => 'Running',
|
||||||
|
AgentToolCallStatus.succeeded => 'Done',
|
||||||
|
AgentToolCallStatus.failed => 'Failed',
|
||||||
|
AgentToolCallStatus.canceled => 'Canceled',
|
||||||
|
}, style: Theme.of(context).textTheme.labelSmall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _statusIcon(AgentToolCallStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
AgentToolCallStatus.pending => Icons.schedule,
|
||||||
|
AgentToolCallStatus.awaitingApproval => Icons.verified_user,
|
||||||
|
AgentToolCallStatus.running => Icons.sync,
|
||||||
|
AgentToolCallStatus.succeeded => Icons.check_circle,
|
||||||
|
AgentToolCallStatus.failed => Icons.error,
|
||||||
|
AgentToolCallStatus.canceled => Icons.cancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
pubspec.yaml
Normal file
19
pubspec.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
name: agent_shell
|
||||||
|
description: Product-agnostic Flutter widgets and interfaces for embeddable agent interaction surfaces.
|
||||||
|
version: 0.1.0
|
||||||
|
publish_to: 'none'
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.11.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
61
test/agent_shell_test.dart
Normal file
61
test/agent_shell_test.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:agent_shell/agent_shell.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('submits composer text', (tester) async {
|
||||||
|
String? submitted;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AgentShell(
|
||||||
|
messages: const [],
|
||||||
|
onSubmit: (text) => submitted = text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'hello');
|
||||||
|
await tester.tap(find.byIcon(Icons.send));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(submitted, 'hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('emits approval action for tool calls', (tester) async {
|
||||||
|
AgentToolAction? action;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AgentShell(
|
||||||
|
messages: const [
|
||||||
|
AgentMessage(
|
||||||
|
id: 'm1',
|
||||||
|
role: AgentMessageRole.assistant,
|
||||||
|
text: 'I need approval.',
|
||||||
|
toolCalls: [
|
||||||
|
AgentToolCall(
|
||||||
|
id: 'tool-1',
|
||||||
|
name: 'restart-node',
|
||||||
|
status: AgentToolCallStatus.awaitingApproval,
|
||||||
|
summary: 'Restart a node',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onToolAction: (next) => action = next,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Approve'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(action?.toolCallId, 'tool-1');
|
||||||
|
expect(action?.type, AgentToolActionType.approve);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue