diff --git a/lib/chat/step_folds.dart b/lib/chat/step_folds.dart index 426ee28b..90303697 100644 --- a/lib/chat/step_folds.dart +++ b/lib/chat/step_folds.dart @@ -242,9 +242,9 @@ class StepFolds { /// Whether [item] is a quick step, folded with others like it: a thought, /// or a tool call that only looks around or runs something. Not an edit -/// (shown on its own), a subagent, a message to another agent, a goal -/// proposed, pictures made, a refused call, nor a command left running in -/// the background. +/// (shown on its own), a subagent, a message to another agent, a question +/// to the user (with what they answered), a goal proposed, pictures made, +/// a refused call, nor a command left running in the background. bool isQuickStep(ChatItem item) => switch (item) { ThinkingItem() => true, ToolCallItem(:final kind, :final status) => @@ -253,6 +253,7 @@ bool isQuickStep(ChatItem item) => switch (item) { ToolKind.edit || ToolKind.agent || ToolKind.message || + ToolKind.question || ToolKind.goal || ToolKind.image => false, _ => true, @@ -262,10 +263,13 @@ bool isQuickStep(ChatItem item) => switch (item) { }; /// Whether [item] shows though its turn's work is folded: pictures an -/// image model made, which are what the user asked for. +/// image model made, which are what the user asked for; questions the user +/// answered, and what they said. bool standsOutOfWork(ChatItem item) => switch (item) { ToolCallItem(kind: ToolKind.image, :final status, :final images) => status == ToolStatus.succeeded && images.isNotEmpty, + ToolCallItem(kind: ToolKind.question, :final status) => + status == ToolStatus.succeeded, _ => false, }; diff --git a/lib/chat/widgets/tool_call_row.dart b/lib/chat/widgets/tool_call_row.dart index d56a84f2..ec5e8e84 100644 --- a/lib/chat/widgets/tool_call_row.dart +++ b/lib/chat/widgets/tool_call_row.dart @@ -82,7 +82,35 @@ class ToolCallRow extends StatelessWidget { _ => null, }; - bool get _opens => results.isNotEmpty || _shown != null || images.isNotEmpty; + bool get _opens => + _answered == null && + (results.isNotEmpty || _shown != null || images.isNotEmpty); + + /// The questions asked and what the user answered, once they have + /// (AskUserQuestion): shown under it as they are, not folded in what it + /// returned. + List<({String question, String answer})>? get _answered { + if (kind != ToolKind.question || status != ToolStatus.succeeded) { + return null; + } + final answers = input['answers']; + if (answers is! Map || answers.isEmpty) return null; + final asked = [ + for (final question in input['questions'] as List? ?? const []) + if (question is Map && question['question'] is String) + question['question'] as String, + ]; + final pairs = [ + for (final question in asked) + if (answers[question] case final answer?) + (question: question, answer: '$answer'), + // Answers to questions not in the list, as they came. + for (final MapEntry(:key, :value) in answers.entries) + if (!asked.contains(key) && value != null) + (question: '$key', answer: '$value'), + ]; + return pairs.isEmpty ? null : pairs; + } /// The list written or read, each item marked by its status (its /// output says it as text). @@ -171,7 +199,9 @@ class ToolCallRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ header, - if (_todos case final todos? when expanded) + if (_answered case final answered?) + StepBody(child: _QuestionAnswers(answered: answered)) + else if (_todos case final todos? when expanded) StepBody(child: _TodoList(todos: todos)) else if (expanded && (results.isNotEmpty || _shown != null)) StepBody( @@ -220,6 +250,57 @@ class ToolCallRow extends StatelessWidget { } } +/// Each question asked, and under it what the user answered. +class _QuestionAnswers extends StatelessWidget { + const _QuestionAnswers({required this.answered}); + + final List<({String question, String answer})> answered; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (i, (:question, :answer)) in answered.indexed) ...[ + if (i > 0) const SizedBox(height: 8), + Text( + question, + style: TextStyle( + color: AppColors.textMuted, + fontSize: 12.5, + height: 1.5, + ), + ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 3, right: 6), + child: Icon( + Icons.subdirectory_arrow_right_rounded, + size: 14, + color: AppColors.textFaint, + ), + ), + Expanded( + child: Text( + answer, + style: TextStyle( + color: AppColors.text, + fontSize: 13, + height: 1.5, + ), + ), + ), + ], + ), + ], + ], + ); + } +} + /// A todo list, each item after its mark: done ones faint and struck /// through, as the todo panel had them. class _TodoList extends StatelessWidget { diff --git a/lib/kernel/claude_code/claude_code_translator.dart b/lib/kernel/claude_code/claude_code_translator.dart index d1bcd77c..84977510 100644 --- a/lib/kernel/claude_code/claude_code_translator.dart +++ b/lib/kernel/claude_code/claude_code_translator.dart @@ -122,6 +122,10 @@ class ClaudeTranslator { /// The subjects of the todos created (TaskCreate), by their ids. final Map _taskSubjects = {}; + /// What the user answered to questions asked (AskUserQuestion), by the + /// call's id: each answer by its question's text. + final Map> _answers = {}; + /// The session's goal (`/goal`), as last reported. KernelGoal? _goal; @@ -1116,6 +1120,8 @@ class ClaudeTranslator { status: status, output: output, ), + // The questions, and once answered, the answers (see [_answers]), + // for the history to show what was asked and said. 'AskUserQuestion' => ToolCallItem( kind: ToolKind.question, target: [ @@ -1124,6 +1130,8 @@ class ClaudeTranslator { ].join(' · '), status: status, output: output, + tool: name, + input: {'questions': input['questions'], 'answers': ?_answers[tool.id]}, ), // "Said …": what it says, on a line; opened, all of it (what sending // it returned only if it failed). @@ -1314,6 +1322,8 @@ class ClaudeTranslator { parent: tool.parent, ); return; + case 'AskUserQuestion' when outcome == _Outcome.done: + _answers[id] = questionAnswers(structured?['answers'], text); case 'ExitPlanMode': if (_string(tool.input['plan']) != null) return; case 'TaskCreate': @@ -2030,3 +2040,25 @@ class _Agent { AgentItem item; final Map children = {}; } + +/// What the user answered to questions asked (AskUserQuestion), each +/// answer by its question's text: from the call's result ([structured]'s +/// `answers`, the CLI's and BaoCode's own core's alike), else read from +/// what it said to the model (`"question"="answer", …`). +Map questionAnswers(Object? structured, String text) { + if (structured is Map && structured.isNotEmpty) { + return { + for (final MapEntry(:key, :value) in structured.entries) + if (key is String && value != null) key: '$value', + }; + } + return { + for (final match in _answered.allMatches(text)) + _unquote(match[1]!): _unquote(match[2]!), + }; +} + +final _answered = RegExp(r'"((?:[^"\\]|\\.)*)"="((?:[^"\\]|\\.)*)"'); + +String _unquote(String text) => + text.replaceAllMapped(RegExp(r'\\(.)'), (match) => match[1]!); diff --git a/test/chat/question_step_test.dart b/test/chat/question_step_test.dart new file mode 100644 index 00000000..c1893bae --- /dev/null +++ b/test/chat/question_step_test.dart @@ -0,0 +1,67 @@ +import 'package:baocode/chat/chat_models.dart'; +import 'package:baocode/chat/widgets/step_header.dart'; +import 'package:baocode/chat/widgets/tool_call_row.dart'; +import 'package:baocode/theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future pump(WidgetTester tester, ToolCallRow row) => tester.pumpWidget( + MaterialApp( + theme: buildAppTheme(), + home: Scaffold(body: row), + ), + ); + + testWidgets('a question answered shows each question and what the user ' + 'said, without being opened; its raw result is not offered', ( + tester, + ) async { + await pump( + tester, + const ToolCallRow( + kind: ToolKind.question, + target: 'Platform · Size', + tool: 'AskUserQuestion', + output: 'User has answered your questions: …', + input: { + 'questions': [ + {'question': 'Which platform?', 'header': 'Platform'}, + {'question': 'What size?', 'header': 'Size'}, + ], + 'answers': {'Which platform?': 'Flutter', 'What size?': 'Large'}, + }, + ), + ); + for (final text in ['Which platform?', 'Flutter', 'What size?', 'Large']) { + expect(find.text(text), findsOneWidget); + } + expect(find.text('User has answered your questions: …'), findsNothing); + expect(tester.widget(find.byType(StepHeader)).onToggle, isNull); + }); + + testWidgets('not answered (refused, failed), it is the step it was', ( + tester, + ) async { + await pump( + tester, + ToolCallRow( + kind: ToolKind.question, + target: 'Platform', + status: ToolStatus.failed, + output: 'The user dismissed the questions.', + onToggle: () {}, + input: const { + 'questions': [ + {'question': 'Which platform?'}, + ], + }, + ), + ); + expect(find.text('Which platform?'), findsNothing); + expect( + tester.widget(find.byType(StepHeader)).onToggle, + isNotNull, + ); + }); +} diff --git a/test/chat/step_folds_test.dart b/test/chat/step_folds_test.dart index 4ca845e4..3622c929 100644 --- a/test/chat/step_folds_test.dart +++ b/test/chat/step_folds_test.dart @@ -236,6 +236,40 @@ void main() { }); }); + test('a question the user answered breaks a run, and shows out of a ' + 'closed turn, with what they said', () { + const asked = ToolCallItem( + kind: ToolKind.question, + target: 'Color', + input: { + 'questions': [ + {'question': 'Red or blue?', 'header': 'Color'}, + ], + 'answers': {'Red or blue?': 'Red'}, + }, + ); + final turn = [ + _prompt(), + _read, + _grep, + asked, + _read, + _grep, + _answer, + ]; + expect(_folds(turn), ['work 1-6', 'steps 1-3', 'steps 4-6']); + final folds = _fold(turn); + expect(folds.rowAt(3, asked, (_) => false).item, isTrue); + // Still being asked: in the panel over the composer, not out of it. + final asking = [...turn] + ..[3] = const ToolCallItem( + kind: ToolKind.question, + target: 'Color', + status: ToolStatus.running, + ); + expect(_fold(asking).rowAt(3, asking[3], (_) => false).item, isFalse); + }); + group('thoughts one after another', () { const empty = ThinkingItem(text: '', tokens: 0, seconds: 12); const titled = ThinkingItem( diff --git a/test/kernel_test.dart b/test/kernel_test.dart index fa98e26d..5649b518 100644 --- a/test/kernel_test.dart +++ b/test/kernel_test.dart @@ -395,6 +395,40 @@ void main() { expect(toolVerb(ToolKind.question), 'Asked'); }); + test('a question answered keeps what was asked and what the user said, ' + 'from the result or, without one, its words', () { + final transcript = Transcript(); + var seq = 0; + final translator = ClaudeTranslator( + emit: transcript.apply, + nextSeq: () => ++seq, + ); + for (final message in recorded('question')) { + translator.translate(message); + } + final asked = [ + for (var i = 0; i < transcript.length; i++) transcript.itemAt(i), + ].whereType().single; + expect(asked.kind, ToolKind.question); + expect(asked.status, ToolStatus.succeeded); + expect(asked.input['answers'], {'Do you prefer red or blue?': 'Red'}); + expect( + (asked.input['questions'] as List).single, + containsPair('question', 'Do you prefer red or blue?'), + ); + + // Only in its words (BaoCode's own core's, a result not kept). + expect( + questionAnswers( + null, + 'The user answered: "Which \\"one\\"?"="A, B", "Size?"="Large". ' + 'Carry on with these answers.', + ), + {'Which "one"?': 'A, B', 'Size?': 'Large'}, + ); + expect(questionAnswers({'Q?': 'Yes'}, 'ignored'), {'Q?': 'Yes'}); + }); + test('a message sent to an agent reads on its line, in full opened', () { final transcript = Transcript(); var seq = 0;