May 13, 2026 · ~12 min read

Getting Flutter logs back in the terminal

I moved my Flutter debugging into the Claude Desktop terminal so an AI could read my app's output live. The terminal showed every print() and swallowed every developer.log(). Fixing that took a logging facade, a custom printer that chunks long lines for iOS, and a shell filter that strips ANSI and mutes the SDK chatter I don't care about. Here is the whole ride.

TL;DR: I wanted to run my Flutter app from Claude Desktop's terminal so I could let an AI help me debug live. The terminal saw my print() calls and completely swallowed dart:developer.log(). That sent me down a rabbit hole: a fresh logging facade with package:logger, a custom printer that chunks long lines for iOS's NSLog ceiling, and a shell filter that strips ANSI, parses a custom tag format, and mutes the SDKs I don't care about. Here is the whole trip, what I tried, what failed, and the code that finally worked.

Most of my Flutter debugging used to happen in DevTools. Open DevTools, watch the Logging tab, get on with life. That broke down once I started using Claude Desktop as my primary terminal. The agent can do a lot of useful work for me if it can read what my app is saying. DevTools is a separate window. The terminal is right there. I wanted everything in one stream.

I expected this to be a five-minute problem. It was not.

The setup

flutter run writes to stdout. Stdout flows into the terminal where I started the command. If I run Flutter from the Claude Desktop terminal, the agent reads the same stream I do. Both of us see the same logs. That part works on day one.

The unfair surprise: I saw print("hello") show up just fine. Every call to developer.log("hello", name: "MyTag") was invisible. No errors. No warnings. The lines simply did not exist.

I looked twice. They really were gone.

Why developer.log goes silent

I had always treated print() and dart:developer.log() as two ways to do the same thing, with log() being "the proper one" because it supports a name, error, and stackTrace. That mental model is wrong, and the wrongness is what was biting me.

print() writes to standard output. On a real iOS device, the Flutter tooling forwards that stdout through to your terminal, prefixed with flutter:. On Android, it shows up in adb logcat tagged as flutter. Either way, it surfaces.

developer.log() does something else entirely. It writes to the VM service log stream. That stream is consumed by DevTools and IDE Logging tabs, which subscribe to the VM service over a websocket. flutter run's console does not subscribe. If you are not running DevTools, those lines exist only inside the VM service buffer where nobody is listening.

Once I understood that, the symptom made sense. My CLAUDE.md actually told me to use developer.log, which was correct when DevTools was my window. It was wrong for terminal-first development. The convention itself had to change.

First attempt: switch to package:logger

package:logger is the obvious move. It is small, popular, and writes to stdout under the hood. Levels, filters, error and stack-trace support, all there. I added it:

# pubspec.yaml
dependencies:
  logger: ^2.7.0

Wrote a small facade so call sites would not bind directly to the package:

// lib/foundation/logging/app_log.dart
import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';

final Logger _logger = Logger(
  level: kDebugMode ? Level.trace : Level.warning,
  printer: PrettyPrinter(
    methodCount: 0,
    errorMethodCount: 8,
    lineLength: 100,
    colors: true,
    printEmojis: false,
    dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart,
  ),
);

class AppLog {
  AppLog._();
  static void i(Object? m, {String? name, Object? error, StackTrace? stackTrace}) =>
      _logger.i(_fmt(name, m), error: error, stackTrace: stackTrace);
  // ... v / d / w / e similar ...
  static String _fmt(String? name, Object? m) =>
      name == null || name.isEmpty ? '$m' : '[$name] $m';
}

Codemodded my whole lib/ over to AppLog. About 220 files, 2,200 call sites. Ran the app. Watched the terminal.

Still nothing useful.

Why PrettyPrinter did not save me

package:logger definitely was reaching stdout. I could see it. The problem was that PrettyPrinter, the default printer, wraps every log call in a multi-line box:

┌─────────────────────────────────────────────
│ 12:32:14.123 │ [Auth] User signed in
└─────────────────────────────────────────────

Two issues with this in my pipeline:

  1. I run Flutter through a small shell filter (frun) that takes raw flutter run output, strips noisy prefixes, and re-colors what is left. My filter expected lines that look like [name] body. PrettyPrinter wrote three lines per call: a top border, the actual message, a bottom border. The filter saw box-drawing characters at the start of two of those three lines and either dropped them or printed them un-styled.
  2. PrettyPrinter writes ANSI colour escapes to the start of every line by default. Even the lines that should have matched my ^\[name\] regex did not, because they actually started with \x1B[38;5;...m before the [.

There is also a third issue that bit me on iOS specifically. NSLog truncates lines at roughly 1,024 bytes. PrettyPrinter happily writes a 2 KB JSON payload as a single line if you give it one, and iOS chops the tail. The whole reason I had picked package:logger was so it would chunk long messages for me. PrettyPrinter does not chunk. It wraps text inside the box at lineLength, which is a display concern, not a transport concern.

So the choice was: teach my shell filter to be smarter about boxes, or stop emitting boxes. I picked both, with the bulk of the work on the Dart side.

The Dart-side fix: a custom printer

I dropped PrettyPrinter and wrote a printer that emits exactly what my filter wants, pre-chunked to 900 bytes (a safe margin under iOS's ~1,024 ceiling).

The format I settled on:

[name|L] message body

Where L is one letter: V, D, I, W, E, or F. The shell filter sees that bracketed tag, pulls the level out, and colour-codes the line by severity. One log call, one line, one trip through.

Here is the printer:

import 'package:flutter/foundation.dart';
import 'package:logger/logger.dart';

const int _kLineByteLimit = 900;

class _AppLogPrinter extends LogPrinter {
  static const Map<Level, String> _levelLetter = {
    Level.trace: 'V',
    Level.debug: 'D',
    Level.info: 'I',
    Level.warning: 'W',
    Level.error: 'E',
    Level.fatal: 'F',
  };

  @override
  List<String> log(LogEvent event) {
    final letter = _levelLetter[event.level] ?? 'I';
    final raw = event.message?.toString() ?? '';
    String tag = letter;
    String body = raw;
    final m = RegExp(r'^\[([^\]]+)\]\s?(.*)$', dotAll: true).firstMatch(raw);
    if (m != null) {
      tag = '${m.group(1)}|$letter';
      body = m.group(2) ?? '';
    }
    final out = <String>[];
    out.addAll(_chunk('[$tag] $body', _kLineByteLimit));
    if (event.error != null) {
      out.addAll(_chunk('[$tag] error=${event.error}', _kLineByteLimit));
    }
    if (event.stackTrace != null) {
      for (final line in event.stackTrace.toString().split('\n')) {
        if (line.isEmpty) continue;
        out.addAll(_chunk('[$tag] $line', _kLineByteLimit));
      }
    }
    return out;
  }

  static Iterable<String> _chunk(String s, int n) sync* {
    if (s.length <= n) { yield s; return; }
    for (var i = 0; i < s.length; i += n) {
      final end = i + n > s.length ? s.length : i + n;
      yield s.substring(i, end);
    }
  }
}

final Logger _logger = Logger(
  level: kDebugMode ? Level.trace : Level.warning,
  printer: _AppLogPrinter(),
);

Three small but load-bearing details:

  • The printer expects the AppLog._fmt(name, message) helper to have wrapped the message as [name] body. It pulls the name out of those brackets and merges the level letter onto it, so the final tag becomes [name|L]. If there was no name, the tag is just [L]. Either way, the line starts with one bracketed token the shell can parse.
  • Chunking is at the byte budget, not at word boundaries. That is intentional. iOS's truncation is byte-driven, not character-driven, and I would rather have a few ugly mid-word splits than mysteriously lose half of a JSON dump.
  • Errors and stack traces get their own chunked output. Every line still starts with the same [name|L] prefix so the shell filter colours them consistently. That matters more than it sounds; without it, a stack trace renders as a wall of grey defaults and you have to mentally rebind it to the log line above.

The shell-side companion

The filter side took two upgrades to play well with the new printer. The full script is flutter_logs.sh, sourced from ~/.zshrc. The two functions worth showing are the ANSI stripper and the line formatter.

ANSI sneaks past regex matching. Strip it first:

_strip_ansi() {
  printf '%s' "$1" | sed $'s/\x1B\\[[0-9;]*[A-Za-z]//g'
}

That sed pattern handles SGR escapes (the long-form ESC [ ... letter colour codes). It runs on every line before any regex match, so loggers that paint their own colours stop fighting with the filter.

The [name|L] parser lives in the line formatter. Severity drives the colour:

_flutter_format_line() {
  local msg="$1"
  local pat_applog='^\[([A-Za-z0-9_]+)\|([VDIWEF])\][[:space:]]*(.*)'

  if [[ "$msg" =~ $pat_applog ]]; then
    local name="${BASH_REMATCH[1]}"
    local lvl="${BASH_REMATCH[2]}"
    local body="${BASH_REMATCH[3]}"
    local color
    case "$lvl" in
      W)   color="$_FL_WARN" ;;
      E|F) color="$_FL_ERR" ;;
      V|D) color="$_FL_DIM" ;;
      *)   color="$_FL_LOG" ;;
    esac
    echo -e "${_FL_LABEL}[${_FL_META}${name}${_FL_LABEL}]${_FL_RESET} ${color}${body}${_FL_RESET}"
    return
  fi
  # ... fallbacks for legacy [name] format and plain prints ...
}

Result: a warning lights up yellow, errors are red, debug lines dim grey, info orange. One glance at the terminal tells me whether the last few seconds were healthy or busy.

Bonus pain: zsh is not bash

Two zsh-specific gotchas cost me a real hour each.

local var=$(...) inside a while read loop emits a stray trace. On the second and later iterations, zsh prints var=value to stdout. Looks just like bash xtrace, except xtrace was off. The fix is silly: declare local var once before the loop, assign without local inside. After that change my filter stopped doubling its own output.

Regex captures live in a different array. Bash's [[ =~ ]] populates $BASH_REMATCH. zsh's puts captures in $match and uses $BASH_REMATCH[1] for the full match if you set bash_rematch, off-by-one from bash. The combination that actually matches bash is bash_rematch plus ksh_arrays. I scoped both inside the function:

_flutter_filter() {
  if [ -n "${ZSH_VERSION:-}" ]; then
    setopt localoptions bash_rematch ksh_arrays 2>/dev/null
    unsetopt xtrace 2>/dev/null
  fi
  # ... rest of the function ...
}

localoptions makes any further setopt/unsetopt calls scoped to this function, so the user's global zsh state is preserved when the function returns. If you maintain shell scripts that span both bash and zsh, both of these are worth keeping in your back pocket.

Muting the noise

Once the AppLog lines were showing, the next problem became visible: half of every screen was SDK chatter. PostHog narrating its event queue. Sentry warning about a misconfigured DSN. Native iOS layout-constraint complaints. The pub resolver dumping every package version on launch.

The filter handles this with an env-var-overridable noise regex:

local noise_re="${FRUN_NOISE:-^(flutter:[[:space:]]+)?\[(PostHog|Sentry)\]|^(flutter:[[:space:]]+)?Debug: Snapshot is the same|^Resolving dependencies\.\.\.|^Downloading packages\.\.\.|^Got dependencies!|^[0-9]+ packages? (have newer versions|is discontinued)|^Automatically signing iOS|^The following target\(s\) do not support|^<NSLayoutConstraint:|UIViewAlertForUnsatisfiableConstraints}"

while IFS= read -r raw_line; do
  line=$(_strip_ansi "$raw_line")
  if [[ "$show_all" != "true" && "$line" =~ $noise_re ]]; then
    continue
  fi
  # ... format and print otherwise ...
done

A few things I like about this shape:

  • One regex, alternated. Easy to add or remove patterns without restructuring the loop.
  • The flutter: prefix is tolerated (^(flutter:[[:space:]]+)?...) so it works for lines that arrived through flutter run's iOS forwarding and lines that did not.
  • Override is a single env var: FRUN_NOISE='^\[PostHog\]' frun to mute only PostHog, FRUN_NOISE='' frun to mute nothing. frun --all shows everything regardless.

I also added a separate block to skip pure box-drawing decoration from third-party loggers (the flutter_bkey_sdk SDK uses its own boxed format). That one is gated by FRUN_KEEP_BOXES=1 for when you actually want to read those.

Specifics matter, so here is the exact patch I had to apply to the noise regex when I noticed flutter: Debug: Snapshot is the same as the last one slipping through. The ^Debug: anchor never matched because the lines arrived with a flutter: prefix. The fix was to add ^(flutter:[[:space:]]+)? to the front of that pattern. If you build something like this, write a test that pipes a sample of real lines through your filter and counts what survives. Eye-balling a live terminal is how I missed it the first time.

What I would do differently

Three honest reflections.

I should have written the printer first. I burnt time tweaking the shell filter to handle PrettyPrinter's boxes when the right answer was to stop emitting boxes. Adapt the producer when you can. The consumer is harder to test.

I should have tested the filter offline. I spent a long time staring at running apps when a fifteen-line printf | _flutter_filter test would have surfaced the zsh quirks immediately. Treat shell filters like normal code: pipe inputs, assert outputs.

Logging conventions belong in CLAUDE.md. The whole reason my codebase was full of developer.log was that the old CLAUDE.md said so. The convention made sense for DevTools-first debugging. It does not for terminal-first. I now have an AppLog-only rule there with print and dart:developer explicitly banned outside app_log.dart, plus a note that the facade is the only allowed call site. The codebase will not drift back on me.

Takeaways

If you are doing Flutter development through a terminal-first agent (Claude Desktop, Cursor's terminal, plain tmux with an AI sidecar), here is the path I would point yours into:

  1. Move off dart:developer.log. It writes to the VM service stream, which your terminal does not subscribe to. Use a logger that goes through print() so the bytes actually flow to stdout.
  2. Pick a flat, parseable output format. Whatever your shell filter expects, make the Dart side emit exactly that. Single-line tagged output is friendlier than pretty boxes.
  3. Chunk before the OS does. iOS truncates at ~1,024 bytes per line. Split your own output below that, with each chunk carrying the same tag so the filter colours them together.
  4. Make the shell filter immune to ANSI. Strip it on the way in, recolor on the way out, do not let third-party colour codes break your regex.
  5. Mute the chatter by default. SDK debug streams are not the signal you came for. Filter them out, with one env var to override.

The whole change is one Dart file, one shell script, and a pubspec line. It took me about a day of poking at it. The payoff: every Flutter log now lights up in the same terminal as everything else my agent is doing, in the right colour, with no truncation. I have not opened DevTools in two weeks.

If you want the full versions of the files, or you are wiring up something similar and hit a snag, the contact section is the easiest place to reach me.

© 2026 Theophilus Rex Danquah. All rights reserved.