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. /image PATH [caption] sends an
27 // image from the client working directory. The client shows the replies, the
28 // tool calls, and their results as they happen. It ends at the end of the input
29 // or when the daemon closes the connection.
30 func terminal(ctx context.Context, in io.Reader, out io.Writer) error {
33 return fmt.Errorf("find data directory: %w", err)
36 path := filepath.Join(dir, socketFileName)
37 err = checkSocketPath(path)
39 return fmt.Errorf("check terminal socket path: %w", err)
44 conn, err := dialer.DialContext(ctx, "unix", path)
46 return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err)
49 err = terminalSession(ctx, conn, in, out)
51 return fmt.Errorf("run terminal session: %w", err)
57 // terminalSession runs the client on conn until the input ends, the daemon
58 // closes the connection, or ctx ends, and closes conn. A read of the input that
59 // waits for a line can stay behind when the daemon closes the connection.
60 func terminalSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer) error {
61 sent := make(chan error, 1)
63 sent <- terminalSendLines(ctx, conn, in)
66 shown := make(chan error, 1)
68 shown <- terminalShowEvents(conn, out)
75 err = errors.Join(err, conn.Close())
79 err = errors.Join(terminalCmpError(err, errTerminalDaemonClosed), conn.Close())
87 return fmt.Errorf("run terminal client: %w", err)
93 // terminalSendLines sends each line of in as a request until the input ends.
94 func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
95 encoder := json.NewEncoder(conn)
96 scanner := bufio.NewScanner(in)
97 scanner.Buffer(make([]byte, 64*1024), socketRequestBytes)
99 for ctx.Err() == nil && scanner.Scan() {
100 line := scanner.Text()
101 request := SocketRequest{
106 name, stop := stopSubagentName(line)
107 args, whatsapp := whatsappCommandArgs(line)
108 image, isImage := terminalImageRequest(line)
110 case strings.TrimSpace(line) == "":
112 case strings.TrimSpace(line) == stopCommand:
113 request = SocketRequest{
117 request = SocketRequest{
123 if request.Path != "" {
126 request.Path, err = filepath.Abs(request.Path)
128 return fmt.Errorf("find terminal image path: %w", err)
132 request = SocketRequest{
138 err := encoder.Encode(request)
140 return fmt.Errorf("send to the daemon: %w", err)
146 return fmt.Errorf("read terminal input: %w", err)
152 // terminalShowEvents writes each event from the daemon to out until the
153 // connection closes. Only replies and work messages use Markdown styles, and
154 // only when out is a terminal. Terminal output ends each shown idle event with
155 // an input prompt and clears that prompt before the next shown event.
156 func terminalShowEvents(conn net.Conn, out io.Writer) error {
157 busy, prompt := false, false
158 scanner := bufio.NewScanner(conn)
159 scanner.Buffer(make([]byte, 64*1024), socketRequestBytes)
164 err := json.Unmarshal(scanner.Bytes(), &event)
166 return fmt.Errorf("decode event: %w", err)
169 text := terminalEventText(event)
174 columns := terminalColumns(out)
175 if columns > 0 && (event.Kind == "reply" || event.Kind == "work") {
176 text = strings.Join(terminalMarkdown(text, columns), "\n")
188 text = "\r\x1b[2K" + text
191 prompt = columns > 0 && !busy
196 _, err = io.WriteString(out, text)
198 return fmt.Errorf("write terminal event: %w", err)
204 return fmt.Errorf("read daemon events: %w", err)
210 // terminalCmpError returns err, or fallback when err is nil.
211 func terminalCmpError(err, fallback error) error {
219 // terminalEventText returns the text that the client shows for event, or "" for
220 // none. The client shows a user message only from another source, such as
221 // telegram, because the user typed the others. A tool result shows its first
222 // terminalResultLines lines. The report of a subagent shows whole, and it
223 // starts with the name of the subagent.
224 func terminalEventText(event Event) string {
227 return "[connected to " + event.Text + "]"
229 if event.Source == terminalSource {
233 return "[" + event.Source + "] " + event.Text
237 return "[tool] " + event.Text
239 return "[result] " + terminalFirstLines(event.Text, terminalResultLines)
241 return "[" + event.Text + "] started"
249 return "[error] " + event.Text
257 // terminalColumns returns the current terminal width, or zero for other output.
258 // A terminal that reports no columns uses 80.
259 func terminalColumns(out io.Writer) int {
260 file, ok := out.(interface{ Fd() uintptr })
265 size, err := unix.IoctlGetWinsize(int(file.Fd()), unix.TIOCGWINSZ)
277 // terminalFirstLines returns the first count lines of text, with the number of
278 // the lines left out.
279 func terminalFirstLines(text string, count int) string {
280 text = strings.TrimRight(text, "\n")
281 lines := strings.Split(text, "\n")
282 if len(lines) <= count {
286 return strings.Join(lines[:count], "\n") + fmt.Sprintf("\n[%d more lines]", len(lines)-count)