Skip to content

Commit 0143f78

Browse files
committed
feat: enhance session management and logging with new permission handling and UI updates
1 parent db138fe commit 0143f78

13 files changed

Lines changed: 835 additions & 92 deletions

File tree

App/lib/constants/levels.dart

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,27 @@ const dependenceLevels = <DependenceLevel>[
1616
DependenceLevel(
1717
level: 1,
1818
name: 'Ask everything',
19-
description: 'Agent asks for your approval on every single action.',
19+
description: 'Agent asks for approval before every meaningful action.',
2020
),
2121
DependenceLevel(
2222
level: 2,
2323
name: 'Ask on writes',
24-
description: 'Agent acts independently on reads, asks before any write.',
24+
description: 'Agent acts independently on reads and asks before write-like actions.',
2525
),
2626
DependenceLevel(
2727
level: 3,
2828
name: 'Ask on ambiguity',
29-
description: 'Agent proceeds on clear tasks, asks when uncertain.',
29+
description: 'Agent proceeds on clear tasks and asks when intent is ambiguous.',
3030
),
3131
DependenceLevel(
3232
level: 4,
3333
name: 'Ask on destructive',
34-
description: 'Agent works freely, only asks before destructive operations.',
34+
description: 'Agent works freely and asks before risky or destructive operations.',
3535
),
3636
DependenceLevel(
3737
level: 5,
3838
name: 'Full delegate',
39-
description: 'Agent has full autonomy — no interruptions.',
39+
description: 'Agent has full autonomy with auto-approved permissions.',
4040
),
4141
];
4242

App/lib/providers/daemon_actions_provider.dart

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import '../providers/connection_provider.dart';
1212
import '../providers/session_provider.dart';
1313
import '../services/socket_service.dart';
1414

15+
final RegExp _writeIntentPattern = RegExp(
16+
r'\b(create|make|write|edit|modify|update|delete|remove|rename|move|add|patch|save)\b|\.(txt|md|json|yaml|yml|toml|dart|ts|js)\b',
17+
caseSensitive: false,
18+
);
19+
1520
class DaemonActions {
1621
final SocketService socketService;
1722
final Ref ref;
@@ -55,8 +60,8 @@ class DaemonActions {
5560
debugPrint('[DaemonActions] activeJobId=${socketService.activeJobId}');
5661
// ────────────────────────────────────────────────────────────────────────
5762

58-
final level = session.dependenceLevel.clamp(1, 5).toInt();
59-
final profile = executionProfileForLevel(level);
63+
final level = session.dependenceLevel.clamp(1, 5).toInt();
64+
final profile = executionProfileForLevel(level);
6065

6166
ref.read(sessionProvider.notifier).appendLog(LogEntry(
6267
id: 'user_input_${DateTime.now().millisecondsSinceEpoch}',
@@ -77,16 +82,36 @@ class DaemonActions {
7782
));
7883
return;
7984
}
85+
86+
if (level <= 2 && _writeIntentPattern.hasMatch(task)) {
87+
ref.read(sessionProvider.notifier).appendLog(
88+
LogEntry(
89+
id: 'warn_level_${DateTime.now().millisecondsSinceEpoch}',
90+
timestamp: DateTime.now().toIso8601String(),
91+
level: AgentLogLevel.info,
92+
message:
93+
'Write-like task detected. Waiting for explicit approval prompts at this dependence level.',
94+
source: LogSource.local,
95+
),
96+
);
97+
}
98+
99+
final args = <String>[
100+
'run',
101+
if (session.sessionId != null && session.sessionId!.isNotEmpty) ...[
102+
'--session',
103+
session.sessionId!,
104+
],
105+
task,
106+
if (level >= 5) '--dangerously-skip-permissions',
107+
'--dependence-level',
108+
level.toString(),
109+
];
110+
80111
debugPrint('[DaemonActions] SENDING cliExecute to bridge...');
81112
socketService.sendBridgeCommand({
82113
'type': 'cliExecute',
83-
'args': [
84-
'run',
85-
task,
86-
'--dangerously-skip-permissions',
87-
'--dependence-level',
88-
level.toString(),
89-
],
114+
'args': args,
90115
'env': {'CODETWIN_DEPENDENCE_LEVEL': level.toString()},
91116
'interactive': profile.interactive,
92117
'streamFormat': profile.streamFormat,

App/lib/providers/session_provider.dart

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
44
import '../models/session_status.dart';
55
import '../models/log_entry.dart';
66

7+
const Object _unset = Object();
8+
79
// ---------------------------------------------------------------------------
810
// State
911
// ---------------------------------------------------------------------------
@@ -36,28 +38,36 @@ class SessionState {
3638
static const empty = SessionState();
3739

3840
SessionState copyWith({
39-
String? sessionId,
40-
String? projectId,
41+
Object? sessionId = _unset,
42+
Object? projectId = _unset,
4143
SessionStatus? status,
42-
String? currentTask,
44+
Object? currentTask = _unset,
4345
int? dependenceLevel,
4446
List<LogEntry>? logs,
4547
List<PreflightItem>? preflightQueue,
4648
List<DecisionItem>? decisionQueue,
47-
TaskCompletePayload? lastComplete,
48-
TaskFailedPayload? lastFailed,
49+
Object? lastComplete = _unset,
50+
Object? lastFailed = _unset,
4951
}) {
5052
return SessionState(
51-
sessionId: sessionId ?? this.sessionId,
52-
projectId: projectId ?? this.projectId,
53+
sessionId:
54+
identical(sessionId, _unset) ? this.sessionId : sessionId as String?,
55+
projectId:
56+
identical(projectId, _unset) ? this.projectId : projectId as String?,
5357
status: status ?? this.status,
54-
currentTask: currentTask ?? this.currentTask,
58+
currentTask: identical(currentTask, _unset)
59+
? this.currentTask
60+
: currentTask as String?,
5561
dependenceLevel: dependenceLevel ?? this.dependenceLevel,
5662
logs: logs ?? this.logs,
5763
preflightQueue: preflightQueue ?? this.preflightQueue,
5864
decisionQueue: decisionQueue ?? this.decisionQueue,
59-
lastComplete: lastComplete ?? this.lastComplete,
60-
lastFailed: lastFailed ?? this.lastFailed,
65+
lastComplete: identical(lastComplete, _unset)
66+
? this.lastComplete
67+
: lastComplete as TaskCompletePayload?,
68+
lastFailed: identical(lastFailed, _unset)
69+
? this.lastFailed
70+
: lastFailed as TaskFailedPayload?,
6171
);
6272
}
6373
}
@@ -124,13 +134,24 @@ class SessionNotifier extends AsyncNotifier<SessionState> {
124134
}
125135

126136
void setLevel(int level) {
127-
state = AsyncData(_s.copyWith(dependenceLevel: level.clamp(1, 5)));
137+
// Dependence level modifies permission rules in CLI sessions.
138+
// Reset stored session id so next run starts a fresh session with new rules.
139+
state = AsyncData(
140+
_s.copyWith(
141+
dependenceLevel: level.clamp(1, 5),
142+
sessionId: null,
143+
),
144+
);
128145
}
129146

130147
void setCurrentTask(String? task) {
131148
state = AsyncData(_s.copyWith(currentTask: task));
132149
}
133150

151+
void setSessionId(String? sessionId) {
152+
state = AsyncData(_s.copyWith(sessionId: sessionId));
153+
}
154+
134155
void setSession(String sessionId, String projectId) {
135156
state = AsyncData(
136157
_s.copyWith(sessionId: sessionId, projectId: projectId),

App/lib/screens/dashboard_screen.dart

Lines changed: 142 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ import '../widgets/daemon_status_bar.dart';
1717
import '../widgets/session_status_badge.dart';
1818
import '../theme/cli_theme.dart';
1919

20+
bool _isChatInteraction(LogEntry log) {
21+
if (log.source == LogSource.raw) return false;
22+
if (log.source == LogSource.local) return true;
23+
final type = log.structuredType;
24+
if (type == null) return true;
25+
return type == 'text' || type == 'error';
26+
}
27+
28+
bool _isTimelineEvent(LogEntry log) {
29+
if (log.source != LogSource.structured) return false;
30+
final type = log.structuredType;
31+
return type != null && type != 'text';
32+
}
33+
2034
// ── Fade-slide-in wrapper ─────────────────────────────────────────────────────
2135
class _FadeSlide extends StatefulWidget {
2236
final Widget child;
@@ -68,6 +82,8 @@ class DashboardScreen extends ConsumerWidget {
6882
final session =
6983
ref.watch(sessionProvider).valueOrNull ?? SessionState.empty;
7084
final actions = ref.read(daemonActionsProvider);
85+
final chatLogs = session.logs.where(_isChatInteraction).toList();
86+
final timelineLogs = session.logs.where(_isTimelineEvent).toList();
7187

7288
return CliTheme(
7389
level: session.dependenceLevel,
@@ -176,26 +192,27 @@ class DashboardScreen extends ConsumerWidget {
176192
body: session.lastFailed!.error,
177193
),
178194
),
195+
196+
if (timelineLogs.isNotEmpty &&
197+
session.preflightQueue.isEmpty &&
198+
session.decisionQueue.isEmpty)
199+
_FadeSlide(
200+
delay: const Duration(milliseconds: 90),
201+
child: _ProcessTimelineSection(logs: timelineLogs),
202+
),
179203
],
180204
),
181205
),
182206
),
183207

184208
// ── Chat log fills remaining space ───────────────────
185-
if (session.logs
186-
.any((l) =>
187-
l.level != AgentLogLevel.error &&
188-
l.source != LogSource.raw) &&
209+
if (chatLogs.isNotEmpty &&
189210
session.preflightQueue.isEmpty &&
190211
session.decisionQueue.isEmpty)
191212
SliverFillRemaining(
192213
hasScrollBody: true,
193214
child: ChatMessageList(
194-
logs: session.logs
195-
.where((l) =>
196-
l.level != AgentLogLevel.error &&
197-
l.source != LogSource.raw)
198-
.toList(),
215+
logs: chatLogs,
199216
),
200217
),
201218

@@ -286,6 +303,122 @@ class _FloatingStatusBar extends ConsumerWidget {
286303
}
287304
}
288305

306+
class _ProcessTimelineSection extends StatelessWidget {
307+
final List<LogEntry> logs;
308+
const _ProcessTimelineSection({required this.logs});
309+
310+
@override
311+
Widget build(BuildContext context) {
312+
final cli = CliTheme.of(context);
313+
final recent = logs.length > 8 ? logs.sublist(logs.length - 8) : logs;
314+
315+
return Padding(
316+
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
317+
child: Container(
318+
decoration: cli.box(borderColor: cli.border),
319+
child: ExpansionTile(
320+
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
321+
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
322+
collapsedIconColor: cli.textDim,
323+
iconColor: cli.accent,
324+
title: Text(
325+
'PROCESS TIMELINE',
326+
style: cli.mono.copyWith(
327+
color: cli.textDim,
328+
fontSize: 10,
329+
letterSpacing: 1.4,
330+
fontWeight: FontWeight.bold,
331+
),
332+
),
333+
subtitle: Text(
334+
'${recent.length} recent events',
335+
style: cli.mono.copyWith(color: cli.textDim, fontSize: 10),
336+
),
337+
children: [
338+
for (final entry in recent)
339+
Padding(
340+
padding: const EdgeInsets.only(bottom: 8),
341+
child: _TimelineEventRow(entry: entry),
342+
),
343+
],
344+
),
345+
),
346+
);
347+
}
348+
}
349+
350+
class _TimelineEventRow extends StatelessWidget {
351+
final LogEntry entry;
352+
const _TimelineEventRow({required this.entry});
353+
354+
@override
355+
Widget build(BuildContext context) {
356+
final cli = CliTheme.of(context);
357+
final type = entry.structuredType ?? 'event';
358+
359+
final (IconData icon, String label, Color color) = switch (type) {
360+
'reasoning' => (Icons.psychology_alt_outlined, 'Thinking', cli.cyan),
361+
'step_start' => (Icons.play_arrow_rounded, 'Step start', cli.amber),
362+
'step_finish' => (Icons.check_circle_outline, 'Step finish', cli.accent),
363+
'tool_use' => (Icons.build_circle_outlined, 'Tool', cli.accent),
364+
'awaiting_approval' => (Icons.help_outline, 'Awaiting approval', cli.amber),
365+
'approval_resolved' => (Icons.task_alt, 'Approval resolved', cli.accent),
366+
_ => (Icons.circle_outlined, type, cli.textDim),
367+
};
368+
369+
final isLong = entry.message.length > 140;
370+
371+
if (!isLong) {
372+
return Row(
373+
crossAxisAlignment: CrossAxisAlignment.start,
374+
children: [
375+
Icon(icon, size: 14, color: color),
376+
const SizedBox(width: 8),
377+
Expanded(
378+
child: Text(
379+
'$label · ${entry.message}',
380+
style: cli.mono.copyWith(color: cli.text, fontSize: 11, height: 1.35),
381+
),
382+
),
383+
],
384+
);
385+
}
386+
387+
return Theme(
388+
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
389+
child: ExpansionTile(
390+
tilePadding: EdgeInsets.zero,
391+
childrenPadding: const EdgeInsets.only(top: 4),
392+
collapsedIconColor: cli.textDim,
393+
iconColor: cli.accent,
394+
title: Row(
395+
children: [
396+
Icon(icon, size: 14, color: color),
397+
const SizedBox(width: 8),
398+
Expanded(
399+
child: Text(
400+
'$label · ${entry.message.substring(0, 120)}...',
401+
maxLines: 1,
402+
overflow: TextOverflow.ellipsis,
403+
style: cli.mono.copyWith(color: cli.text, fontSize: 11),
404+
),
405+
),
406+
],
407+
),
408+
children: [
409+
Align(
410+
alignment: Alignment.centerLeft,
411+
child: Text(
412+
entry.message,
413+
style: cli.mono.copyWith(color: cli.textDim, fontSize: 11, height: 1.35),
414+
),
415+
),
416+
],
417+
),
418+
);
419+
}
420+
}
421+
289422
// ── Wraps a child in a CLI-styled bordered section ────────────────────────────
290423
class _CliSection extends StatelessWidget {
291424
final String label;

0 commit comments

Comments
 (0)