fs: split chat into chat (filtered text) and chat.raw (full markup)

chat — strips [[[...]]] markers and source fences at the byte level.
Partial lines pass through immediately for true streaming. Terminals
and text editors use this.

chat.raw — full block-structured stream with markers and fences.
GUIs that parse blocks (KDE, web) use this.

The TUI no longer pipes through grep (which was line-buffering and
killing char-by-char streaming since Aug 4).

The stripMarkers state machine processes each chunk from the server:
- Complete lines starting with [[[ and ending with ]]] → dropped
- Fence lines (```...) → dropped (open toggles in-fence state)
- Partial lines (no trailing \n) → always emitted immediately
- Everything else → passed through
This commit is contained in:
Ollie Agent 2026-08-09 01:43:39 +02:00
parent b2700b6e50
commit 744f775eb5
4 changed files with 77 additions and 4 deletions

View File

@ -36,7 +36,7 @@ ROOT_ENTRIES="session elevate agents backends models help ctl eventwait complete
SESSION_FILES="env paused connected ctl name id elevate"
# Agent-level files (exist under session/{name}/agent/{name}/)
AGENT_FILES="prompt prompt.prev fifo.in fifo.out chat connection state statewait log offset plan cfg ctl cwd usage cost ctxsz models systemprompt context tail tools name id proc"
AGENT_FILES="prompt prompt.prev fifo.in fifo.out chat chat.raw connection state statewait log offset plan cfg ctl cwd usage cost ctxsz models systemprompt context tail tools name id proc"
is_root_path() {
local path="$1"
@ -523,7 +523,7 @@ cmd_tui() {
# Pane 0: chat (top, 80%) - show last 1000 lines of log, then tail chat
tmux -CC new-session -d -s "$tmux_session" -c "$HOME" \
-e "session=$s" -e "agent=$a"
tmux send-keys -t "$tmux_session" "o log -n 1000 | grep -v '\\[\\[\\[.*\\]\\]\\]'; o chatstream | grep -v '\\[\\[\\[.*\\]\\]\\]'" Enter
tmux send-keys -t "$tmux_session" "o log -n 1000; o chatstream" Enter
# Pane 1: prompt REPL (bottom, 20% height)
tmux split-window -t "$tmux_session" -v -l 20% \

View File

@ -1,6 +1,7 @@
package fs
import (
"bytes"
"context"
"encoding/json"
"fmt"
@ -493,12 +494,77 @@ func readAgentChat(ctx HandlerCtx) ([]byte, error) {
return data, nil
}
func readAgentChatText(ctx HandlerCtx) ([]byte, error) {
ctx.AgentLog.mu.RLock()
defer ctx.AgentLog.mu.RUnlock()
return stripMarkers(ctx.AgentLog.log), nil
}
func streamAgentChat(ctx HandlerCtx, cctx context.Context, base string) ([]byte, string, error) {
merged, cancel := mergeCtx(ctx, cctx)
defer cancel()
return streamChat(ctx.AgentLog, merged, base)
}
func streamAgentChatText(ctx HandlerCtx, cctx context.Context, base string) ([]byte, string, error) {
merged, cancel := mergeCtx(ctx, cctx)
defer cancel()
data, nextBase, err := streamChat(ctx.AgentLog, merged, base)
if err != nil || len(data) == 0 {
return data, nextBase, err
}
return stripMarkers(data), nextBase, nil
}
// stripMarkers removes [[[...]]] marker lines and source fence lines from raw
// chat data. Partial lines (no trailing \n) are always passed through — they
// are streaming content, never markers.
func stripMarkers(data []byte) []byte {
var out []byte
inFence := false
for len(data) > 0 {
// Find next newline
nl := bytes.IndexByte(data, '\n')
if nl < 0 {
// Partial line at end — always content, emit it
out = append(out, data...)
break
}
line := data[:nl+1] // includes \n
data = data[nl+1:]
// Drop [[[...]]] marker lines
trimmed := bytes.TrimRight(line, "\n")
if len(trimmed) >= 7 && bytes.HasPrefix(trimmed, []byte("[[[")) && bytes.HasSuffix(trimmed, []byte("]]]")) {
continue
}
// Drop fence open/close lines (``` with optional language suffix)
stripped := bytes.TrimSpace(trimmed)
if bytes.HasPrefix(stripped, []byte("```")) {
if inFence {
inFence = false
continue
}
// Opening fence — only drop if it's a source fence (tool output),
// not markdown fences in assistant prose. Tool fences follow
// immediately after a marker line, so if we just dropped a marker
// and this is the next line, it's a source fence.
// Simplification: drop all bare ``` lines (no content on the line).
// Keep fences that are part of assistant markdown (they have text before/after).
if len(stripped) == 3 || (len(stripped) > 3 && stripped[3] != '`') {
inFence = true
continue
}
}
if !inFence {
out = append(out, line...)
}
}
return out
}
func readAgentConnection(ctx HandlerCtx) ([]byte, error) {
if ctx.Session.ToolsConn() == nil {
return nil, fmt.Errorf("agent tool server unavailable")

View File

@ -230,7 +230,14 @@ FileNode("generate", 0666,
),
FileNode("chat", 0444,
Doc("Full conversation history. Supports streaming reads for real-time updates."),
Doc("Filtered text presentation of conversation. Strips markers and source fences for terminal display."),
Stat(statAgentChat),
Read(readAgentChatText),
Stream(streamAgentChatText),
),
FileNode("chat.raw", 0444,
Doc("Full conversation with block markers and fences. Used by GUIs."),
Stat(statAgentChat),
Read(readAgentChat),
Stream(streamAgentChat),

2
kde

@ -1 +1 @@
Subproject commit eed4a1089edfffc897c4f1d1f2bfa9abed236785
Subproject commit 26076b0ae96ebadb7a34518bda3b22b83dc6b2aa