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:
parent
b2700b6e50
commit
744f775eb5
|
|
@ -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% \
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
2
kde
|
|
@ -1 +1 @@
|
|||
Subproject commit eed4a1089edfffc897c4f1d1f2bfa9abed236785
|
||||
Subproject commit 26076b0ae96ebadb7a34518bda3b22b83dc6b2aa
|
||||
Loading…
Reference in New Issue