]> git.giacomolli.uk - onechat/commitdiff
Show styled replies only on terminal output
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 02:38:19 +0000 (23:38 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Tue, 10 Nov 2026 02:52:52 +0000 (23:52 -0300)
Replies and work reports use the terminal width for Markdown rendering.
Other event kinds and output to files or pipes keep their text. Each
event reads the width again, with 80 columns when a terminal reports
zero. Tests use isolated terminal descriptors and fake events, including
single-column and very narrow table headers.

README.rst
terminal.go
terminal_markdown.go
terminal_markdown_test.go
terminal_test.go

index 18af25626790ed7c2defd843f576c503b4fa3130..23462b43ecaa2167feed93a769d1ee5de5c7be84 100644 (file)
@@ -83,14 +83,16 @@ listens on the Unix socket ``onechat.sock`` there. In the client, each
 line is a message to the agent, the line ``/stop`` interrupts the
 running turn, the line ``/stop NAME`` stops the subagent NAME, and
 Ctrl-D ends the client. The client shows the replies, the tool calls,
-and the first lines of each tool result as they happen. It also shows
-each message that the user sends from another client, with the client as
-a mark, such as ``[telegram] text``. When a subagent starts, the client
-shows ``[NAME] started``, and it shows the whole report of the subagent,
-which starts with ``[NAME]``, but not the tool calls of the subagent.
-The daemon is the only process that uses the token file while it runs,
-so ``onechat login`` and ``onechat probe`` refuse to start until it
-stops.
+and the first lines of each tool result as they happen. Replies and
+subagent reports render Markdown with styles and wrapped tables only
+when the output is a terminal, and stay unchanged in a pipe or file. It
+also shows each message that the user sends from another client, with
+the client as a mark, such as ``[telegram] text``. When a subagent
+starts, the client shows ``[NAME] started``, and it shows the whole
+report of the subagent, which starts with ``[NAME]``, but not the tool
+calls of the subagent. The daemon is the only process that uses the
+token file while it runs, so ``onechat login`` and ``onechat probe``
+refuse to start until it stops.
 
 The agent runs commands with bash as the user in its working directory,
 with no sandbox. It has the rights of the user, also on the token file.
index 4f1c37836a6685c9bb9d704bfa55cf0b4985759e..203cb5e64adb2d6a8054f71e2b0431536e5c1331 100644 (file)
@@ -10,6 +10,8 @@ import (
        "net"
        "path/filepath"
        "strings"
+
+       "golang.org/x/sys/unix"
 )
 
 // terminalResultLines is the most lines of a tool result that the client shows.
@@ -131,7 +133,8 @@ func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
 }
 
 // terminalShowEvents writes each event from the daemon to out until the
-// connection closes.
+// connection closes. Only replies and work messages use Markdown styles, and
+// only when out is a terminal.
 func terminalShowEvents(conn net.Conn, out io.Writer) error {
        scanner := bufio.NewScanner(conn)
        scanner.Buffer(make([]byte, 64*1024), socketRequestBytes)
@@ -149,6 +152,11 @@ func terminalShowEvents(conn net.Conn, out io.Writer) error {
                        continue
                }
 
+               columns := terminalColumns(out)
+               if columns > 0 && (event.Kind == "reply" || event.Kind == "work") {
+                       text = strings.Join(terminalMarkdown(text, columns), "\n")
+               }
+
                _, err = io.WriteString(out, text+"\n")
                if err != nil {
                        return fmt.Errorf("write terminal event: %w", err)
@@ -210,6 +218,26 @@ func terminalEventText(event Event) string {
        }
 }
 
+// terminalColumns returns the current terminal width, or zero for other output.
+// A terminal that reports no columns uses 80.
+func terminalColumns(out io.Writer) int {
+       file, ok := out.(interface{ Fd() uintptr })
+       if !ok {
+               return 0
+       }
+
+       size, err := unix.IoctlGetWinsize(int(file.Fd()), unix.TIOCGWINSZ)
+       if err != nil {
+               return 0
+       }
+
+       if size.Col == 0 {
+               return 80
+       }
+
+       return int(size.Col)
+}
+
 // terminalFirstLines returns the first count lines of text, with the number of
 // the lines left out.
 func terminalFirstLines(text string, count int) string {
index a13a4e949a2ea3cb2b25681be298617d411dab5f..73ae7d572d900a8c41eb60d7ffcc03ec3d6ae641 100644 (file)
@@ -46,7 +46,7 @@ func terminalMarkdown(text string, columns int) []string {
                        index = end
                case strings.TrimSpace(line) == "---":
                        out = append(out, "\x1b[2m"+strings.Repeat("-", columns)+"\x1b[0m")
-               case index+1 < len(lines) && terminalMarkdownSeparator(lines[index+1]) && len(terminalMarkdownCells(line)) > 1:
+               case index+1 < len(lines) && terminalMarkdownSeparator(lines[index+1]):
                        rows := [][]string{terminalMarkdownCells(line)}
                        index += 2
                        for index < len(lines) && strings.Contains(lines[index], "|") && strings.TrimSpace(lines[index]) != "" {
@@ -135,7 +135,7 @@ func terminalMarkdownPlain(text string, style int) []TerminalMarkdownGlyph {
 // terminalMarkdownSeparator tells if a line is a Markdown table separator.
 func terminalMarkdownSeparator(line string) bool {
        cells := terminalMarkdownCells(line)
-       if len(cells) < 2 {
+       if !strings.Contains(line, "|") {
                return false
        }
 
@@ -219,10 +219,14 @@ func terminalMarkdownTable(rows [][]string, columns int) []string {
        if required > columns {
                var out []string
 
-               for _, row := range cells {
-                       for _, cell := range row {
+               for row, values := range cells {
+                       for _, cell := range values {
                                out = append(out, terminalMarkdownLines(cell, columns, "", false)...)
                        }
+
+                       if row == 0 {
+                               out = append(out, "\x1b[2m"+strings.Repeat("-", columns)+"\x1b[0m")
+                       }
                }
 
                return out
index 6344d8dba054e490a3659525cbe0ee6fa51bdeea..adf0fa483f9fb3f5f30174abeca371d4d047504f 100644 (file)
@@ -183,6 +183,14 @@ func TestTerminalMarkdownSmallTablesKeepNaturalWidths(t *testing.T) {
        }, lines)
 }
 
+func TestTerminalMarkdownSingleColumnTablesKeepTheirHeaderRule(t *testing.T) {
+       require.Equal(t, []string{
+               "\x1b[1mName\x1b[0m",
+               "\x1b[2m----\x1b[0m",
+               "One ",
+       }, terminalMarkdown("| Name |\n| --- |\n| One |", 80))
+}
+
 func TestTerminalMarkdownSingleCharacterColumnsStayNarrow(t *testing.T) {
        require.Equal(t, []string{
                "\x1b[1mA\x1b[0m | \x1b[1mB\x1b[0m",
@@ -229,7 +237,7 @@ func TestTerminalMarkdownTablesStackWhenColumnsCannotFit(t *testing.T) {
                text.WriteString(plain)
        }
 
-       require.Equal(t, "FirstSecondalphabeta", text.String())
+       require.Equal(t, "FirstSecond----alphabeta", text.String())
 }
 
 func TestTerminalMarkdownWidthDefaultsToEighty(t *testing.T) {
index 62ffb040abe37518915d7f15560e001cfe51fa17..47791efae5cee4f791c97a23e5d01531eaee2c8b 100644 (file)
@@ -1,13 +1,16 @@
 package main
 
 import (
+       "encoding/json"
        "io"
        "net"
+       "os"
        "path/filepath"
        "strings"
        "testing"
 
        "github.com/stretchr/testify/require"
+       "golang.org/x/sys/unix"
 )
 
 func TestClientEndsWhenTheDaemonEnds(t *testing.T) {
@@ -81,3 +84,145 @@ func TestEventText(t *testing.T) {
                })
        }
 }
+
+func TestTerminalEventsKeepMarkdownInNonterminalOutput(t *testing.T) {
+       var out strings.Builder
+
+       text := "# Heading\n\n**bold** and `code`\nA | B\n--- | ---\nx | y"
+       terminalTestShowEvents(t, &out, Event{
+               Kind: "reply",
+               Text: text,
+       }, Event{
+               Kind: "work",
+               Text: "[BlueBird]\n" + text,
+       })
+       require.Equal(t, text+"\n[BlueBird]\n"+text+"\n", out.String())
+}
+
+func TestTerminalFileOutputKeepsMarkdown(t *testing.T) {
+       out, err := os.CreateTemp(t.TempDir(), "output")
+       require.NoError(t, err)
+
+       defer func() {
+               require.NoError(t, out.Close())
+       }()
+
+       text := "**plain**\n---\n`code`"
+       terminalTestShowEvents(t, out, Event{
+               Kind: "reply",
+               Text: text,
+       })
+       require.Zero(t, terminalColumns(out))
+
+       contents, err := os.ReadFile(out.Name())
+       require.NoError(t, err)
+       require.Equal(t, text+"\n", string(contents))
+}
+
+func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) {
+       fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
+       require.NoError(t, err)
+
+       defer func() {
+               require.NoError(t, unix.Close(fd))
+       }()
+
+       require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 12}))
+
+       out := TerminalTestOutput{fd: fd}
+       require.Equal(t, 12, terminalColumns(&out))
+       terminalTestShowEvents(t, &out,
+               Event{
+                       Kind: "reply",
+                       Text: "**first next last**",
+               },
+               Event{
+                       Kind: "work",
+                       Text: "[BlueBird]\n# Report",
+               },
+               Event{
+                       Kind: "tool",
+                       Text: "**raw tool**",
+               },
+               Event{
+                       Kind: "echo",
+                       Text: "**raw result**",
+               },
+               Event{
+                       Kind: "error",
+                       Text: "**raw error**",
+               },
+               Event{
+                       Kind: "notice",
+                       Text: "**raw notice**",
+               },
+               Event{
+                       Kind:   "user",
+                       Text:   "**raw input**",
+                       Source: "telegram",
+               },
+               Event{
+                       Kind: "ready",
+                       Text: "**raw agent**",
+               },
+               Event{
+                       Kind: "subagent",
+                       Text: "BlueBird",
+               },
+               Event{Kind: "busy"},
+               Event{Kind: "idle"},
+       )
+       require.Equal(t, "\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+
+               "[tool] **raw tool**\n[result] **raw result**\n[error] **raw error**\n**raw notice**\n[telegram] **raw input**\n"+
+               "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n", out.String())
+}
+
+func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) {
+       fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0)
+       require.NoError(t, err)
+
+       defer func() {
+               require.NoError(t, unix.Close(fd))
+       }()
+
+       out := TerminalTestOutput{fd: fd}
+       require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 0}))
+       require.Equal(t, 80, terminalColumns(&out))
+       terminalTestShowEvents(t, &out, Event{
+               Kind: "reply",
+               Text: strings.Repeat("a", 81),
+       })
+       require.Equal(t, strings.Repeat("a", 80)+"\na\n", out.String())
+       require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 40}))
+       require.Equal(t, 40, terminalColumns(&out))
+}
+
+// TerminalTestOutput captures text while it supplies a test terminal
+// descriptor.
+type TerminalTestOutput struct {
+       strings.Builder
+       fd int
+}
+
+func (o *TerminalTestOutput) Fd() uintptr {
+       return uintptr(o.fd)
+}
+
+func terminalTestShowEvents(t *testing.T, out io.Writer, events ...Event) {
+       t.Helper()
+
+       server, client := net.Pipe()
+       done := make(chan error, 1)
+       go func() {
+               done <- terminalShowEvents(client, out)
+       }()
+
+       encoder := json.NewEncoder(server)
+       for _, event := range events {
+               require.NoError(t, encoder.Encode(event))
+       }
+
+       require.NoError(t, server.Close())
+       require.NoError(t, <-done)
+       require.NoError(t, client.Close())
+}