Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions lib/chat/step_folds.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -253,6 +253,7 @@ bool isQuickStep(ChatItem item) => switch (item) {
ToolKind.edit ||
ToolKind.agent ||
ToolKind.message ||
ToolKind.question ||
ToolKind.goal ||
ToolKind.image => false,
_ => true,
Expand All @@ -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,
};

Expand Down
85 changes: 83 additions & 2 deletions lib/chat/widgets/tool_call_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions lib/kernel/claude_code/claude_code_translator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ class ClaudeTranslator {
/// The subjects of the todos created (TaskCreate), by their ids.
final Map<String, String> _taskSubjects = {};

/// What the user answered to questions asked (AskUserQuestion), by the
/// call's id: each answer by its question's text.
final Map<String, Map<String, String>> _answers = {};

/// The session's goal (`/goal`), as last reported.
KernelGoal? _goal;

Expand Down Expand Up @@ -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: [
Expand All @@ -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).
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -2030,3 +2040,25 @@ class _Agent {
AgentItem item;
final Map<String, ChatItem> 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<String, String> 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]!);
67 changes: 67 additions & 0 deletions test/chat/question_step_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<StepHeader>(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<StepHeader>(find.byType(StepHeader)).onToggle,
isNotNull,
);
});
}
34 changes: 34 additions & 0 deletions test/chat/step_folds_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <ChatItem>[
_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(
Expand Down
34 changes: 34 additions & 0 deletions test/kernel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolCallItem>().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;
Expand Down