14 "golang.org/x/sys/unix"
17 // terminalResultLines is the most lines of a tool result that the client shows.
18 const terminalResultLines = 10
20 // errTerminalDaemonClosed tells that the daemon closed the connection.
21 var errTerminalDaemonClosed = errors.New("the daemon closed the connection")
23 // terminal runs "onechat" and "onechat chat": the terminal client of the
24 // daemon. Each line of input is a user message, the line /stop interrupts the
25 // running turn, the line /stop NAME stops the subagent NAME, and a line that
26 // starts with /whatsapp is a WhatsApp command. The client shows the replies,
27 // the tool calls, and their results as they happen. It ends at the end of the
28 // input or when the daemon closes the connection.
29 func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
35 path := filepath.Join(dir, socketFileName)
36 err = checkSocketPath(path)
43 conn, err := dialer.DialContext(ctx, "unix", path)
45 return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err)
48 return terminalSession(ctx, conn, in, out)
51 // terminalSession runs the client on conn until the input ends, the daemon
52 // closes the connection, or ctx ends, and closes conn. A read of the input that
53 // waits for a line can stay behind when the daemon closes the connection.
54 func terminalSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer) error {
55 sent := make(chan error, 1)
57 sent <- terminalSendLines(ctx, conn, in)
60 shown := make(chan error, 1)
62 shown <- terminalShowEvents(conn, out)
69 err = errors.Join(err, conn.Close())
73 err = errors.Join(terminalCmpError(err, errTerminalDaemonClosed), conn.Close())
81 return fmt.Errorf("run terminal client: %w", err)
87 // terminalSendLines sends each line of in as a request until the input ends.
88 func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
89 encoder := json.NewEncoder(conn)
90 scanner := bufio.NewScanner(in)
91 scanner.Buffer(make([]byte, 64*1024), socketRequestBytes)
93 for ctx.Err() == nil && scanner.Scan() {
94 line := scanner.Text()
95 request := SocketRequest{
100 name, stop := stopSubagentName(line)
101 args, whatsapp := whatsappCommandArgs(line)
103 case strings.TrimSpace(line) == "":
105 case strings.TrimSpace(line) == stopCommand:
106 request = SocketRequest{
110 request = SocketRequest{
115 request = SocketRequest{
121 err := encoder.Encode(request)
123 return fmt.Errorf("send to the daemon: %w", err)
129 return fmt.Errorf("read terminal input: %w", err)
135 // terminalShowEvents writes each event from the daemon to out until the
136 // connection closes. Only replies and work messages use Markdown styles, and
137 // only when out is a terminal. Terminal output ends each shown idle event with
138 // an input prompt and clears that prompt before the next shown event.
139 func terminalShowEvents(conn net.Conn, out io.Writer) error {
140 busy, prompt := false, false
141 scanner := bufio.NewScanner(conn)
142 scanner.Buffer(make([]byte, 64*1024), socketRequestBytes)
147 err := json.Unmarshal(scanner.Bytes(), &event)
149 return fmt.Errorf("decode event: %w", err)
152 text := terminalEventText(event)
157 columns := terminalColumns(out)
158 if columns > 0 && (event.Kind == "reply" || event.Kind == "work") {
159 text = strings.Join(terminalMarkdown(text, columns), "\n")
171 text = "\r\x1b[2K" + text
174 prompt = columns > 0 && !busy
179 _, err = io.WriteString(out, text)
181 return fmt.Errorf("write terminal event: %w", err)
187 return fmt.Errorf("read daemon events: %w", err)
193 // terminalCmpError returns err, or fallback when err is nil.
194 func terminalCmpError(err, fallback error) error {
202 // terminalEventText returns the text that the client shows for event, or "" for
203 // none. The client shows a user message only from another source, such as
204 // telegram, because the user typed the others. A tool result shows its first
205 // terminalResultLines lines. The report of a subagent shows whole, and it
206 // starts with the name of the subagent.
207 func terminalEventText(event Event) string {
210 return "[connected to " + event.Text + "]"
212 if event.Source == terminalSource {
216 return "[" + event.Source + "] " + event.Text
220 return "[tool] " + event.Text
222 return "[result] " + terminalFirstLines(event.Text, terminalResultLines)
224 return "[" + event.Text + "] started"
232 return "[error] " + event.Text
240 // terminalColumns returns the current terminal width, or zero for other output.
241 // A terminal that reports no columns uses 80.
242 func terminalColumns(out io.Writer) int {
243 file, ok := out.(interface{ Fd() uintptr })
248 size, err := unix.IoctlGetWinsize(int(file.Fd()), unix.TIOCGWINSZ)
260 // terminalFirstLines returns the first count lines of text, with the number of
261 // the lines left out.
262 func terminalFirstLines(text string, count int) string {
263 text = strings.TrimRight(text, "\n")
264 lines := strings.Split(text, "\n")
265 if len(lines) <= count {
269 return strings.Join(lines[:count], "\n") + fmt.Sprintf("\n[%d more lines]", len(lines)-count)