Skip to content

Commit 4f1e200

Browse files
maximelbclaude
andcommitted
fix: clean up ai chat/attach live output
Default pretty output was a firehose: empty assistant/user/result frames, plumbing system subtypes (init_received, hook_started, autoinit_loaded, ...), raw session_status JSON, full ISO timestamps, and a stray ">" caret that landed on the next streamed line. - Filter noisy message types (session_status, usage_delta) and system subtypes (bridge + Claude SDK plumbing) by default. - Skip assistant frames with only tool_use blocks, user frames that wrap a tool_result, and result pings without a summary. - Shorten timestamps to HH:MM:SS; add --verbose/-v to both `ai session attach` and `ai chat` to restore the firehose. - Fix user-content extraction: server sends {"content": [blocks]}, not {"text": ...}, so the "user:" line was always blank. - Strip leading/trailing whitespace and whitespace-only blocks in assistant text so Claude's "\n\n..." openers stop rendering as empty indented lines. - Drop the eager "> " input caret — under concurrent streaming it interleaved with output; the terminal echoes keystrokes anyway. - Share the noise set with `ai session history` / `ai chats history`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f9fa8c8 commit 4f1e200

3 files changed

Lines changed: 414 additions & 39 deletions

File tree

limacharlie/commands/_ai_attach.py

Lines changed: 160 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,18 @@ def run_attach(sdk: AISDK, session_id: str, *,
4242
interactive: bool,
4343
show_history: bool,
4444
raw: bool,
45+
verbose: bool = False,
4546
initial_prompt: str | None = None) -> int:
4647
"""Run the attach session loop.
4748
4849
``initial_prompt`` is sent as the first message after the WebSocket
4950
connects. Used by ``ai chat`` to seed a new session; attach leaves
5051
it ``None``.
5152
53+
``verbose`` disables the default noise filter (plumbing system
54+
subtypes, empty assistant/user/result frames, session_status pings)
55+
and also prints full ISO timestamps instead of HH:MM:SS.
56+
5257
Returns the process exit code: 0 on clean disconnect, 1 on error.
5358
"""
5459
try:
@@ -58,6 +63,7 @@ def run_attach(sdk: AISDK, session_id: str, *,
5863
interactive=interactive,
5964
show_history=show_history,
6065
raw=raw,
66+
verbose=verbose,
6167
initial_prompt=initial_prompt,
6268
))
6369
except KeyboardInterrupt:
@@ -73,6 +79,7 @@ async def _attach(sdk: AISDK, session_id: str, *,
7379
interactive: bool,
7480
show_history: bool,
7581
raw: bool,
82+
verbose: bool = False,
7683
initial_prompt: str | None = None) -> int:
7784
# Choose the endpoint. If the user didn't force --read-only and the
7885
# owner endpoint refuses us with 403, fall back transparently.
@@ -112,6 +119,7 @@ async def _attach(sdk: AISDK, session_id: str, *,
112119
interactive=interactive and not read_only,
113120
show_history=show_history,
114121
raw=raw,
122+
verbose=verbose,
115123
))
116124
writer: asyncio.Task | None = None
117125
if interactive and not read_only:
@@ -140,7 +148,8 @@ async def _attach(sdk: AISDK, session_id: str, *,
140148
async def _reader_loop(att: SessionAttachment, *,
141149
interactive: bool,
142150
show_history: bool,
143-
raw: bool) -> None:
151+
raw: bool,
152+
verbose: bool = False) -> None:
144153
async for msg in att.messages():
145154
if raw:
146155
click.echo(json.dumps(msg))
@@ -156,7 +165,9 @@ async def _reader_loop(att: SessionAttachment, *,
156165
messages = payload.get("messages") or []
157166
_print_banner(f"History ({len(messages)} messages)")
158167
for m in messages:
159-
_render_message(m)
168+
if _should_skip_message(m, verbose):
169+
continue
170+
_render_message(m, verbose=verbose)
160171
_print_banner("Live stream")
161172
continue
162173

@@ -168,7 +179,8 @@ async def _reader_loop(att: SessionAttachment, *,
168179
await _handle_question(att, payload)
169180
continue
170181

171-
_render_message(msg)
182+
if not _should_skip_message(msg, verbose):
183+
_render_message(msg, verbose=verbose)
172184

173185
if mtype == MSG_SESSION_END:
174186
return
@@ -214,14 +226,16 @@ async def _input_loop(att: SessionAttachment) -> None:
214226
def _read_line_or_eof() -> str | None:
215227
"""Blocking stdin read returning ``None`` on EOF.
216228
217-
Printed to stderr so it doesn't interleave with stdout messages
218-
when the terminal is being driven by another task.
229+
We intentionally do NOT print a prompt character before reading.
230+
The reader task streams server messages to the terminal
231+
concurrently, so any caret we print eagerly ends up on the same
232+
line as the next incoming frame (``> [ts] assistant:``) or gets
233+
overwritten — the result was inconsistent noise rather than a
234+
helpful cue. The terminal already echoes keystrokes, so users
235+
can see what they're typing without a visible prompt; getting a
236+
true persistent prompt would need a line-editor library such as
237+
``prompt_toolkit``.
219238
"""
220-
try:
221-
sys.stderr.write("> ")
222-
sys.stderr.flush()
223-
except Exception:
224-
pass
225239
line = sys.stdin.readline()
226240
if not line:
227241
return None
@@ -309,11 +323,113 @@ async def _handle_question(att: SessionAttachment, payload: dict) -> None:
309323
# Rendering
310324
# ---------------------------------------------------------------------------
311325

312-
def _render_message(msg: dict[str, Any]) -> None:
326+
# System subtypes that are internal plumbing — noisy in the default
327+
# live stream. The list matches what the ai-sessions sdk_bridge and
328+
# the Claude Code SDK emit as "this is what I just configured"
329+
# housekeeping. ``ai.py`` also reuses this set for history filtering.
330+
_NOISY_SYSTEM_SUBTYPES: frozenset[str] = frozenset({
331+
# ai-sessions bridge plumbing
332+
"credential_diagnostics",
333+
"init_received",
334+
"claude_md_loaded",
335+
"claude_md_error",
336+
"user_mcp_servers_loaded",
337+
"mcp_config_debug",
338+
"mcp_servers_loaded",
339+
"mcp_servers_set",
340+
"oid_added_to_system_prompt",
341+
"ttl_added_to_system_prompt",
342+
"unknown_plugin",
343+
"plugins_resolved",
344+
"autoinit_loaded",
345+
"autoinit_error",
346+
"resuming_sdk_session",
347+
"model_set",
348+
"max_turns_set",
349+
"max_budget_set",
350+
"task_budget_set",
351+
"one_shot_mode_set",
352+
"permission_mode_set",
353+
"tools_configured",
354+
"system_prompt_set",
355+
"sdk_session_id",
356+
"session_patterns_loaded",
357+
# Claude SDK passthrough noise
358+
"hook_started",
359+
"hook_response",
360+
"hook_matched",
361+
})
362+
363+
# Top-level message types treated as plumbing (not user-facing).
364+
_NOISY_TYPES: frozenset[str] = frozenset({
365+
"session_status",
366+
"usage_delta",
367+
"sdk_session_id",
368+
})
369+
370+
371+
def _should_skip_message(msg: dict[str, Any], verbose: bool) -> bool:
372+
"""Return True when ``msg`` is plumbing noise we hide by default.
373+
374+
``verbose=True`` disables every filter so the user can see the raw
375+
firehose. Skips:
376+
377+
* Known plumbing message types (session_status, usage_delta, ...).
378+
* ``system`` messages whose subtype is in the noisy set.
379+
* ``assistant`` frames with no extractable text (tool-use-only
380+
turns — the accompanying ``tool_use`` message already renders).
381+
* ``user`` frames with no extractable text (tool_result wrappers
382+
— the accompanying ``tool_result`` message already renders).
383+
* ``result`` frames without a human-readable summary/message.
384+
"""
385+
if verbose:
386+
return False
387+
313388
mtype = msg.get("type")
314389
payload = msg.get("payload") or {}
315-
ts = msg.get("timestamp")
316-
prefix = f"[{ts}] " if ts else ""
390+
391+
if mtype in _NOISY_TYPES:
392+
return True
393+
394+
if mtype == MSG_SYSTEM:
395+
subtype = payload.get("subtype", "")
396+
return subtype in _NOISY_SYSTEM_SUBTYPES
397+
398+
if mtype == MSG_ASSISTANT:
399+
return not _extract_assistant_text(payload).strip()
400+
401+
if mtype == MSG_USER:
402+
return not _extract_user_text(payload).strip()
403+
404+
if mtype == MSG_RESULT:
405+
summary = payload.get("summary") or payload.get("message") or ""
406+
return not str(summary).strip()
407+
408+
return False
409+
410+
411+
def _format_timestamp(ts: str | None, verbose: bool) -> str:
412+
"""Render the incoming ISO timestamp as a prefix.
413+
414+
Default: ``[HH:MM:SS]`` — enough to eyeball ordering without
415+
swamping the line. ``verbose=True`` preserves the full string so
416+
precise correlation with server logs remains possible.
417+
"""
418+
if not ts:
419+
return ""
420+
if verbose:
421+
return f"[{ts}] "
422+
# ISO-8601: "YYYY-MM-DDTHH:MM:SS[.frac][+-ZZ:ZZ|Z]". Grab HH:MM:SS.
423+
if "T" in ts:
424+
time_part = ts.split("T", 1)[1]
425+
return f"[{time_part[:8]}] "
426+
return f"[{ts}] "
427+
428+
429+
def _render_message(msg: dict[str, Any], *, verbose: bool = False) -> None:
430+
mtype = msg.get("type")
431+
payload = msg.get("payload") or {}
432+
prefix = _format_timestamp(msg.get("timestamp"), verbose)
317433

318434
if mtype == MSG_ASSISTANT:
319435
text = _extract_assistant_text(payload)
@@ -323,7 +439,7 @@ def _render_message(msg: dict[str, Any]) -> None:
323439
return
324440

325441
if mtype == MSG_USER:
326-
text = payload.get("text", "")
442+
text = _extract_user_text(payload)
327443
click.echo(click.style(f"{prefix}user:", fg="green", bold=True))
328444
if text:
329445
click.echo(_indent(text))
@@ -405,19 +521,45 @@ def _render_message(msg: dict[str, Any]) -> None:
405521

406522

407523
def _extract_assistant_text(payload: dict) -> str:
524+
"""Collapse an assistant/user content blocks array into text.
525+
526+
Returns the concatenation of ``text`` blocks; non-text blocks
527+
(tool_use, tool_result, thinking) contribute nothing — a frame
528+
that carries only those yields an empty string, which the noise
529+
filter uses to drop the redundant "assistant:" header.
530+
531+
Whitespace-only blocks are dropped and leading/trailing whitespace
532+
is stripped from the final result: Claude's first reply of a turn
533+
often opens with a couple of ``\\n`` characters that otherwise
534+
render as blank indented lines under the ``assistant:`` header.
535+
"""
408536
content = payload.get("content")
409537
if isinstance(content, str):
410-
return content
538+
return content.strip()
411539
if isinstance(content, list):
412540
parts: list[str] = []
413541
for block in content:
414542
if isinstance(block, dict):
415543
if block.get("type") == "text":
416544
parts.append(block.get("text", ""))
417-
elif "text" in block:
545+
elif "text" in block and block.get("type") not in {"tool_use", "tool_result"}:
418546
parts.append(str(block["text"]))
419-
return "\n".join(p for p in parts if p)
420-
return str(content or "")
547+
return "\n".join(p for p in parts if p.strip()).strip()
548+
return str(content or "").strip()
549+
550+
551+
def _extract_user_text(payload: dict) -> str:
552+
"""Pull the human-readable text from a ``user`` payload.
553+
554+
The server wraps prompt turns as ``{"content": [blocks...]}`` (same
555+
shape as assistant messages), but older tests and a few code paths
556+
still pass ``{"text": "..."}``; accept both so nothing regresses.
557+
Tool-result wrappers (no text blocks) collapse to ``""``, which
558+
the noise filter uses to drop them.
559+
"""
560+
if "text" in payload and "content" not in payload:
561+
return str(payload.get("text") or "")
562+
return _extract_assistant_text(payload)
421563

422564

423565
# ---------------------------------------------------------------------------

limacharlie/commands/ai.py

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -589,31 +589,20 @@ def session_terminate(ctx, session_id) -> None:
589589
"""
590590
register_explain("ai.session.history", _EXPLAIN_SESSION_HISTORY)
591591

592-
# System subtypes that are internal plumbing, not useful conversation.
593-
_SYSTEM_INIT_SUBTYPES = frozenset({
594-
"credential_diagnostics",
595-
"init_received",
596-
"claude_md_loaded",
597-
"mcp_config_debug",
598-
"mcp_servers_set",
599-
"model_set",
600-
"max_turns_set",
601-
"max_budget_set",
602-
"oid_added_to_system_prompt",
603-
"permission_mode_set",
604-
"tools_configured",
605-
"system_prompt_set",
606-
"sdk_session_id",
607-
})
592+
def _filter_history(messages: list[dict]) -> list[dict]:
593+
"""Remove internal system init messages from history.
608594
595+
Reuses the single source-of-truth noise set defined alongside the
596+
live-stream renderer in ``_ai_attach`` so both surfaces hide the
597+
same plumbing subtypes.
598+
"""
599+
from ._ai_attach import _NOISY_SYSTEM_SUBTYPES
609600

610-
def _filter_history(messages: list[dict]) -> list[dict]:
611-
"""Remove internal system init messages from history."""
612601
filtered = []
613602
for m in messages:
614603
if m.get("type") == "system":
615604
payload = m.get("payload", {})
616-
if isinstance(payload, dict) and payload.get("subtype") in _SYSTEM_INIT_SUBTYPES:
605+
if isinstance(payload, dict) and payload.get("subtype") in _NOISY_SYSTEM_SUBTYPES:
617606
continue
618607
filtered.append(m)
619608
return filtered
@@ -684,8 +673,10 @@ def session_history(ctx, session_id, raw) -> None:
684673
help="Don't render the initial history block on connect.")
685674
@click.option("--raw", is_flag=True, default=False,
686675
help="Print raw JSON messages instead of pretty formatting.")
676+
@click.option("--verbose", "-v", is_flag=True, default=False,
677+
help="Show plumbing system/status messages and full ISO timestamps.")
687678
@pass_context
688-
def session_attach(ctx, session_id, read_only, interactive, no_history, raw) -> None:
679+
def session_attach(ctx, session_id, read_only, interactive, no_history, raw, verbose) -> None:
689680
from ._ai_attach import run_attach
690681

691682
org = _get_org(ctx)
@@ -696,6 +687,7 @@ def session_attach(ctx, session_id, read_only, interactive, no_history, raw) ->
696687
interactive=interactive,
697688
show_history=not no_history,
698689
raw=raw,
690+
verbose=verbose,
699691
)
700692
ctx.exit(exit_code)
701693

@@ -959,10 +951,12 @@ def claude_auth_logout(ctx) -> None:
959951
help="Repeatable. Plugin names to enable.")
960952
@click.option("--idempotent-key", default=None,
961953
help="Deduplication key for session creation.")
954+
@click.option("--verbose", "-v", is_flag=True, default=False,
955+
help="Show plumbing system/status messages and full ISO timestamps.")
962956
@pass_context
963957
def chat(ctx, prompt, name, model, max_turns, max_budget_usd,
964958
task_budget_tokens, permission_mode,
965-
allowed_tools, denied_tools, plugins, idempotent_key) -> None:
959+
allowed_tools, denied_tools, plugins, idempotent_key, verbose) -> None:
966960
from ._ai_attach import run_attach
967961

968962
org = _get_org(ctx)
@@ -1020,6 +1014,7 @@ def chat(ctx, prompt, name, model, max_turns, max_budget_usd,
10201014
interactive=True,
10211015
show_history=False,
10221016
raw=False,
1017+
verbose=verbose,
10231018
initial_prompt=initial_prompt,
10241019
)
10251020
ctx.exit(exit_code)

0 commit comments

Comments
 (0)