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.
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.
"net"
"path/filepath"
"strings"
+
+ "golang.org/x/sys/unix"
)
// terminalResultLines is the most lines of a tool result that the client shows.
}
// 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)
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)
}
}
+// 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 = 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]) != "" {
// 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
}
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
}, 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",
text.WriteString(plain)
}
- require.Equal(t, "FirstSecondalphabeta", text.String())
+ require.Equal(t, "FirstSecond----alphabeta", text.String())
}
func TestTerminalMarkdownWidthDefaultsToEighty(t *testing.T) {
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) {
})
}
}
+
+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())
+}