]> git.giacomolli.uk - onechat/blob - terminal.go
Derive contexts from the caller and name timeouts
[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 err
34 }
35
36 path := filepath.Join(dir, socketFileName)
37 err = checkSocketPath(path)
38 if err != nil {
39 return 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 return terminalSession(ctx, conn, in, out)
50 }
51
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)
57 go func() {
58 sent <- terminalSendLines(ctx, conn, in)
59 }()
60
61 shown := make(chan error, 1)
62 go func() {
63 shown <- terminalShowEvents(conn, out)
64 }()
65
66 var err error
67
68 select {
69 case err = <-sent:
70 err = errors.Join(err, conn.Close())
71
72 <-shown
73 case err = <-shown:
74 err = errors.Join(terminalCmpError(err, errTerminalDaemonClosed), conn.Close())
75 case <-ctx.Done():
76 err = conn.Close()
77
78 <-shown
79 }
80
81 if err != nil {
82 return fmt.Errorf("run terminal client: %w", err)
83 }
84
85 return nil
86 }
87
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)
93
94 for ctx.Err() == nil && scanner.Scan() {
95 line := scanner.Text()
96 request := SocketRequest{
97 Type: "send",
98 Text: line,
99 }
100
101 name, stop := stopSubagentName(line)
102 args, whatsapp := whatsappCommandArgs(line)
103 image, isImage := terminalImageRequest(line)
104 switch {
105 case strings.TrimSpace(line) == "":
106 continue
107 case strings.TrimSpace(line) == stopCommand:
108 request = SocketRequest{
109 Type: "interrupt",
110 }
111 case stop:
112 request = SocketRequest{
113 Type: "stop",
114 Text: name,
115 }
116 case isImage:
117 request = image
118 if request.Path != "" {
119 var err error
120
121 request.Path, err = filepath.Abs(request.Path)
122 if err != nil {
123 return fmt.Errorf("find terminal image path: %w", err)
124 }
125 }
126 case whatsapp:
127 request = SocketRequest{
128 Type: "whatsapp",
129 Text: args,
130 }
131 }
132
133 err := encoder.Encode(request)
134 if err != nil {
135 return fmt.Errorf("send to the daemon: %w", err)
136 }
137 }
138
139 err := scanner.Err()
140 if err != nil {
141 return fmt.Errorf("read terminal input: %w", err)
142 }
143
144 return nil
145 }
146
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)
155
156 for scanner.Scan() {
157 var event Event
158
159 err := json.Unmarshal(scanner.Bytes(), &event)
160 if err != nil {
161 return fmt.Errorf("decode event: %w", err)
162 }
163
164 text := terminalEventText(event)
165 if text == "" {
166 continue
167 }
168
169 columns := terminalColumns(out)
170 if columns > 0 && (event.Kind == "reply" || event.Kind == "work") {
171 text = strings.Join(terminalMarkdown(text, columns), "\n")
172 }
173
174 switch event.Kind {
175 case "busy":
176 busy = true
177 case "idle":
178 busy = false
179 }
180
181 text += "\n"
182 if prompt {
183 text = "\r\x1b[2K" + text
184 }
185
186 prompt = columns > 0 && !busy
187 if prompt {
188 text += "> "
189 }
190
191 _, err = io.WriteString(out, text)
192 if err != nil {
193 return fmt.Errorf("write terminal event: %w", err)
194 }
195 }
196
197 err := scanner.Err()
198 if err != nil {
199 return fmt.Errorf("read daemon events: %w", err)
200 }
201
202 return nil
203 }
204
205 // terminalCmpError returns err, or fallback when err is nil.
206 func terminalCmpError(err, fallback error) error {
207 if err == nil {
208 return fallback
209 }
210
211 return err
212 }
213
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 {
220 switch event.Kind {
221 case "ready":
222 return "[connected to " + event.Text + "]"
223 case "user":
224 if event.Source == terminalSource {
225 return ""
226 }
227
228 return "[" + event.Source + "] " + event.Text
229 case "reply":
230 return event.Text
231 case "tool":
232 return "[tool] " + event.Text
233 case "echo":
234 return "[result] " + terminalFirstLines(event.Text, terminalResultLines)
235 case "subagent":
236 return "[" + event.Text + "] started"
237 case "work":
238 return event.Text
239 case "busy":
240 return "[working]"
241 case "idle":
242 return "[done]"
243 case "error":
244 return "[error] " + event.Text
245 case "notice":
246 return event.Text
247 default:
248 return ""
249 }
250 }
251
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 })
256 if !ok {
257 return 0
258 }
259
260 size, err := unix.IoctlGetWinsize(int(file.Fd()), unix.TIOCGWINSZ)
261 if err != nil {
262 return 0
263 }
264
265 if size.Col == 0 {
266 return 80
267 }
268
269 return int(size.Col)
270 }
271
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 {
278 return text
279 }
280
281 return strings.Join(lines[:count], "\n") + fmt.Sprintf("\n[%d more lines]", len(lines)-count)
282 }