From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 02:38:19 +0000 (-0300) Subject: Show styled replies only on terminal output X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/0bc8db71aa5d2c55b215a7a15b073bb85a1172eb Show styled replies only on terminal output 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. --- diff --git a/README.rst b/README.rst index 18af256..23462b4 100644 --- a/README.rst +++ b/README.rst @@ -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. diff --git a/terminal.go b/terminal.go index 4f1c378..203cb5e 100644 --- a/terminal.go +++ b/terminal.go @@ -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 { diff --git a/terminal_markdown.go b/terminal_markdown.go index a13a4e9..73ae7d5 100644 --- a/terminal_markdown.go +++ b/terminal_markdown.go @@ -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 diff --git a/terminal_markdown_test.go b/terminal_markdown_test.go index 6344d8d..adf0fa4 100644 --- a/terminal_markdown_test.go +++ b/terminal_markdown_test.go @@ -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) { diff --git a/terminal_test.go b/terminal_test.go index 62ffb04..47791ef 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -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()) +}