]> git.giacomolli.uk - onechat/blob - terminal.go
Complete context on forwarded local errors
[onechat] / terminal.go
1 package main
2
3 import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net"
11 "path/filepath"
12 "strings"
13
14 "golang.org/x/sys/unix"
15 )
16
17 // terminalResultLines is the most lines of a tool result that the client shows.
18 const terminalResultLines = 10
19
20 // errTerminalDaemonClosed tells that the daemon closed the connection.
21 var errTerminalDaemonClosed = errors.New("the daemon closed the connection")
22
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 {
31 dir, err := dataDir()
32 if err != nil {
33 return fmt.Errorf("find data directory: %w", err)
34 }
35
36 path := filepath.Join(dir, socketFileName)
37 err = checkSocketPath(path)
38 if err != nil {
39 return fmt.Errorf("check terminal socket path: %w", err)
40 }
41
42 var dialer net.Dialer
43
44 conn, err := dialer.DialContext(ctx, "unix", path)
45 if err != nil {
46 return fmt.Errorf(`connect to the daemon (start it with "onechat daemon"): %w`, err)
47 }
48
49 err = terminalSession(ctx, conn, in, out)
50 if err != nil {
51 return fmt.Errorf("run terminal session: %w", err)
52 }
53
54 return nil
55 }
56
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)
62 go func() {
63 sent <- terminalSendLines(ctx, conn, in)
64 }()
65
66 shown := make(chan error, 1)
67 go func() {
68 shown <- terminalShowEvents(conn, out)
69 }()
70
71 var err error
72
73 select {
74 case err = <-sent:
75 err = errors.Join(err, conn.Close())
76
77 <-shown
78 case err = <-shown:
79 err = errors.Join(terminalCmpError(err, errTerminalDaemonClosed), conn.Close())
80 case <-ctx.Done():
81 err = conn.Close()
82
83 <-shown
84 }
85
86 if err != nil {
87 return fmt.Errorf("run terminal client: %w", err)
88 }
89
90 return nil
91 }
92
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)
98
99 for ctx.Err() == nil && scanner.Scan() {
100 line := scanner.Text()
101 request := SocketRequest{
102 Type: "send",
103 Text: line,
104 }
105
106 name, stop := stopSubagentName(line)
107 args, whatsapp := whatsappCommandArgs(line)
108 image, isImage := terminalImageRequest(line)
109 switch {
110 case strings.TrimSpace(line) == "":
111 continue
112 case strings.TrimSpace(line) == stopCommand:
113 request = SocketRequest{
114 Type: "interrupt",
115 }
116 case stop:
117 request = SocketRequest{
118 Type: "stop",
119 Text: name,
120 }
121 case isImage:
122 request = image
123 if request.Path != "" {
124 var err error
125
126 request.Path, err = filepath.Abs(request.Path)
127 if err != nil {
128 return fmt.Errorf("find terminal image path: %w", err)
129 }
130 }
131 case whatsapp:
132 request = SocketRequest{
133 Type: "whatsapp",
134 Text: args,
135 }
136 }
137
138 err := encoder.Encode(request)
139 if err != nil {
140 return fmt.Errorf("send to the daemon: %w", err)
141 }
142 }
143
144 err := scanner.Err()
145 if err != nil {
146 return fmt.Errorf("read terminal input: %w", err)
147 }
148
149 return nil
150 }
151
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)
160
161 for scanner.Scan() {
162 var event Event
163
164 err := json.Unmarshal(scanner.Bytes(), &event)
165 if err != nil {
166 return fmt.Errorf("decode event: %w", err)
167 }
168
169 text := terminalEventText(event)
170 if text == "" {
171 continue
172 }
173
174 columns := terminalColumns(out)
175 if columns > 0 && (event.Kind == "reply" || event.Kind == "work") {
176 text = strings.Join(terminalMarkdown(text, columns), "\n")
177 }
178
179 switch event.Kind {
180 case "busy":
181 busy = true
182 case "idle":
183 busy = false
184 }
185
186 text += "\n"
187 if prompt {
188 text = "\r\x1b[2K" + text
189 }
190
191 prompt = columns > 0 && !busy
192 if prompt {
193 text += "> "
194 }
195
196 _, err = io.WriteString(out, text)
197 if err != nil {
198 return fmt.Errorf("write terminal event: %w", err)
199 }
200 }
201
202 err := scanner.Err()
203 if err != nil {
204 return fmt.Errorf("read daemon events: %w", err)
205 }
206
207 return nil
208 }
209
210 // terminalCmpError returns err, or fallback when err is nil.
211 func terminalCmpError(err, fallback error) error {
212 if err == nil {
213 return fallback
214 }
215
216 return err
217 }
218
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 {
225 switch event.Kind {
226 case "ready":
227 return "[connected to " + event.Text + "]"
228 case "user":
229 if event.Source == terminalSource {
230 return ""
231 }
232
233 return "[" + event.Source + "] " + event.Text
234 case "reply":
235 return event.Text
236 case "tool":
237 return "[tool] " + event.Text
238 case "echo":
239 return "[result] " + terminalFirstLines(event.Text, terminalResultLines)
240 case "subagent":
241 return "[" + event.Text + "] started"
242 case "work":
243 return event.Text
244 case "busy":
245 return "[working]"
246 case "idle":
247 return "[done]"
248 case "error":
249 return "[error] " + event.Text
250 case "notice":
251 return event.Text
252 default:
253 return ""
254 }
255 }
256
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 })
261 if !ok {
262 return 0
263 }
264
265 size, err := unix.IoctlGetWinsize(int(file.Fd()), unix.TIOCGWINSZ)
266 if err != nil {
267 return 0
268 }
269
270 if size.Col == 0 {
271 return 80
272 }
273
274 return int(size.Col)
275 }
276
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 {
283 return text
284 }
285
286 return strings.Join(lines[:count], "\n") + fmt.Sprintf("\n[%d more lines]", len(lines)-count)
287 }