okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: ctrl-click a path in the transcript to open it in an editorcc6ab84

main
8.9 KiB300 linesraw
1package tui
2
3import (
4	"cmp"
5	"errors"
6	"fmt"
7	"io"
8	"os"
9	"os/exec"
10	"path/filepath"
11	"regexp"
12	"slices"
13	"strconv"
14	"strings"
15
16	"albedo/cli/internal/daemon"
17	tea "charm.land/bubbletea/v2"
18	"github.com/charmbracelet/x/ansi"
19)
20
21func (m ChatModel) openEditorCmd() tea.Cmd {
22	args := strings.Fields(cmp.Or(os.Getenv("EDITOR"), os.Getenv("VISUAL"), "nano"))
23	if len(args) == 0 {
24		args = []string{"nano"}
25	}
26	process := &promptEditorProcess{
27		editorProcess: editorProcess{exec.Command(args[0], args[1:]...), m.graphemes},
28		prompt:        m.TextArea.Value(),
29	}
30	sessionID, generation := m.SessionID, m.Generation
31	return tea.Exec(process, func(err error) tea.Msg {
32		return ChatEditorFinishedMsg{SessionID: sessionID, Generation: generation, Text: process.text, Edited: process.edited, Err: err}
33	})
34}
35
36// promptEditorProcess owns the prompt file for the complete editor operation.
37// Its completion message carries text even when the UI no longer owns the session.
38type promptEditorProcess struct {
39	editorProcess
40	prompt string
41	text   string
42	edited bool
43}
44
45func (p *promptEditorProcess) Run() error {
46	file, err := os.CreateTemp("", "albedo-prompt-*.md")
47	if err != nil {
48		return fmt.Errorf("could not create temporary file: %w", err)
49	}
50	path := file.Name()
51	defer os.Remove(path)
52	if _, err := file.WriteString(p.prompt); err != nil {
53		file.Close()
54		return fmt.Errorf("could not write to temporary file: %w", err)
55	}
56	if err := file.Close(); err != nil {
57		return fmt.Errorf("could not close temporary file: %w", err)
58	}
59
60	p.Args = append(p.Args, path)
61	commandErr, restoreErr := p.runAndRestore()
62	if commandErr != nil {
63		return errors.Join(commandErr, restoreErr)
64	}
65	data, err := os.ReadFile(path)
66	if err != nil {
67		return errors.Join(fmt.Errorf("could not read the edited prompt: %w", err), restoreErr)
68	}
69	p.text = string(data)
70	p.edited = true
71	return restoreErr
72}
73
74// editorProcess runs the editor on the terminal, then hands the terminal back
75// the way Bubble Tea left it. Bubble Tea repaints from where it left the
76// cursor and trusts the column, but leaving the alternate screen puts the
77// cursor back wherever the editor found it, and a terminal reply echoed
78// before the editor took raw mode can have moved it along the row; the
79// repaint then starts mid-row, wraps the header and scrolls it off the top.
80// Bubble Tea also turns grapheme widths off for the editor and never turns
81// them back on, while it keeps measuring by grapheme.
82type editorProcess struct {
83	*exec.Cmd
84	graphemes bool
85}
86
87func (p editorProcess) SetStdin(r io.Reader)  { p.Stdin = r }
88func (p editorProcess) SetStdout(w io.Writer) { p.Stdout = w }
89func (p editorProcess) SetStderr(w io.Writer) { p.Stderr = w }
90
91func (p editorProcess) Run() error {
92	return errors.Join(p.runAndRestore())
93}
94
95func (p editorProcess) runAndRestore() (commandErr, restoreErr error) {
96	commandErr = p.Cmd.Run()
97	restore := "\r"
98	if p.graphemes {
99		restore += ansi.SetModeUnicodeCore
100	}
101	written, err := io.WriteString(p.Stdout, restore)
102	if err == nil && written != len(restore) {
103		err = io.ErrShortWrite
104	}
105	if err != nil {
106		restoreErr = fmt.Errorf("could not restore terminal output after the editor: %w", err)
107	}
108	return commandErr, restoreErr
109}
110
111// FileTarget is a file location clicked in the transcript.
112type FileTarget struct {
113	Path      string
114	Line, Col int
115}
116
117var (
118	// pathToken is a run of text that quotes, brackets, and spaces end.
119	pathToken = regexp.MustCompile("[^\\s\"'`()\\[\\]{}<>]+")
120	// position is a trailing :line[:col] or #Lline.
121	position   = regexp.MustCompile(`(?::(\d+)(?::(\d+))?|#L(\d+))$`)
122	diffGutter = regexp.MustCompile(`^\s*(\d+) [+\- ] `)
123)
124
125// openFileAt opens the file named at transcript position (row, col).
126func (m *ChatModel) openFileAt(row, col int) tea.Cmd {
127	if row < 0 || row >= len(m.frameLines) {
128		return nil
129	}
130	target, ok := fileTargetAt(ansi.Strip(m.frameLines[row]), col, m.Workspace)
131	if !ok {
132		target, ok = m.diffTargetAt(row)
133	}
134	if !ok {
135		return nil
136	}
137	return m.openFileCmd(target)
138}
139
140// diffTargetAt names the line of a diff row: the path is the first row above
141// it that is neither a numbered row nor a hunk header.
142func (m *ChatModel) diffTargetAt(row int) (FileTarget, bool) {
143	gutter := diffGutter.FindStringSubmatch(rowBody(m.frameLines[row]))
144	if gutter == nil {
145		return FileTarget{}, false
146	}
147	for prev := row - 1; prev >= 0; prev-- {
148		above := rowBody(m.frameLines[prev])
149		if diffGutter.MatchString(above) || strings.Contains(above, "@@") {
150			continue
151		}
152		target, ok := fileTargetAt(above, 0, m.Workspace)
153		target.Line, _ = strconv.Atoi(gutter[1])
154		target.Col = 0
155		return target, ok
156	}
157	return FileTarget{}, false
158}
159
160// rowBody is a transcript row's plain text after its rail.
161func rowBody(row string) string {
162	plain := ansi.Strip(row)
163	return ansi.Cut(plain, railWidth, ansi.StringWidth(plain))
164}
165
166// fileTargetAt is the existing file named by the token under column col of
167// plain, else the only existing file plain names.
168func fileTargetAt(plain string, col int, workspace string) (FileTarget, bool) {
169	at := len(ansi.Truncate(plain, col, ""))
170	var found []FileTarget
171	for _, span := range pathToken.FindAllStringIndex(plain, -1) {
172		target, ok := parseTarget(plain[span[0]:span[1]], workspace)
173		if !ok {
174			continue
175		}
176		if span[0] <= at && at <= span[1] {
177			return target, true
178		}
179		found = append(found, target)
180	}
181	if len(found) == 1 {
182		return found[0], true
183	}
184	return FileTarget{}, false
185}
186
187// parseTarget reads a token such as path=a/x.go:12:3 or file://x.go#L4,
188// keeping it only when it names a file that exists.
189func parseTarget(token, workspace string) (FileTarget, bool) {
190	token = strings.TrimRight(token, ".,;:")
191	if strings.Contains(token, "://") && !strings.HasPrefix(token, "file://") {
192		return FileTarget{}, false
193	}
194	token = strings.TrimPrefix(token, "file://")
195	if _, after, ok := strings.Cut(token, "="); ok {
196		token = after
197	}
198	var target FileTarget
199	if loc := position.FindStringSubmatchIndex(token); loc != nil {
200		groups := position.FindStringSubmatch(token)
201		target.Line, _ = strconv.Atoi(groups[1] + groups[3])
202		target.Col, _ = strconv.Atoi(groups[2])
203		token = token[:loc[0]]
204	}
205	if token == "" {
206		return FileTarget{}, false
207	}
208	home, _ := os.UserHomeDir()
209	_, root := daemon.SplitLocation(workspace)
210	paths := []string{token}
211	// a diff's a/ and b/ prefixes
212	if rest, ok := strings.CutPrefix(token, "a/"); ok {
213		paths = append(paths, rest)
214	} else if rest, ok := strings.CutPrefix(token, "b/"); ok {
215		paths = append(paths, rest)
216	}
217	for _, p := range paths {
218		p = expandHome(p, home)
219		if !filepath.IsAbs(p) {
220			p = filepath.Join(cmp.Or(root, "."), p)
221		}
222		if info, err := os.Stat(p); err == nil && info.Mode().IsRegular() {
223			target.Path, _ = filepath.Abs(p)
224			return target, true
225		}
226	}
227	return FileTarget{}, false
228}
229
230func (m ChatModel) openFileCmd(target FileTarget) tea.Cmd {
231	bin, args, terminal := detectEditor(target)
232	sessionID := m.SessionID
233	if terminal {
234		process := editorProcess{exec.Command(bin, args...), m.graphemes}
235		return tea.Exec(process, func(err error) tea.Msg {
236			return FileOpenResultMsg{SessionID: sessionID, Target: target, Err: err}
237		})
238	}
239	return func() tea.Msg {
240		cmd := exec.Command(bin, args...)
241		err := cmd.Start()
242		if err == nil {
243			go cmd.Wait()
244		}
245		return FileOpenResultMsg{SessionID: sessionID, Target: target, Err: err}
246	}
247}
248
249// guiEditors open a file in a window of their own and return at once; how
250// each takes a line is in editorArgs.
251var guiEditors = []string{"code", "cursor", "windsurf", "codium", "zed", "subl"}
252
253// detectEditor picks the IDE whose terminal albedo runs in, else $EDITOR,
254// and says whether the editor needs this terminal.
255func detectEditor(target FileTarget) (bin string, args []string, terminal bool) {
256	if ide := terminalIDE(); ide != "" {
257		if path, err := exec.LookPath(ide); err == nil {
258			return path, editorArgs(ide, target), false
259		}
260	}
261	fields := strings.Fields(cmp.Or(os.Getenv("EDITOR"), os.Getenv("VISUAL"), "nano"))
262	base := filepath.Base(fields[0])
263	return fields[0], append(fields[1:], editorArgs(base, target)...), !slices.Contains(guiEditors, base)
264}
265
266// terminalIDE names the CLI of the editor whose integrated terminal this is.
267func terminalIDE() string {
268	env := os.Getenv
269	switch {
270	case env("TERM_PROGRAM") == "zed" || env("ZED_TERM") != "":
271		return "zed"
272	case env("TERM_PROGRAM") != "vscode" && env("VSCODE_PID") == "":
273		return ""
274	case env("CURSOR_TRACE_ID") != "" || env("CURSOR_VERSION") != "":
275		return "cursor"
276	case env("WINDSURF_VERSION") != "":
277		return "windsurf"
278	}
279	return "code"
280}
281
282func editorArgs(editor string, t FileTarget) []string {
283	at := t.Path
284	if t.Line > 0 {
285		at += ":" + strconv.Itoa(t.Line)
286		if t.Col > 0 {
287			at += ":" + strconv.Itoa(t.Col)
288		}
289	}
290	switch {
291	case t.Line == 0:
292		return []string{t.Path}
293	case editor == "code" || editor == "cursor" || editor == "windsurf" || editor == "codium":
294		return []string{"-g", at}
295	case editor == "zed" || editor == "subl" || editor == "hx":
296		return []string{at}
297	}
298	// vi, nano, emacs, and most others
299	return []string{"+" + strconv.Itoa(t.Line), t.Path}
300}