package main import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "net" "path/filepath" "strings" "golang.org/x/sys/unix" ) // terminalResultLines is the most lines of a tool result that the client shows. const terminalResultLines = 10 // errTerminalDaemonClosed tells that the daemon closed the connection. var errTerminalDaemonClosed = errors.New("the daemon closed the connection") // terminal runs "onechat" and "onechat chat": the terminal client of the // daemon. Each line of input is a user message, the line /stop interrupts the // running turn, the line /stop NAME stops the subagent NAME, and a line that // starts with /whatsapp is a WhatsApp command. 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 terminal(ctx context.Context, in io.Reader, out io.Writer) error { dir, err := dataDir() if err != nil { return err } path := filepath.Join(dir, socketFileName) err = checkSocketPath(path) if err != nil { return err } var dialer net.Dialer conn, err := dialer.DialContext(ctx, "unix", path) if err != nil { return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err) } return terminalSession(ctx, conn, in, out) } // 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 <- terminalSendLines(ctx, conn, in) }() shown := make(chan error, 1) go func() { shown <- terminalShowEvents(conn, out) }() var err error select { case err = <-sent: err = errors.Join(err, conn.Close()) <-shown case err = <-shown: err = errors.Join(terminalCmpError(err, errTerminalDaemonClosed), conn.Close()) case <-ctx.Done(): err = conn.Close() <-shown } if err != nil { return fmt.Errorf("run terminal client: %w", err) } return nil } // 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), socketRequestBytes) for ctx.Err() == nil && scanner.Scan() { line := scanner.Text() request := SocketRequest{ Type: "send", Text: line, } name, stop := stopSubagentName(line) args, whatsapp := whatsappCommandArgs(line) switch { case strings.TrimSpace(line) == "": continue case strings.TrimSpace(line) == stopCommand: request = SocketRequest{ Type: "interrupt", } case stop: request = SocketRequest{ Type: "stop", Text: name, } case whatsapp: request = SocketRequest{ Type: "whatsapp", Text: args, } } err := encoder.Encode(request) if err != nil { return fmt.Errorf("send to the daemon: %w", err) } } err := scanner.Err() if err != nil { return fmt.Errorf("read terminal input: %w", err) } return nil } // terminalShowEvents writes each event from the daemon to out until the // 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) for scanner.Scan() { var event Event err := json.Unmarshal(scanner.Bytes(), &event) if err != nil { return fmt.Errorf("decode event: %w", err) } text := terminalEventText(event) if text == "" { 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) } } err := scanner.Err() if err != nil { return fmt.Errorf("read daemon events: %w", err) } return nil } // 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. The report of a subagent shows whole, and it // starts with the name of the subagent. func terminalEventText(event Event) string { switch event.Kind { case "ready": return "[connected to " + event.Text + "]" case "user": if event.Source == terminalSource { return "" } return "[" + event.Source + "] " + event.Text case "reply": return event.Text case "tool": return "[tool] " + event.Text case "echo": return "[result] " + terminalFirstLines(event.Text, terminalResultLines) case "subagent": return "[" + event.Text + "] started" case "work": return event.Text case "busy": return "[working]" case "idle": return "[done]" case "error": return "[error] " + event.Text case "notice": return event.Text default: return "" } } // 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 { text = strings.TrimRight(text, "\n") lines := strings.Split(text, "\n") if len(lines) <= count { return text } return strings.Join(lines[:count], "\n") + fmt.Sprintf("\n[%d more lines]", len(lines)-count) }