]> git.giacomolli.uk - onechat/commitdiff
Name and order the terminal client code
authorAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 16:34:32 +0000 (13:34 -0300)
committerAnderson A. Giacomolli <agiacomolli@gmail.com>
Sat, 7 Nov 2026 16:34:32 +0000 (13:34 -0300)
Every package-level name of the terminal client starts with terminal,
and its file is terminal.go, with its tests in terminal_test.go. The
client runs the command "onechat chat", but in the code the chat is the
chat of the agent, with its log, tree, and views, so the prefix names
the client by its kind.

The stop command is in stop.go and the test buffer in buffer_test.go,
because the terminal client and the Telegram bot use them both. The
declarations of the terminal files follow the order of the go-code
skill. The behavior does not change.

Issue: 947bddb

buffer_test.go [new file with mode: 0644]
daemon_test.go
main.go
stop.go [new file with mode: 0644]
telegram_test.go
terminal.go [moved from chat.go with 57% similarity]
terminal_test.go [new file with mode: 0644]

diff --git a/buffer_test.go b/buffer_test.go
new file mode 100644 (file)
index 0000000..a819bcc
--- /dev/null
@@ -0,0 +1,26 @@
+package main
+
+import (
+       "bytes"
+       "sync"
+)
+
+// SyncBuffer is a buffer that the test and the client use at once.
+type SyncBuffer struct {
+       mutex  sync.Mutex
+       buffer bytes.Buffer
+}
+
+func (b *SyncBuffer) Write(p []byte) (int, error) {
+       b.mutex.Lock()
+       defer b.mutex.Unlock()
+
+       return b.buffer.Write(p)
+}
+
+func (b *SyncBuffer) String() string {
+       b.mutex.Lock()
+       defer b.mutex.Unlock()
+
+       return b.buffer.String()
+}
index d1f1bc5e9c4c0b700e4c20bd77949e99a56b9668..3d84faaffc7e03117ae2878beafe16cb7a536d70 100644 (file)
@@ -1,17 +1,13 @@
 package main
 
 import (
 package main
 
 import (
-       "bytes"
        "context"
        "io"
        "context"
        "io"
-       "net"
        "net/http"
        "os"
        "path/filepath"
        "strings"
        "net/http"
        "os"
        "path/filepath"
        "strings"
-       "sync"
        "testing"
        "testing"
-       "time"
 
        "github.com/stretchr/testify/require"
 )
 
        "github.com/stretchr/testify/require"
 )
@@ -28,13 +24,13 @@ func TestClientsSeeTheTurnAsItHappens(t *testing.T) {
        }
 
        socket := startTestDaemon(t, server)
        }
 
        socket := startTestDaemon(t, server)
-       first := startTestClient(t, socket)
-       second := startTestClient(t, socket)
+       first := terminalStartTestClient(t, socket)
+       second := terminalStartTestClient(t, socket)
        first.WaitFor(t, "[connected to OneChat]")
        second.WaitFor(t, "[connected to OneChat]")
 
        first.Type("Run echo hi.")
        first.WaitFor(t, "[connected to OneChat]")
        second.WaitFor(t, "[connected to OneChat]")
 
        first.Type("Run echo hi.")
-       for _, client := range []*testClient{first, second} {
+       for _, client := range []*TerminalTestClient{first, second} {
                client.WaitFor(t, "[done]")
                require.Equal(t, "[connected to OneChat]\n[working]\n[tool] shell {\"command\":\"echo hi\"}\n[result] hi\nIt said hi.\n[done]\n", client.Output())
        }
                client.WaitFor(t, "[done]")
                require.Equal(t, "[connected to OneChat]\n[working]\n[tool] shell {\"command\":\"echo hi\"}\n[result] hi\nIt said hi.\n[done]\n", client.Output())
        }
@@ -59,44 +55,17 @@ func TestStopInterruptsTheTurn(t *testing.T) {
        }
 
        socket := startTestDaemon(t, server)
        }
 
        socket := startTestDaemon(t, server)
-       client := startTestClient(t, socket)
+       client := terminalStartTestClient(t, socket)
        client.Type("a long task")
        <-arrived
 
        client.Type("a long task")
        <-arrived
 
-       late := startTestClient(t, socket)
+       late := terminalStartTestClient(t, socket)
        late.WaitFor(t, "[connected to OneChat]\n[working]")
 
        client.Type(stopCommand)
        client.WaitFor(t, "[error] interrupted\n[done]")
 }
 
        late.WaitFor(t, "[connected to OneChat]\n[working]")
 
        client.Type(stopCommand)
        client.WaitFor(t, "[error] interrupted\n[done]")
 }
 
-func TestClientEndsWhenTheDaemonEnds(t *testing.T) {
-       dir := t.TempDir()
-       listener, err := listenSocket(filepath.Join(dir, socketFileName))
-       require.NoError(t, err)
-
-       accepted := make(chan net.Conn)
-       go func() {
-               conn, err := listener.Accept()
-               if err == nil {
-                       accepted <- conn
-               }
-       }()
-
-       conn, err := net.Dial("unix", filepath.Join(dir, socketFileName))
-       require.NoError(t, err)
-
-       in, _ := io.Pipe()
-       done := make(chan error)
-       go func() {
-               done <- chatSession(t.Context(), conn, in, io.Discard)
-       }()
-
-       require.NoError(t, (<-accepted).Close())
-       require.ErrorIs(t, <-done, errDaemonClosed)
-       require.NoError(t, listener.Close())
-}
-
 func TestListenSocketReplacesAnOldSocketFile(t *testing.T) {
        path := filepath.Join(t.TempDir(), socketFileName)
        require.NoError(t, os.WriteFile(path, nil, 0o600))
 func TestListenSocketReplacesAnOldSocketFile(t *testing.T) {
        path := filepath.Join(t.TempDir(), socketFileName)
        require.NoError(t, os.WriteFile(path, nil, 0o600))
@@ -142,100 +111,6 @@ func TestOneProcessHoldsTheTokens(t *testing.T) {
        require.NoError(t, lock.Close())
 }
 
        require.NoError(t, lock.Close())
 }
 
-func TestEventText(t *testing.T) {
-       testCases := []struct {
-               name  string
-               event Event
-               text  string
-       }{
-               {
-                       name:  "reply as it is",
-                       event: Event{Kind: "reply", Text: "Done."},
-                       text:  "Done.",
-               },
-               {
-                       name:  "own user message not shown",
-                       event: Event{Kind: "user", Text: "typed", Source: terminalSource},
-                       text:  "",
-               },
-               {
-                       name:  "user message of another source marked",
-                       event: Event{Kind: "user", Text: "from the phone", Source: "telegram"},
-                       text:  "[telegram] from the phone",
-               },
-               {
-                       name:  "long result cut",
-                       event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)},
-                       text:  "[result] " + strings.Repeat("line\n", 9) + "line\n[2 more lines]",
-               },
-       }
-
-       for _, tc := range testCases {
-               t.Run(tc.name, func(t *testing.T) {
-                       require.Equal(t, tc.text, eventText(tc.event))
-               })
-       }
-}
-
-// testClient is a terminal client of a test daemon, with a pipe as its input.
-type testClient struct {
-       input *io.PipeWriter
-       done  chan error
-       out   *syncBuffer
-}
-
-// Type writes one line to the input of the client.
-func (c *testClient) Type(line string) {
-       _, err := io.WriteString(c.input, line+"\n")
-       if err != nil {
-               panic(err)
-       }
-}
-
-// Output returns what the client showed.
-func (c *testClient) Output() string {
-       return c.out.String()
-}
-
-// WaitFor waits until the client showed text.
-func (c *testClient) WaitFor(t *testing.T, text string) {
-       t.Helper()
-
-       require.Eventually(t, func() bool {
-               return strings.Contains(c.Output(), text)
-       }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output())
-}
-
-// Close ends the input and waits for the client.
-func (c *testClient) Close() error {
-       err := c.input.Close()
-       if err != nil {
-               return err
-       }
-
-       return <-c.done
-}
-
-// syncBuffer is a buffer that the test and the client use at once.
-type syncBuffer struct {
-       mutex  sync.Mutex
-       buffer bytes.Buffer
-}
-
-func (b *syncBuffer) Write(p []byte) (int, error) {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       return b.buffer.Write(p)
-}
-
-func (b *syncBuffer) String() string {
-       b.mutex.Lock()
-       defer b.mutex.Unlock()
-
-       return b.buffer.String()
-}
-
 // startTestDaemon serves a new agent on a socket in a temporary directory and
 // returns the socket path. The commands of the agent run in a temporary
 // directory.
 // startTestDaemon serves a new agent on a socket in a temporary directory and
 // returns the socket path. The commands of the agent run in a temporary
 // directory.
@@ -279,28 +154,3 @@ func startTestDaemonWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) s
 
        return socket
 }
 
        return socket
 }
-
-// startTestClient connects a client to the daemon at socket.
-func startTestClient(t *testing.T, socket string) *testClient {
-       t.Helper()
-
-       conn, err := net.Dial("unix", socket)
-       require.NoError(t, err)
-
-       reader, writer := io.Pipe()
-       client := &testClient{
-               input: writer,
-               done:  make(chan error, 1),
-               out:   &syncBuffer{},
-       }
-
-       go func() {
-               client.done <- chatSession(t.Context(), conn, reader, client.out)
-       }()
-
-       t.Cleanup(func() {
-               _ = writer.Close()
-       })
-
-       return client
-}
diff --git a/main.go b/main.go
index bdbcb722420b9dd8d8100c8ea1d7363a3160aaad..f2c263dc9600d83153c8424d0f34310e29abc9ea 100644 (file)
--- a/main.go
+++ b/main.go
@@ -38,7 +38,7 @@ func run() error {
        case len(args) == 1 && args[0] == "daemon":
                return daemon(ctx, os.Stdout)
        case len(args) == 1 && args[0] == "chat":
        case len(args) == 1 && args[0] == "daemon":
                return daemon(ctx, os.Stdout)
        case len(args) == 1 && args[0] == "chat":
-               return chat(ctx, os.Stdin, os.Stdout)
+               return terminal(ctx, os.Stdin, os.Stdout)
        case len(args) > 0 && args[0] == "probe":
                err := probe(ctx, args[1:], os.Stdout)
                if err != nil {
        case len(args) > 0 && args[0] == "probe":
                err := probe(ctx, args[1:], os.Stdout)
                if err != nil {
diff --git a/stop.go b/stop.go
new file mode 100644 (file)
index 0000000..640464f
--- /dev/null
+++ b/stop.go
@@ -0,0 +1,4 @@
+package main
+
+// stopCommand is the input line that interrupts the running turn.
+const stopCommand = "/stop"
index 2f944fabddbb1b6117ab047da4b50cbf803a0b41..a1a1ed24bc42d842a1327e778e48bfde5a90f3a3 100644 (file)
@@ -22,7 +22,7 @@ func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) {
        telegram := newTelegramFake(t)
        bot, _ := telegramStartTestBot(t, telegram)
        socket := startTestDaemonWithBot(t, server, bot)
        telegram := newTelegramFake(t)
        bot, _ := telegramStartTestBot(t, telegram)
        socket := startTestDaemonWithBot(t, server, bot)
-       terminal := startTestClient(t, socket)
+       terminal := terminalStartTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
 
        telegram.Add(TelegramUpdate{
        terminal.WaitFor(t, "[connected to OneChat]")
 
        telegram.Add(TelegramUpdate{
@@ -44,7 +44,7 @@ func TestBotIgnoresEveryoneButTheOwner(t *testing.T) {
        telegram := newTelegramFake(t)
        bot, logs := telegramStartTestBot(t, telegram)
        socket := startTestDaemonWithBot(t, newModelFake(t), bot)
        telegram := newTelegramFake(t)
        bot, logs := telegramStartTestBot(t, telegram)
        socket := startTestDaemonWithBot(t, newModelFake(t), bot)
-       terminal := startTestClient(t, socket)
+       terminal := terminalStartTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
 
        group := telegramOwnerMessage("secret in a group")
        terminal.WaitFor(t, "[connected to OneChat]")
 
        group := telegramOwnerMessage("secret in a group")
@@ -150,7 +150,7 @@ func TestBotStartsAtTheStoredOffset(t *testing.T) {
        })
 
        socket := startTestDaemonWithBot(t, server, bot)
        })
 
        socket := startTestDaemonWithBot(t, server, bot)
-       terminal := startTestClient(t, socket)
+       terminal := terminalStartTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
        telegramWaitForSent(t, telegram, "ok")
 
        terminal.WaitFor(t, "[connected to OneChat]")
        telegramWaitForSent(t, telegram, "ok")
 
@@ -237,7 +237,7 @@ func TestRepliesGoAsRichMessages(t *testing.T) {
        telegram := newTelegramFake(t)
        bot, _ := telegramStartTestBot(t, telegram)
        socket := startTestDaemonWithBot(t, server, bot)
        telegram := newTelegramFake(t)
        bot, _ := telegramStartTestBot(t, telegram)
        socket := startTestDaemonWithBot(t, server, bot)
-       terminal := startTestClient(t, socket)
+       terminal := terminalStartTestClient(t, socket)
        terminal.WaitFor(t, "[connected to OneChat]")
 
        terminal.Type("make **it** so")
        terminal.WaitFor(t, "[connected to OneChat]")
 
        terminal.Type("make **it** so")
@@ -653,10 +653,10 @@ func TestLoadBotConfig(t *testing.T) {
 // telegramStartTestBot returns a bot of telegramTestOwner on server, with its
 // offset file in a temporary directory and short waits, and the buffer of its
 // log.
 // telegramStartTestBot returns a bot of telegramTestOwner on server, with its
 // offset file in a temporary directory and short waits, and the buffer of its
 // log.
-func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *syncBuffer) {
+func telegramStartTestBot(t *testing.T, server *TelegramFake) (*TelegramBot, *SyncBuffer) {
        t.Helper()
 
        t.Helper()
 
-       logs := &syncBuffer{}
+       logs := &SyncBuffer{}
        bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0))
        bot.minWait = time.Millisecond
        bot.maxWait = 10 * time.Millisecond
        bot := newTelegramBot(newTelegramAPI(server.URL, telegramTestToken), telegramTestOwner, filepath.Join(t.TempDir(), telegramOffsetFileName), log.New(logs, "", 0))
        bot.minWait = time.Millisecond
        bot.maxWait = 10 * time.Millisecond
similarity index 57%
rename from chat.go
rename to terminal.go
index 1e96b2458be159bdd8fe624f35c0af4668cb6dbc..2906204477b14baa33600fd3efb743865cc85037 100644 (file)
--- a/chat.go
@@ -12,19 +12,17 @@ import (
        "strings"
 )
 
        "strings"
 )
 
-const (
-       // stopCommand is the input line that interrupts the running turn.
-       stopCommand = "/stop"
+// terminalResultLines is the most lines of a tool result that the client shows.
+const terminalResultLines = 10
 
 
-       // resultLines is the most lines of a tool result that the client shows.
-       resultLines = 10
-)
+// errTerminalDaemonClosed tells that the daemon closed the connection.
+var errTerminalDaemonClosed = errors.New("the daemon closed the connection")
 
 
-// chat runs "onechat chat": the terminal client of the daemon. Each line of
+// terminal runs "onechat chat": the terminal client of the daemon. Each line of
 // input is a user message, and the line /stop interrupts the running turn. The
 // client shows the replies, the tool calls, and their results as they happen.
 // It ends at the end of the input or when the daemon closes the connection.
 // input is a user message, and the line /stop interrupts the running turn. The
 // client shows the replies, the tool calls, and their results as they happen.
 // It ends at the end of the input or when the daemon closes the connection.
-func chat(ctx context.Context, in io.Reader, out io.Writer) error {
+func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
        dir, err := dataDir()
        if err != nil {
                return err
        dir, err := dataDir()
        if err != nil {
                return err
@@ -42,24 +40,21 @@ func chat(ctx context.Context, in io.Reader, out io.Writer) error {
                return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err)
        }
 
                return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err)
        }
 
-       return chatSession(ctx, conn, in, out)
+       return terminalSession(ctx, conn, in, out)
 }
 
 }
 
-// errDaemonClosed tells that the daemon closed the connection.
-var errDaemonClosed = errors.New("the daemon closed the connection")
-
-// chatSession runs the client on conn until the input ends, the daemon closes
-// the connection, or ctx ends, and closes conn. A read of the input that waits
-// for a line can stay behind when the daemon closes the connection.
-func chatSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer) error {
+// terminalSession runs the client on conn until the input ends, the daemon
+// closes the connection, or ctx ends, and closes conn. A read of the input that
+// waits for a line can stay behind when the daemon closes the connection.
+func terminalSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer) error {
        sent := make(chan error, 1)
        go func() {
        sent := make(chan error, 1)
        go func() {
-               sent <- sendLines(ctx, conn, in)
+               sent <- terminalSendLines(ctx, conn, in)
        }()
 
        shown := make(chan error, 1)
        go func() {
        }()
 
        shown := make(chan error, 1)
        go func() {
-               shown <- showEvents(conn, out)
+               shown <- terminalShowEvents(conn, out)
        }()
 
        var err error
        }()
 
        var err error
@@ -68,7 +63,7 @@ func chatSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer
                err = errors.Join(err, conn.Close())
                <-shown
        case err = <-shown:
                err = errors.Join(err, conn.Close())
                <-shown
        case err = <-shown:
-               err = errors.Join(cmpError(err, errDaemonClosed), conn.Close())
+               err = errors.Join(terminalCmpError(err, errTerminalDaemonClosed), conn.Close())
        case <-ctx.Done():
                err = conn.Close()
                <-shown
        case <-ctx.Done():
                err = conn.Close()
                <-shown
@@ -77,17 +72,8 @@ func chatSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer
        return err
 }
 
        return err
 }
 
-// cmpError returns err, or fallback when err is nil.
-func cmpError(err, fallback error) error {
-       if err == nil {
-               return fallback
-       }
-
-       return err
-}
-
-// sendLines sends each line of in as a request until the input ends.
-func sendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
+// terminalSendLines sends each line of in as a request until the input ends.
+func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
        encoder := json.NewEncoder(conn)
        scanner := bufio.NewScanner(in)
        scanner.Buffer(make([]byte, 64*1024), requestBytes)
        encoder := json.NewEncoder(conn)
        scanner := bufio.NewScanner(in)
        scanner.Buffer(make([]byte, 64*1024), requestBytes)
@@ -116,9 +102,9 @@ func sendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
        return scanner.Err()
 }
 
        return scanner.Err()
 }
 
-// showEvents writes each event from the daemon to out until the connection
-// closes.
-func showEvents(conn net.Conn, out io.Writer) error {
+// terminalShowEvents writes each event from the daemon to out until the
+// connection closes.
+func terminalShowEvents(conn net.Conn, out io.Writer) error {
        scanner := bufio.NewScanner(conn)
        scanner.Buffer(make([]byte, 64*1024), requestBytes)
        for scanner.Scan() {
        scanner := bufio.NewScanner(conn)
        scanner.Buffer(make([]byte, 64*1024), requestBytes)
        for scanner.Scan() {
@@ -128,7 +114,7 @@ func showEvents(conn net.Conn, out io.Writer) error {
                        return fmt.Errorf("decode event: %w", err)
                }
 
                        return fmt.Errorf("decode event: %w", err)
                }
 
-               text := eventText(event)
+               text := terminalEventText(event)
                if text == "" {
                        continue
                }
                if text == "" {
                        continue
                }
@@ -142,11 +128,20 @@ func showEvents(conn net.Conn, out io.Writer) error {
        return scanner.Err()
 }
 
        return scanner.Err()
 }
 
-// eventText returns the text that the client shows for event, or "" for none.
-// The client shows a user message only from another source, such as telegram,
-// because the user typed the others. A tool result shows its first resultLines
-// lines.
-func eventText(event Event) string {
+// terminalCmpError returns err, or fallback when err is nil.
+func terminalCmpError(err, fallback error) error {
+       if err == nil {
+               return fallback
+       }
+
+       return err
+}
+
+// terminalEventText returns the text that the client shows for event, or "" for
+// none. The client shows a user message only from another source, such as
+// telegram, because the user typed the others. A tool result shows its first
+// terminalResultLines lines.
+func terminalEventText(event Event) string {
        switch event.Kind {
        case "ready":
                return "[connected to " + event.Text + "]"
        switch event.Kind {
        case "ready":
                return "[connected to " + event.Text + "]"
@@ -161,7 +156,7 @@ func eventText(event Event) string {
        case "tool":
                return "[tool] " + event.Text
        case "echo":
        case "tool":
                return "[tool] " + event.Text
        case "echo":
-               return "[result] " + firstLines(event.Text, resultLines)
+               return "[result] " + terminalFirstLines(event.Text, terminalResultLines)
        case "busy":
                return "[working]"
        case "idle":
        case "busy":
                return "[working]"
        case "idle":
@@ -173,9 +168,9 @@ func eventText(event Event) string {
        }
 }
 
        }
 }
 
-// firstLines returns the first count lines of text, with the number of the
-// lines left out.
-func firstLines(text string, count int) string {
+// terminalFirstLines returns the first count lines of text, with the number of
+// the lines left out.
+func terminalFirstLines(text string, count int) string {
        text = strings.TrimRight(text, "\n")
        lines := strings.Split(text, "\n")
        if len(lines) <= count {
        text = strings.TrimRight(text, "\n")
        lines := strings.Split(text, "\n")
        if len(lines) <= count {
diff --git a/terminal_test.go b/terminal_test.go
new file mode 100644 (file)
index 0000000..9df3e0a
--- /dev/null
@@ -0,0 +1,139 @@
+package main
+
+import (
+       "io"
+       "net"
+       "path/filepath"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestClientEndsWhenTheDaemonEnds(t *testing.T) {
+       dir := t.TempDir()
+       listener, err := listenSocket(filepath.Join(dir, socketFileName))
+       require.NoError(t, err)
+
+       accepted := make(chan net.Conn)
+       go func() {
+               conn, err := listener.Accept()
+               if err == nil {
+                       accepted <- conn
+               }
+       }()
+
+       conn, err := net.Dial("unix", filepath.Join(dir, socketFileName))
+       require.NoError(t, err)
+
+       in, _ := io.Pipe()
+       done := make(chan error)
+       go func() {
+               done <- terminalSession(t.Context(), conn, in, io.Discard)
+       }()
+
+       require.NoError(t, (<-accepted).Close())
+       require.ErrorIs(t, <-done, errTerminalDaemonClosed)
+       require.NoError(t, listener.Close())
+}
+
+func TestEventText(t *testing.T) {
+       testCases := []struct {
+               name  string
+               event Event
+               text  string
+       }{
+               {
+                       name:  "reply as it is",
+                       event: Event{Kind: "reply", Text: "Done."},
+                       text:  "Done.",
+               },
+               {
+                       name:  "own user message not shown",
+                       event: Event{Kind: "user", Text: "typed", Source: terminalSource},
+                       text:  "",
+               },
+               {
+                       name:  "user message of another source marked",
+                       event: Event{Kind: "user", Text: "from the phone", Source: "telegram"},
+                       text:  "[telegram] from the phone",
+               },
+               {
+                       name:  "long result cut",
+                       event: Event{Kind: "echo", Text: strings.Repeat("line\n", 12)},
+                       text:  "[result] " + strings.Repeat("line\n", 9) + "line\n[2 more lines]",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       require.Equal(t, tc.text, terminalEventText(tc.event))
+               })
+       }
+}
+
+// TerminalTestClient is a terminal client of a test daemon, with a pipe as its
+// input.
+type TerminalTestClient struct {
+       input *io.PipeWriter
+       done  chan error
+       out   *SyncBuffer
+}
+
+// Type writes one line to the input of the client.
+func (c *TerminalTestClient) Type(line string) {
+       _, err := io.WriteString(c.input, line+"\n")
+       if err != nil {
+               panic(err)
+       }
+}
+
+// Output returns what the client showed.
+func (c *TerminalTestClient) Output() string {
+       return c.out.String()
+}
+
+// WaitFor waits until the client showed text.
+func (c *TerminalTestClient) WaitFor(t *testing.T, text string) {
+       t.Helper()
+
+       require.Eventually(t, func() bool {
+               return strings.Contains(c.Output(), text)
+       }, 10*time.Second, time.Millisecond, "output so far: %q", c.Output())
+}
+
+// Close ends the input and waits for the client.
+func (c *TerminalTestClient) Close() error {
+       err := c.input.Close()
+       if err != nil {
+               return err
+       }
+
+       return <-c.done
+}
+
+// terminalStartTestClient connects a client to the daemon at socket.
+func terminalStartTestClient(t *testing.T, socket string) *TerminalTestClient {
+       t.Helper()
+
+       conn, err := net.Dial("unix", socket)
+       require.NoError(t, err)
+
+       reader, writer := io.Pipe()
+       client := &TerminalTestClient{
+               input: writer,
+               done:  make(chan error, 1),
+               out:   &SyncBuffer{},
+       }
+
+       go func() {
+               client.done <- terminalSession(t.Context(), conn, reader, client.out)
+       }()
+
+       t.Cleanup(func() {
+               _ = writer.Close()
+       })
+
+       return client
+}