Commit 84ee14c6 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(chat): render prose as prose, tool calls as steps; restore the original mark

Two problems from the first real use of the app.

RENDERING. Every line of a run was drawn as flat monospace text, so the parts
that were markdown arrived as their source: tables as rows of pipe characters,
`**bold**` as asterisks, headings as hashes. That is the "gibberish between real
replies" — there was never any gibberish, only unrendered markdown sitting
between the parts that happened to contain none.

The server already emits a structured line vocabulary (▶ header, ⚙ tool,
→ result, · thinking, ■ footer, everything else prose). AgentOutput now groups
those lines into blocks and renders each as what it is:

  prose   -> MarkdownBody, with tables, code blocks, lists and headings
  tool    -> a compact one-line step, its result collapsed behind it
  thinking-> a single pulsing marker; consecutive ones collapse
  header  -> present but recessive; useful only when something goes wrong

A tool result is attached to the step above it rather than printed inline, so a
500-character command output no longer sits between two paragraphs. Tap to
expand it, long-press the step to copy the command.

THE ICON. I rebuilt the flame by hand instead of taking it from the APK that was
sitting in front of me the whole time — a worse copy of an asset I already had.
The original icon.png, icon_foreground.png and flame.png are lifted from the old
build's flutter_assets and are now both the launcher icon and the in-app mark,
loaded from the same file so the two cannot drift.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent e92220ba
......@@ -10,6 +10,10 @@ android {
ndkVersion = flutter.ndkVersion
compileOptions {
// flutter_local_notifications uses java.time APIs that do not exist on
// older Android runtimes; desugaring backports them. Without this the
// AAR metadata check fails the build outright.
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
......@@ -47,3 +51,7 @@ kotlin {
flutter {
source = "../.."
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0B0B0D</color>
</resources>
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
assets/icon.png

13.1 KB | W: | H:

assets/icon.png

84.7 KB | W: | H:

assets/icon.png
assets/icon.png
assets/icon.png
assets/icon.png
  • 2-up
  • Swipe
  • Onion skin
assets/icon_foreground.png

8.93 KB | W: | H:

assets/icon_foreground.png

128 KB | W: | H:

assets/icon_foreground.png
assets/icon_foreground.png
assets/icon_foreground.png
assets/icon_foreground.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -416,12 +416,9 @@ class _Bubble extends StatelessWidget {
),
],
),
if (message.content.isNotEmpty) ...[
if (message.content.isNotEmpty || running) ...[
const SizedBox(height: 10),
AgentOutput(text: message.content),
] else if (running) ...[
const SizedBox(height: 10),
const Text('Starting…', style: TextStyle(color: Ember.textFaint, fontSize: 13)),
AgentOutput(text: message.content, running: running),
],
],
),
......
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../core/theme.dart';
/// Renders the agent's streamed output.
/// Renders an agent run the way a chat should read: prose as prose, tool calls
/// as compact inline steps, in the order they happened.
///
/// The server's renderer emits a line vocabulary rather than raw JSON:
/// ▶ session header ■ completion footer
/// [Tool ...] tool invocations
/// ⚠ errors
/// Colouring those makes a long run scannable at a glance, which matters on a
/// phone where you cannot see much at once. Everything else is left verbatim.
/// The server emits a line vocabulary rather than raw JSON:
/// ▶ session … run header
/// ⚙ `<summary>` a tool being invoked
/// → `<result>` its result ✗ `<result>` its error
/// · thinking… reasoning elided
/// ■ finished … run footer
/// anything else assistant prose, in markdown
///
/// The previous version drew all of that as flat monospace, so tables arrived as
/// pipe characters and `**bold**` as asterisks — the "gibberish" between real
/// replies. Here the lines are first grouped into blocks, then each block is
/// rendered as what it actually is.
class AgentOutput extends StatelessWidget {
const AgentOutput({super.key, required this.text});
const AgentOutput({super.key, required this.text, this.running = false});
final String text;
final bool running;
@override
Widget build(BuildContext context) {
final blocks = _parse(text);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < blocks.length; i++) ...[
if (i > 0) const SizedBox(height: 8),
blocks[i].build(context),
],
// A live run with nothing rendered yet still needs to look alive.
if (running && blocks.isEmpty) const _Pulse(label: 'Working…'),
],
);
}
List<_Block> _parse(String raw) {
final out = <_Block>[];
final lines = raw.split('\n');
final prose = StringBuffer();
void flushProse() {
final t = prose.toString().trimRight();
prose.clear();
if (t.trim().isNotEmpty) out.add(_Prose(t));
}
for (final line in lines) {
final t = line.trimLeft();
if (t.startsWith('▶')) {
flushProse();
out.add(_Header(t.substring(1).trim()));
} else if (t.startsWith('■')) {
flushProse();
out.add(_Footer(t.substring(1).trim()));
} else if (t.startsWith('⚙')) {
flushProse();
out.add(_Tool(t.substring(1).trim()));
} else if (t.startsWith('→') || t.startsWith('✗')) {
// Attaches to the tool above it; a stray result becomes its own step
// rather than being dropped.
final body = t.substring(1).trim();
final err = t.startsWith('✗');
if (out.isNotEmpty && out.last is _Tool) {
(out.last as _Tool).attach(body, err);
} else {
flushProse();
out.add(_Tool(body)..attach('', err));
}
} else if (t.startsWith('·')) {
flushProse();
// Consecutive thinking markers collapse: five in a row says nothing
// more than one does.
if (out.isNotEmpty && out.last is _Thinking) continue;
out.add(const _Thinking());
} else {
prose.writeln(line);
}
}
flushProse();
return out;
}
}
// --- blocks -----------------------------------------------------------------
abstract class _Block {
const _Block();
Widget build(BuildContext context);
}
class _Prose extends _Block {
const _Prose(this.text);
final String text;
@override
Widget build(BuildContext context) => MarkdownBody(
data: text,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(fontSize: 14, height: 1.5, color: Ember.text),
h1: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: Ember.text),
h2: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Ember.text),
h3: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Ember.text),
strong: const TextStyle(fontWeight: FontWeight.w700, color: Colors.white),
em: const TextStyle(fontStyle: FontStyle.italic, color: Ember.text),
code: const TextStyle(
fontSize: 12,
fontFamilyFallback: Ember.mono,
backgroundColor: Ember.surfaceHi,
color: Ember.amber,
),
codeblockDecoration: BoxDecoration(
color: const Color(0xFF0E0E12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Ember.border),
),
codeblockPadding: const EdgeInsets.all(10),
blockquoteDecoration: const BoxDecoration(
border: Border(left: BorderSide(color: Ember.flame, width: 3)),
),
blockquotePadding: const EdgeInsets.only(left: 10),
listBullet: const TextStyle(color: Ember.flame, fontSize: 14),
a: const TextStyle(color: Ember.info, decoration: TextDecoration.underline),
tableBorder: TableBorder.all(color: Ember.border, width: 1),
tableHead: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12.5),
tableBody: const TextStyle(fontSize: 12.5, color: Ember.text),
tableCellsPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
horizontalRuleDecoration: const BoxDecoration(
border: Border(top: BorderSide(color: Ember.border)),
),
),
);
}
/// A tool invocation and, once it lands, its result. Collapsed by default: the
/// summary is the useful part, and a 500-character result inlined between two
/// paragraphs is what made the transcript unreadable.
class _Tool extends _Block {
_Tool(this.summary);
final String summary;
String? result;
bool isError = false;
void attach(String body, bool err) {
result = body;
isError = err;
}
IconData get _icon {
final s = summary;
if (s.startsWith(r'$')) return Icons.terminal;
if (s.startsWith('read')) return Icons.description_outlined;
if (s.startsWith('write') || s.startsWith('edit')) return Icons.edit_outlined;
if (s.startsWith('grep') || s.startsWith('glob')) return Icons.search;
if (s.startsWith('subagent')) return Icons.account_tree_outlined;
if (s.startsWith('todo')) return Icons.checklist;
return Icons.bolt;
}
@override
Widget build(BuildContext context) => _ToolTile(
icon: _icon,
summary: summary,
result: result,
isError: isError,
);
}
class _ToolTile extends StatefulWidget {
const _ToolTile({
required this.icon,
required this.summary,
required this.result,
required this.isError,
});
final IconData icon;
final String summary;
final String? result;
final bool isError;
@override
State<_ToolTile> createState() => _ToolTileState();
}
class _ToolTileState extends State<_ToolTile> {
bool _open = false;
@override
Widget build(BuildContext context) {
final lines = text.split('\n');
return SelectionArea(
final hasResult = (widget.result ?? '').trim().isNotEmpty;
final accent = widget.isError ? Ember.bad : Ember.info;
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFF101015),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: widget.isError ? Ember.bad.withValues(alpha: 0.4) : Ember.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final line in lines)
if (line.trim().isEmpty)
const SizedBox(height: 6)
else
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(line, style: _styleFor(line)),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: hasResult ? () => setState(() => _open = !_open) : null,
onLongPress: () {
Clipboard.setData(ClipboardData(text: widget.summary));
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Command copied')));
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 7),
child: Row(
children: [
Icon(widget.icon, size: 13, color: accent),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.summary,
maxLines: _open ? 6 : 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11.5,
height: 1.35,
fontFamilyFallback: Ember.mono,
color: Ember.textDim,
),
),
),
if (hasResult)
Icon(
_open ? Icons.expand_less : Icons.expand_more,
size: 15,
color: Ember.textFaint,
),
],
),
),
),
if (_open && hasResult)
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(9, 0, 9, 9),
child: SelectableText(
widget.result!,
style: TextStyle(
fontSize: 11,
height: 1.4,
fontFamilyFallback: const [...Ember.mono],
color: widget.isError ? Ember.bad : Ember.textFaint,
),
),
),
],
),
);
}
}
TextStyle _styleFor(String line) {
const base = TextStyle(fontSize: 13, height: 1.45, color: Ember.text);
final t = line.trimLeft();
class _Thinking extends _Block {
const _Thinking();
if (t.startsWith('▶')) {
return base.copyWith(
color: Ember.textFaint,
fontSize: 11,
fontFamilyFallback: Ember.mono,
@override
Widget build(BuildContext context) => const _Pulse(label: 'thinking…');
}
class _Pulse extends StatefulWidget {
const _Pulse({required this.label});
final String label;
@override
State<_Pulse> createState() => _PulseState();
}
class _PulseState extends State<_Pulse> with SingleTickerProviderStateMixin {
late final _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
)..repeat(reverse: true);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => FadeTransition(
opacity: _c.drive(Tween(begin: 0.35, end: 0.9)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.blur_on, size: 12, color: Ember.textFaint),
const SizedBox(width: 6),
Text(
widget.label,
style: const TextStyle(
fontSize: 11.5,
fontStyle: FontStyle.italic,
color: Ember.textFaint,
),
),
],
),
);
}
if (t.startsWith('■')) {
return base.copyWith(color: Ember.ok, fontSize: 11, fontWeight: FontWeight.w600);
}
if (t.startsWith('⚠') || t.startsWith('Error') || t.startsWith('error')) {
return base.copyWith(color: Ember.bad);
}
if (t.startsWith('[')) {
// Tool calls: monospace and dimmed — they are context, not the answer.
return base.copyWith(
color: Ember.info,
fontSize: 11.5,
fontFamilyFallback: Ember.mono,
}
/// The run header carries the session id and cwd. Useful when something goes
/// wrong, noise the rest of the time — so it is present but recessive.
class _Header extends _Block {
const _Header(this.text);
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(fontSize: 10, color: Ember.textFaint, fontFamilyFallback: Ember.mono),
);
}
class _Footer extends _Block {
const _Footer(this.text);
final String text;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle, size: 11, color: Ember.ok),
const SizedBox(width: 5),
Text(text, style: const TextStyle(fontSize: 10.5, color: Ember.ok)),
],
);
}
if (t.startsWith('#')) {
return base.copyWith(fontWeight: FontWeight.w700);
}
if (t.startsWith('```') || t.startsWith(' ') || t.startsWith('\t')) {
return base.copyWith(fontFamilyFallback: Ember.mono, fontSize: 12);
}
return base;
}
}
......@@ -2,35 +2,43 @@ import 'package:flutter/material.dart';
import '../core/theme.dart';
/// The app mark: a flame on a dark rounded tile. Drawn rather than shipped as a
/// bitmap so it stays sharp at every size and the gradient matches the theme
/// tokens instead of drifting from them.
/// The app mark — the original Son of Anton flame, carried over from the first
/// build's assets rather than redrawn. It is the identity the app is known by;
/// a "close enough" reconstruction is not the same thing.
///
/// Uses the same `assets/flame.png` that generates the launcher icon, so the
/// icon on the home screen and the mark inside the app cannot drift apart.
class FlameMark extends StatelessWidget {
const FlameMark({super.key, this.size = 40, this.tile = true});
final double size;
/// Whether to draw the dark rounded plate behind the flame. Off when the mark
/// sits on a surface that already provides its own contrast.
final bool tile;
@override
Widget build(BuildContext context) {
final flame = SizedBox(
width: size * 0.56,
height: size * 0.56,
child: CustomPaint(painter: _FlamePainter()),
final flame = Image.asset(
'assets/flame.png',
width: size * (tile ? 0.62 : 1.0),
height: size * (tile ? 0.62 : 1.0),
filterQuality: FilterQuality.medium,
);
if (!tile) return flame;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF121216),
color: const Color(0xFF0A0A0E),
borderRadius: BorderRadius.circular(size * 0.28),
border: Border.all(color: Ember.border),
boxShadow: [
BoxShadow(
color: Ember.flame.withValues(alpha: 0.22),
blurRadius: size * 0.35,
spreadRadius: -size * 0.08,
color: Ember.flame.withValues(alpha: 0.20),
blurRadius: size * 0.34,
spreadRadius: -size * 0.10,
),
],
),
......@@ -38,52 +46,3 @@ class FlameMark extends StatelessWidget {
);
}
}
class _FlamePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size s) {
final w = s.width, h = s.height;
// Outer flame: a teardrop with a curled left shoulder.
final outer = Path()
..moveTo(w * 0.50, 0)
..cubicTo(w * 0.86, h * 0.26, w * 1.00, h * 0.46, w * 0.94, h * 0.66)
..cubicTo(w * 0.88, h * 0.89, w * 0.71, h * 1.00, w * 0.50, h * 1.00)
..cubicTo(w * 0.29, h * 1.00, w * 0.12, h * 0.89, w * 0.06, h * 0.66)
..cubicTo(w * 0.00, h * 0.44, w * 0.18, h * 0.30, w * 0.30, h * 0.14)
..cubicTo(w * 0.34, h * 0.34, w * 0.42, h * 0.40, w * 0.46, h * 0.36)
..cubicTo(w * 0.52, h * 0.30, w * 0.50, h * 0.14, w * 0.50, 0)
..close();
canvas.drawPath(
outer,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Ember.flameSoft, Ember.flame],
).createShader(Rect.fromLTWH(0, 0, w, h)),
);
// Inner core, brighter and offset low — what reads as heat.
final core = Path()
..moveTo(w * 0.52, h * 0.42)
..cubicTo(w * 0.72, h * 0.56, w * 0.74, h * 0.70, w * 0.62, h * 0.82)
..cubicTo(w * 0.52, h * 0.92, w * 0.34, h * 0.88, w * 0.30, h * 0.74)
..cubicTo(w * 0.27, h * 0.62, w * 0.38, h * 0.54, w * 0.52, h * 0.42)
..close();
canvas.drawPath(
core,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFD68A), Ember.amber],
).createShader(Rect.fromLTWH(0, h * 0.4, w, h * 0.6)),
);
}
@override
bool shouldRepaint(covariant CustomPainter old) => false;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment