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 {
36 path := filepath.Join(dir, socketFileName)
37 err = checkSocketPath(path)
44 conn, err := dialer.DialContext(ctx, "unix", path)
46 return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err)
49 return terminalSession(ctx, conn, in, out)
52 // terminalSession runs the client on conn until the input ends, the daemon
53 // closes the connection, or ctx ends, and closes conn. A read of the input that
54 // waits for a line can stay behind when the daemon closes the connection.
55 func terminalSession(ctx context.Context, conn net.Conn, in io.Reader, out io.Writer) error {
56 sent := make(chan error, 1)
58 sent <- terminalSendLines(ctx, conn, in)
61 shown := make(chan error, 1)
63 shown <- terminalShowEvents(conn, out)
70 err = errors.Join(err, conn.Close())
74 err = errors.Join(terminalCmpError(err, errTerminalDaemonClosed), conn.Close())
82 return fmt.Errorf("run terminal client: %w", err)
88 // terminalSendLines sends each line of in as a request until the input ends.
89 func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error {
90 encoder := json.NewEncoder(conn)
91 scanner := bufio.NewScanner(in)
92 scanner.Buffer(make([]byte, 64*1024), socketRequestBytes)
94 for ctx.Err() == nil && scanner.Scan() {
95 line := scanner.Text()
96 request := SocketRequest{
101 name, stop := stopSubagentName(line)
102 args, whatsapp := whatsappCommandArgs(line)
103 image, isImage := terminalImageRequest(line)
105 case strings.TrimSpace(line) == "":
107 case strings.TrimSpace(line) == stopCommand:
108 request = SocketRequest{
112 request = SocketRequest{
118 if request.Path != "" {
121 request.Path, err = filepath.Abs(request.Path)
123 return fmt.Errorf("find terminal image path: %w", err)
127 request = SocketRequest{
133 err := encoder.Encode(request)
135 return fmt.Errorf("send to the daemon: %w", err)
141 return fmt.Errorf("read terminal input: %w", err)
147 // terminalShowEvents writes each event from the daemon to out until the
148 // connection closes. Only replies and work messages use Markdown styles, and
149 // only when out is a terminal. Terminal output ends each shown idle event with
150 // an input prompt and clears that prompt before the next shown event.
151 func terminalShowEvents(conn net.Conn, out io.Writer) error {
152 busy, prompt := false, false
153 scanner := bufio.NewScanner(conn)
154 scanner.Buffer(make([]byte, 64*1024), socketRequestBytes)
159 err := json.Unmarshal(scanner.Bytes(), &event)
161 return fmt.Errorf("decode event: %w", err)
164 text := terminalEventText(event)
169 columns := terminalColumns(out)
170 if columns > 0 && (event.Kind == "reply" || event.Kind == "work") {
171 text = strings.Join(terminalMarkdown(text, columns), "\n")
183 text = "\r\x1b[2K" + text
186 prompt = columns > 0 && !busy
191 _, err = io.WriteString(out, text)
193 return fmt.Errorf("write terminal event: %w", err)
199 return fmt.Errorf("read daemon events: %w", err)
205 // terminalCmpError returns err, or fallback when err is nil.
206 func terminalCmpError(err, fallback error) error {
214 // terminalEventText returns the text that the client shows for event, or "" for
215 // none. The client shows a user message only from another source, such as
216 // telegram, because the user typed the others. A tool result shows its first
217 // terminalResultLines lines. The report of a subagent shows whole, and it
218 // starts with the name of the subagent.
219 func terminalEventText(event Event) string {
222 return "[connected to " + event.Text + "]"
224 if event.Source == terminalSource {
228 return "[" + event.Source + "] " + event.Text
232 return "[tool] " + event.Text
234 return "[result] " + terminalFirstLines(event.Text, terminalResultLines)
236 return "[" + event.Text + "] started"
244 return "[error] " + event.Text
252 // terminalColumns returns the current terminal width, or zero for other output.
253 // A terminal that reports no columns uses 80.
254 func terminalColumns(out io.Writer) int {
255 file, ok := out.(interface{ Fd() uintptr })
260 size, err := unix.IoctlGetWinsize(int(file.Fd()), unix.TIOCGWINSZ)
272 // terminalFirstLines returns the first count lines of text, with the number of
273 // the lines left out.
274 func terminalFirstLines(text string, count int) string {
275 text = strings.TrimRight(text, "\n")
276 lines := strings.Split(text, "\n")
277 if len(lines) <= count {
281 return strings.Join(lines[:count], "\n") + fmt.Sprintf("\n[%d more lines]", len(lines)-count)