Skip to content
Open
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
22 changes: 22 additions & 0 deletions NEEDS_YOU.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,28 @@ took the shape it did.

## Found while building, out of the item's scope

- [ ] **An exception in the frame pump leaves the row painted at the last width, under a
window that has already been resized.** Found on 2026-09-03 while photographing the mic
view, with a probe whose fake session was missing `busy`. `Pill._frame` runs
`_pump_talk()` *before* `_sync_dock()` and `_draw()`, so anything that raises in the
pump aborts the frame before the repaint. `Pill._tick` catches it and surfaces the
message — which makes the bubble visible, which calls `_sync_shell` from the panel side
and grows the window to 400×98 — and the canvas still holds the capsule the previous
frame drew. The result is a 90 px row parked under a 400 px panel with a gap between
them, which is exactly the "two boxes of different widths stacked in one window" failure
`_sync_shell`'s own comment describes. It then *stays* that way, because the next frame
raises at the same place and never reaches the repaint either.
**Why it is here and not fixed in the same breath:** the mic view made it visible rather
than caused it — any raise in `_pump_talk`, `_pump_events` or `pump_results` does this,
and has been able to since the pill and its panel became one window. The fix is a
judgement call about the frame loop's contract, not a line: either `_frame` puts the
sync-and-draw in a `finally` so a broken pump still repaints what it can, or `_tick`'s
handler forces one after surfacing the error. The first is better and is also the one
that could hide a pump that is failing every frame behind a row that looks fine, which
is the trade worth your call rather than mine.
**Reproduce:** give `Pill` a session whose `busy` raises, arm a `_ptt_wait`, and watch
the window grow while the row does not.

- [ ] **The furniture users saw is the *answer*, not the chrome — and stripping it is a
product call.** Item 61 re-measured codex-cli 0.145.0 and claude 2.1.218 through
`_invoke`'s own `Popen` shape, six calls over four prompt shapes, in this repo and in a
Expand Down
131 changes: 131 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,137 @@ numbered condition that reopens it. The items these decisions spec'd are archive
their evidence in [history/loop-rounds-1-3.md](history/loop-rounds-1-3.md). New
decisions append here when NEEDS_YOU.md closes them.

### 2026-09-03 — A failing CLI's reason comes from whichever stream it used

`_invoke` read **stderr alone** on a non-zero exit, on the stream discipline this module
documents and measures at the top: the answer on stdout, the banner and prompt echo and
token accounting on stderr. That discipline is real and it holds while a CLI is
*working*. It does not hold when one refuses.

Measured here, 2026-09-03, claude 2.1.248:

claude --safe-mode -p -> exit 1
stdout: "Not logged in · Please run /login"
stderr: ""

So an ask with a logged-out claude reached the card as `ask failed (claude exited 1: ;
then kiro-cli exited 1: Not logged in. Set the KIRO_API_KEY environment variable or run
kiro-cli login first.)` — the only CLI that explained itself was the *fallback*, and the
line the user could act on for the CLI they actually run was thrown away between the
colon and the semicolon. **A refusal that prints an empty reason is worse than a silent
one: it looks like it said something.** That is P2 failing in the one place it is hardest
to notice, because the note is there and merely says nothing.

`_why(err, out)` takes the first non-blank line of stderr, and stdout only when stderr
had none. **stderr keeps first place** for two reasons and not one: it is where a CLI
that separates its streams puts the error, and a CLI can exit non-zero having already
written a partial answer to stdout — repeating the head of that answer as the diagnosis
would be a fabrication, which is worse than the blank it replaces. Bounded to one line
and `_WHY_CHARS`, because some CLIs print a stack trace and a panel is not a terminal.

**Reopens if** a CLI is found that writes its real error to stdout *and* a partial answer
alongside it, which is the one shape this ordering cannot get right. The fix then is
per-CLI knowledge on `Cli`, not a cleverer heuristic here.

### 2026-09-03 — The mic view: a controls-free pill, and why it is a view and not a mode

Push-to-talk is a gesture with no decisions in it — hold, speak, release, and the words
are pasted — and every control on the pill row is a decision it never asks for. So the
row drops to what the gesture actually needs, in **two frames inside one 90 px box**
(`MIC_W`, against 400 for the narrowest panel): at rest the focused app's name and the
mic glyph, and while the chord is held the level bars and nothing else. Off by default,
one checkbutton under Settings, and offered only while the chord is the hold gesture.

**Two frames rather than one row that dims parts of itself**, because the question
changes with the gesture. Before the hold it is *where will these words go* — which is
the one mistake a hold can make that it cannot otherwise see — and during it there is
exactly one question, *is it hearing me*, to which a mic glyph and an app initial are
answers nobody is asking for mid-sentence. A resting meter is the same waste in the
other direction: a control's worth of pixels reporting a level nobody is producing.

**One width across both**, and the arithmetic made that free: the meter plus `PAD`
either side, and the name slot plus the glyph plus `PAD` either side, both come to 90.
This was drafted with two widths and an argument for why they were not the 205 -> 420
jump of 2026-08-09; the numbers made the argument unnecessary, which is the better
outcome. The press swaps what is drawn inside a box that does not move.

**The name, not an initial.** The first cut showed one character on the reasoning that
the slot only has to catch the wrong-window mistake. It does not catch it: `C` is Code,
Chrome, Claude and cmd. The row is small enough to say the word, so `MIC_NAME_W` is 50 —
six pixels wider than the full row's `APP_SLOT_W`, because here nothing competes for the
line — and `app_label` and `APP_NAME_CHARS` are the same rules the full row uses.

The frame is `Pill.mic_talking`: the chord hold (`_ptt_since`) or the pill's own
press-and-hold (`_press_talking`), and deliberately **not** `_ptt_wait`. That is the
decode still running after the key came up, and nothing is being captured during it — a
meter left up there is the same false "hearing you" `_flatten` exists to kill. The
release falls straight back to the resting frame and the words arrive by paste when they
arrive. For the same reason the waiting dots are drawn in neither frame: there is no
resting meter for them to stand in for, and the mic glyph already carries `accent`.

**It is a view, not a mode, and that is the load-bearing decision.** `flow/session.py` is
untouched: it emits exactly the events it emits without this, and `Pill._pump_events`
draws fewer of them — `draft` and `partial`, and nothing else. That is what makes "no
impact on the existing behaviour" structural rather than a promise, because there is no
branch in a session route to get wrong. If one is ever needed, the design is wrong and
not the file; `tests/test_mic.py` asserts both halves, against the source and against a
scripted drain whose session-side calls are identical either way.

**The paste is the existing paste-on-release.** Nothing here makes words arrive sooner.
Optimistic paste — paste the partial, reconcile with the final — stays rejected for the
reason already on this record: a paste cannot be taken back in a terminal.

**The hard part was the note.** With no panels there is nowhere for a refine's commentary,
an unreachable CLI or an unverified-CLI line to land, and swallowing one would make this
the only place in Flow where a refusal is silent (P2). Because this is a view, it can grow
back to the full pill for that moment and shrink again **with no state change**:
`Pill.mic_view` is a property that reads whether a surface is up, so the grow-back is
`Bubble.note` calling `surface` on a hidden bubble — one gate, only under the view — and
the shrink is that surface going away. Nothing is entered and nothing has to be unwound,
which is the difference a mode would have made and the argument for not building one.

**Placement is free here, and only here.** `PLACES` has two entries because the pill
anchors a stack that must fit a 400/480/580 px panel on whichever monitor the pointer is
on; this row anchors nothing, so it persists an (x, y) instead (`profile.mic_at`, set by
dragging the thing itself, saved on the release rather than per motion event). Re-clamped
against `_work_area` on the way out, because the answer is per monitor and the profile is
not: a position saved on a display that is no longer plugged in must land somewhere
visible.

**The sent card is the route that had to be found by using it.** A release ends in
`_pump_talk` calling `Pill._send`, and `_send` put a sent card on screen for words that
had *already* been pasted — so the panel opened on every single utterance, and it opened
through the one door that is not an event and that `_pump_events`' gate therefore never
saw. On the full row that card is the record of what was sent; under this view the record
is the words now sitting in the app whose name the row was showing a moment ago. Gated.
The failure branch is not: a paste that failed or could not be guaranteed still opens the
panel and still flashes, because it is the one thing here that cannot be read off the
other window.

**The grow-back needed a way back, and two doors needed closing.** Found by running the
app rather than the suite, which had asserted the shrink by calling `hide()` by hand:

- *A note that grew the panel back never left.* Nothing hides a surfaced note — on the
full row it lands on a panel that was already up and the next draft clears it, and here
there is no next draft. One line from a settings row left the pill 400 px wide for as
long as Flow ran; measured at twelve seconds and still up. `Bubble.tick_note` gives the
row back after `MIC_NOTE_SEC` (7 s, longer than the sent card's 4 because that is a
receipt for something the user just did and this is a line they did not ask for),
stamped only where this view surfaced it, and dropped the moment the panel has a second
reason to be there.
- *Progress opened a panel.* `tick_activity` surfaces the bubble for a wait with nothing
else on screen, which is right on the full row and wrong here: the model load opened a
400 px panel on every launch and held it for the eight seconds the load takes — 90x34 at
0.76 s, 400x98 at 1.07 s, back at 9.52 s from a cold start. The grow-back is for a
*refusal*, and an activity is the opposite: the mic glyph already carries that state in
its colour and there is nothing to act on. It declines to *open* one; a wait already up
is left alone, and a note arriving during a load still surfaces.

**Reopens if** the view needs a fourth thing on the row — the honest answer then is that
the gesture has grown a decision and the full row is the surface for it — or if the
grow-back is reported as jarring rather than as the panel doing its job, in which case
what changes is the animation, not the state model.

### 2026-09-03 — A Windows-only module must still be *importable* everywhere

The macOS CI leg had never been green, and one line was the whole reason. `flow/tray.py`
Expand Down
36 changes: 36 additions & 0 deletions flow/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,24 @@ def _text_list(value, cap: int) -> list[str]:
return [t for t in (_text(v) for v in value) if t][:cap]


def _pair(value) -> list[int] | None:
"""A pair of whole numbers, or None. Never raises, for anything.

The shape only — whether the point is *on a screen* is `ui._mic_spot`'s question and
`Pill._placed`'s clamp, for the reason `panel` and `place` are judged there. What is
settled here is what `save` may write, and the invariant behind that is the suite's:
no shape of any field, however hand-edited, may raise on the way out. `inf` found
this — a float is not iterable and `list()` on one is a `TypeError` thrown while
writing somebody's profile.
"""
if not isinstance(value, (list, tuple)) or len(value) != 2:
return None
try:
return [int(value[0]), int(value[1])]
except (TypeError, ValueError, OverflowError):
return None


def _text_set(value) -> set[str]:
if not isinstance(value, (list, tuple, set)):
return set()
Expand Down Expand Up @@ -403,6 +421,17 @@ def __init__(self, path: Path | str | None = None) -> None:
#: of whichever monitor the pointer is on — and `"corner"` is the bottom-right
#: Flow used to use, kept for anybody who wants it back rather than removed.
self.place: str = PLACE_DEFAULT
#: Whether the pill draws the mic view: the focused app's initial, the mic and
#: the level bars, with no chips, marks or panel. A view rather than a mode —
#: nothing in this package or in `flow/session.py` reads it, and `flow/ui.py`
#: draws fewer of the events the session emits either way.
self.mic: bool = False
#: Where that view sits, as an (x, y), or None for wherever `place` would put
#: it. Its own field and not a third `PLACES` name because the view anchors no
#: panel: the two placements exist so a 400-580 px stack is guaranteed to fit,
#: and a 128 px row fits anywhere. Judged in `flow/ui.py` (`_mic_spot`), which
#: is also where it is re-clamped against the monitor it is opened on.
self.mic_at: tuple[int, int] | None = None
#: How the chord behaves. Both gestures ship because neither replaces the other:
#: a hold is better for a sentence, a toggle is the only one that survives a
#: paragraph or a pair of hands that cannot hold two keys down. Judged in
Expand Down Expand Up @@ -497,6 +526,8 @@ def take(key, validate, default=None, stored=None):
self.apps = take("apps", lambda v, _d: _apps(v), {})
self.panel = take("panel", _text, PANEL_DEFAULT)
self.place = take("place", _text, PLACE_DEFAULT)
self.mic = take("mic", lambda v, _d: bool(v), False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the mic setting as a real boolean

If a profile is hand-edited or corrupted to contain "mic": "false" (or any other nonempty non-boolean value), bool(v) loads it as True without recording a fault, so Flow unexpectedly starts in the controls-free view. The existing boolean fields deliberately use _flag to avoid exactly this truthiness conversion; this field should use the same strict validation.

Useful? React with 👍 / 👎.

self.mic_at = take("mic_at", lambda v, _d: _pair(v), None)
self.gesture = take("gesture", _text, GESTURE_DEFAULT)
self.cli_model = take("cli_model", _text, "")
self.cli_effort = take("cli_effort", _text, EFFORT_DEFAULT)
Expand Down Expand Up @@ -542,6 +573,11 @@ def save(self) -> bool:
"apps": dict(self.apps),
"panel": self.panel,
"place": self.place,
"mic": self.mic,
# Through `_pair` on the way out as well as in, because this one is written
# by the UI between loads — `Pill._remember_mic_at` assigns the tuple a drag
# ended at — so `save` is not only re-emitting what `load` already judged.
"mic_at": _pair(self.mic_at),
"gesture": self.gesture,
"cli_model": self.cli_model,
"cli_effort": self.cli_effort,
Expand Down
42 changes: 40 additions & 2 deletions flow/refine.py
Original file line number Diff line number Diff line change
Expand Up @@ -834,11 +834,49 @@ def _invoke(
break

if proc.returncode != 0:
first = (err or "").strip().splitlines()
return None, f"{cli.name} exited {proc.returncode}: {first[0] if first else ''}"
return None, f"{cli.name} exited {proc.returncode}: {_why(err, out)}"
return out, ""


#: How much of a failing CLI's own words to repeat. One line, and short enough to sit in
#: a panel beside the name of the CLI that said it — the whole of it is on stderr or
#: stdout for anyone who runs the command themselves.
_WHY_CHARS = 160


def _why(err: str | None, out: str | None) -> str:
"""Why a CLI exited non-zero, in its own words: stderr first, then stdout.

**stdout is not a fallback for tidiness, it is where claude puts it.** This read
stderr alone, on the stream discipline this module documents at the top — the answer
on stdout, the banner and the errors on stderr — and that discipline holds while a
CLI is *working*. It does not hold when one refuses. Measured here on 2026-09-03:

claude --safe-mode -p -> exit 1, stdout "Not logged in · Please run /login",
stderr empty

So the note read `ask failed (claude exited 1: ; then kiro-cli exited 1: Not logged
in...)` — the one CLI that explained itself was the fallback, and the actionable line
for the CLI the user actually runs was thrown away between the colon and the
semicolon. A refusal that prints an empty reason is worse than a silent one: it looks
like it said something.

stderr keeps first place because that is where a CLI that separates its streams puts
the error, and because a CLI can exit non-zero having already written a partial
answer to stdout — repeating the start of that answer as the reason would be a
fabricated diagnosis. stdout is read only when stderr had nothing to say.

Bounded and single-line so the note stays a note: some CLIs print a stack trace, and
a panel is not a terminal.
"""
for stream in (err, out):
for line in (stream or "").strip().splitlines():
line = line.strip()
if line:
return line[:_WHY_CHARS - 1] + "…" if len(line) > _WHY_CHARS else line
return ""


def tail_sent(text: str) -> int:
"""How much of `text` a CLI call will actually see, in characters.

Expand Down
Loading
Loading