@@ -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, *,
140148async 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:
214226def _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
407523def _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# ---------------------------------------------------------------------------
0 commit comments