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
14.2 KiB481 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"fmt"
6	"slices"
7	"strings"
8	"time"
9
10	tea "charm.land/bubbletea/v2"
11)
12
13func (m *ChatModel) handleInput(msg tea.Msg) (tea.Cmd, bool) {
14	if cmd, handled := m.handleTerminalClipboard(msg); handled {
15		return cmd, true
16	}
17	var cmds []tea.Cmd
18	switch msg := msg.(type) {
19	case tea.KeyPressMsg:
20		// The selector owns keys while open, before chat navigation or composer input.
21		if len(m.effortOptions) > 0 {
22			switch msg.String() {
23			case "left", "up":
24				m.effortSelected = max(0, m.effortSelected-1)
25			case "right", "down":
26				m.effortSelected = min(len(m.effortOptions)-1, m.effortSelected+1)
27			case "enter", "esc":
28				level := m.effortOptions[m.effortSelected]
29				m.effortOptions = nil
30				m.syncLayout()
31				if msg.String() == "enter" {
32					return func() tea.Msg { return ChatExecuteCommandMsg{Name: "/effort", Args: level} }, true
33				}
34			case "ctrl+c":
35				m.Close()
36				return func() tea.Msg { return ChatQuitMsg{} }, true
37			}
38			return nil, true
39		}
40
41		if msg.String() == "left" && m.TextArea.Value() == "" {
42			return func() tea.Msg { return ChatBackToSessionsMsg{} }, true
43		}
44
45		if msg.String() == "ctrl+c" || (msg.String() == "ctrl+d" && m.TextArea.Focused() && m.TextArea.Value() == "") {
46			m.Close()
47			return func() tea.Msg { return ChatQuitMsg{} }, true
48		}
49		if msg.String() == "ctrl+n" {
50			return func() tea.Msg { return ChatNewSessionMsg{} }, true
51		}
52		if msg.String() == "ctrl+o" {
53			return func() tea.Msg { return ChatOpenAgentsMsg{} }, true
54		}
55		if msg.String() == "esc" && m.dragAnchor != nil {
56			m.dragAnchor, m.dragDir = nil, 0
57			return nil, true
58		}
59
60		inputVal := m.TextArea.Value()
61		consumed := m.CommandMenu.OnKey(
62			msg,
63			inputVal,
64			func(replacement string) {
65				m.TextArea.SetValue(replacement)
66			},
67			func(commandName string) {
68				m.handleSubmittedCommand(commandName, &cmds)
69			},
70		)
71		if consumed {
72			m.syncLayout()
73			return tea.Batch(cmds...), true
74		}
75
76		switch msg.String() {
77		case "esc":
78			if m.isSending {
79				m.interruptDeferred = true
80				m.Stopping = true
81				return m.startAnimation(), true
82			}
83			if m.Status.Running || len(m.pendingUsers) > 0 {
84				m.Stopping = true
85				cmds = append(cmds, m.interruptCmd(), m.startAnimation())
86				return tea.Batch(cmds...), true
87			}
88		case "ctrl+v":
89			return m.pasteImage(), true
90		case "backspace", "ctrl+h", "ctrl+w", "alt+backspace", "ctrl+backspace":
91			if m.deleteMarker(true) {
92				return nil, true
93			}
94		case "delete":
95			if m.deleteMarker(false) {
96				return nil, true
97			}
98		case "ctrl+g":
99			return m.openEditorCmd(), true
100		case "ctrl+l":
101			if m.hostAuth != nil && m.hostAuth.here {
102				return m.signInCmd(), true
103			}
104		case "pgup", "pgdown":
105			delta := m.Viewport.Height()
106			if msg.String() == "pgup" {
107				delta = -delta
108			}
109			m.scrollBy(delta)
110			return nil, true
111		case "up":
112			if m.TextArea.Line() == 0 && m.TextArea.LineInfo().RowOffset == 0 {
113				m.scrollBy(-1)
114				return nil, true
115			}
116		case "down":
117			if m.TextArea.Line() >= m.TextArea.LineCount()-1 && m.TextArea.LineInfo().RowOffset >= m.TextArea.LineInfo().Height-1 {
118				m.scrollBy(1)
119				return nil, true
120			}
121		case "shift+up", "shift+down":
122			m.jumpToYou(msg.String() == "shift+up")
123			return nil, true
124		case "ctrl+home", "ctrl+end":
125			m.Follow = msg.String() == "ctrl+end"
126			if !m.Follow {
127				m.scrollOffset = 0
128			}
129			m.refreshViewportContent()
130			return nil, true
131		case "ctrl+j", "ctrl+k":
132			if msg.String() == "ctrl+j" {
133				m.Flags.Diffs = !m.Flags.Diffs
134			} else {
135				m.Flags.Compaction = !m.Flags.Compaction
136			}
137			m.rebuildSettledLines()
138			m.refreshViewportContent()
139			return nil, true
140		case "ctrl+left":
141			// Bubbles wordLeft never terminates when everything before the cursor
142			// is whitespace. Move to the input start directly in that case.
143			if m.wordBackwardAtStart() {
144				var cmd tea.Cmd
145				m.TextArea, cmd = m.TextArea.Update(tea.KeyPressMsg{Code: tea.KeyHome, Mod: tea.ModCtrl})
146				return cmd, true
147			}
148		case "enter":
149			trimmed := strings.TrimSpace(m.TextArea.Value())
150			// a draft typed while connecting waits in the composer
151			if m.connecting() && (!strings.HasPrefix(trimmed, "/") || !m.isRecognizedCommand(trimmed)) {
152				return nil, true
153			}
154			if trimmed != "" {
155				m.TextArea.Reset()
156				m.syncLayout()
157				m.submitInput(trimmed, &cmds)
158				return tea.Batch(cmds...), true
159			}
160			return nil, true
161		}
162	case dragScrollMsg:
163		cmd := m.dragScrolled(msg)
164		return cmd, true
165	case tea.MouseMsg:
166		firstRow := 2 + m.Notices.ChromeRows()
167		mouse := msg.Mouse()
168		point := func() Point {
169			screen := max(0, min(m.Viewport.Height()-1, mouse.Y-firstRow))
170			return Point{Row: m.scrollOffset + screen, Col: max(0, min(m.Viewport.Width(), mouse.X-m.padding()))}
171		}
172		switch msg := msg.(type) {
173		case tea.MouseReleaseMsg:
174			if m.dragAnchor == nil {
175				return nil, true
176			}
177			m.dragHead = point()
178			sel := Selection{Anchor: *m.dragAnchor, Head: m.dragHead, Gutter: railWidth}
179			isCtrl := m.dragCtrl || mouse.Mod.Contains(tea.ModCtrl) || mouse.Mod.Contains(tea.ModSuper)
180			m.dragAnchor, m.dragDir, m.dragCtrl = nil, 0, false
181			if isCtrl {
182				if cmd := m.openFileAt(sel.Head.Row, sel.Head.Col); cmd != nil {
183					return cmd, true
184				}
185				return nil, true
186			}
187			if sel.IsEmpty() {
188				cmd := m.actAt(sel.Head.Row)
189				return cmd, true
190			}
191			if text := SelectedText(m.frameLines, sel); text != "" {
192				cmd := m.copied(text)
193				return cmd, true
194			}
195		case tea.MouseMotionMsg:
196			if m.dragAnchor != nil {
197				m.dragHead = point()
198				cmd := m.steerDragScroll(mouse.Y - firstRow)
199				return cmd, true
200			}
201		case tea.MouseClickMsg:
202			if msg.Button == tea.MouseLeft && mouse.Y >= firstRow && mouse.Y < firstRow+m.Viewport.Height() {
203				pt := point()
204				m.dragAnchor = &pt
205				m.dragHead = pt
206				m.dragCtrl = mouse.Mod.Contains(tea.ModCtrl) || mouse.Mod.Contains(tea.ModSuper)
207			}
208		case tea.MouseWheelMsg:
209			switch msg.Button {
210			case tea.MouseWheelUp:
211				m.scrollBy(-3)
212			case tea.MouseWheelDown:
213				m.scrollBy(3)
214			}
215			if m.dragAnchor != nil {
216				m.dragHead = point()
217			}
218		}
219		return nil, true
220	case tea.PasteMsg:
221		text := strings.ReplaceAll(msg.Content, "\r\n", "\n")
222		if !collapses(text) {
223			return nil, false
224		}
225		m.TextArea.InsertString(m.Pastes.add(text))
226		m.syncLayout()
227		return nil, true
228	case ClipboardImagePastedMsg:
229		if msg.SessionID != m.SessionID || msg.Generation != m.Generation {
230			return nil, true
231		}
232		switch {
233		case msg.Err != nil:
234			m.AddError(fmt.Sprintf("Could not paste the image: %v", msg.Err))
235		case msg.Image == nil:
236		case len(m.Images.referenced(m.TextArea.Value())) >= MaxPromptImages:
237			m.AddError(fmt.Sprintf("A prompt can attach at most %d images.", MaxPromptImages))
238		default:
239			m.TextArea.InsertString(m.Images.add(pastedImage{*msg.Image, msg.thumb}))
240			m.CopyStatus = msg.hint
241			m.syncLayout()
242			if msg.transmit != "" {
243				return tea.Raw(msg.transmit), true
244			}
245		}
246		return nil, true
247	case ChatEditorFinishedMsg:
248		if msg.SessionID != m.SessionID || msg.Generation != m.Generation {
249			return nil, true
250		}
251		if msg.Edited {
252			m.TextArea.SetValue(strings.TrimRight(msg.Text, "\r\n"))
253			m.syncLayout()
254		}
255		if msg.Err != nil {
256			m.AddError("Could not finish editing the prompt: " + msg.Err.Error())
257		}
258		return nil, true
259	default:
260		return nil, false
261	}
262	return m.updateComposer(msg, cmds), true
263}
264
265func (m *ChatModel) updateComposer(msg tea.Msg, cmds []tea.Cmd) tea.Cmd {
266	oldRows := m.inputRows()
267	var taCmd tea.Cmd
268	m.TextArea, taCmd = m.TextArea.Update(msg)
269	if m.inputRows() != oldRows {
270		m.syncLayout()
271	}
272	cmds = append(cmds, taCmd)
273
274	return tea.Batch(cmds...)
275}
276
277// deleteMarker removes the whole image or paste marker that a deletion at
278// the cursor would cut into, reporting whether there was one.
279func (m *ChatModel) deleteMarker(backward bool) bool {
280	lines := strings.Split(m.TextArea.Value(), "\n")
281	row := m.TextArea.Line()
282	if row >= len(lines) {
283		return false
284	}
285	start, end, ok := markerSpan([]rune(lines[row]), m.TextArea.Column(), backward)
286	if !ok {
287		return false
288	}
289	m.TextArea.SetCursorColumn(end)
290	for range end - start {
291		m.TextArea, _ = m.TextArea.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})
292	}
293	m.syncLayout()
294	return true
295}
296
297// wordBackwardAtStart reports whether the prompt has no word before its cursor.
298// The textarea's word-backward handler loops indefinitely in this case.
299func (m ChatModel) wordBackwardAtStart() bool {
300	lines := strings.Split(m.TextArea.Value(), "\n")
301	row := m.TextArea.Line()
302	if row < 0 || row >= len(lines) {
303		return false
304	}
305	if slices.ContainsFunc(lines[:row], func(line string) bool { return strings.TrimSpace(line) != "" }) {
306		return false
307	}
308	col := m.TextArea.LineInfo().StartColumn + m.TextArea.LineInfo().ColumnOffset
309	current := []rune(lines[row])
310	col = max(0, min(col, len(current)))
311	return strings.TrimSpace(string(current[:col])) == ""
312}
313
314func (m *ChatModel) isRecognizedCommand(input string) bool {
315	trimmed := strings.TrimSpace(input)
316	if !strings.HasPrefix(trimmed, "/") {
317		return false
318	}
319	fields := strings.Fields(trimmed)
320	token, typed := fields[0], len(fields) > 1
321	switch token {
322	case "/a", "/agents", "/sessions", "/q", "/quit", "/exit", "/new", "/model", "/extensions",
323		"/plugins", "/tree", "/quota", "/context", "/t", "/thinking", "/v", "/verbose",
324		"/status", "/login", "/mouse", "/skills", "/instructions", "/mcp", "/cd", "/effort", "/compact", "/kernel", "/reload",
325		"/work", "/paperclips", "/webhooks":
326		return true
327	}
328	// Text after a command that declares no arguments is a prompt that
329	// happens to start with the command's name.
330	return slices.ContainsFunc(m.CommandMenu.Catalog, func(cmd daemon.SessionCommand) bool {
331		return cmd.Name == token && (!typed || len(cmd.Arguments) > 0)
332	})
333}
334
335func (m *ChatModel) submitInput(input string, cmds *[]tea.Cmd) {
336	if strings.HasPrefix(input, "/") && m.isRecognizedCommand(input) {
337		m.handleSubmittedCommand(input, cmds)
338		return
339	}
340
341	if m.pendingSendCount() >= MaxPendingUsers {
342		m.AddError("Too many messages are waiting. Wait for the current reply to finish before sending another message.")
343		m.refreshViewportContent()
344		return
345	}
346
347	m.ClearNotices()
348	m.TurnFailed = false
349	m.Stopped = false
350
351	continuation := input == "."
352	var images []daemon.ImageAttachment
353	var pastes []string
354	if !continuation {
355		var pasted []pastedImage
356		input, pasted = m.Images.resolve(input)
357		for _, image := range pasted {
358			images = append(images, image.ImageAttachment)
359		}
360		input, pastes = m.Pastes.resolve(input)
361	}
362	handle, err := m.client.PrepareTurn(input, images, pastes, continuation)
363	if err != nil {
364		m.AddError(err.Error())
365		return
366	}
367	cmd := m.sendCmd(handle, input, images, pastes, continuation)
368	if continuation {
369		if m.pendingContinuations == nil {
370			m.pendingContinuations = make(map[string]pendingOperation)
371		}
372		m.pendingContinuations[handle.ID()] = pendingOperation{Handle: handle}
373	}
374	if !continuation {
375		m.Images.restore(nil)
376		m.Pastes.restore(nil)
377		m.pendingUsers = append(m.pendingUsers, PendingUserTurn{Text: input, Images: images, Pastes: pastes, At: time.Now().UnixMilli(), Handle: handle, OperationID: handle.ID()})
378	}
379	m.isSending, m.sentHere, m.Follow = true, true, true
380	m.reseedMood()
381	m.refreshViewportContent()
382	*cmds = append(*cmds, cmd, m.startAnimation())
383}
384
385func (m *ChatModel) handleSubmittedCommand(input string, cmds *[]tea.Cmd) {
386	m.TextArea.Reset()
387	m.syncLayout()
388	trimmed := strings.TrimSpace(input)
389	emit := func(msg tea.Msg) { *cmds = append(*cmds, func() tea.Msg { return msg }) }
390
391	name, args, _ := strings.Cut(trimmed, " ")
392	commandIndex := slices.IndexFunc(m.CommandMenu.Catalog, func(command daemon.SessionCommand) bool {
393		return command.Name == name && command.Delivery == "input"
394	})
395	if commandIndex >= 0 {
396		command := m.CommandMenu.Catalog[commandIndex]
397		if m.pendingSendCount() >= MaxPendingUsers {
398			m.AddError("Too many messages are waiting.")
399			return
400		}
401		var handle *daemon.OperationHandle
402		var err error
403		arguments, err := daemon.ParseCommandArguments(command, args)
404		if err == nil {
405			handle, err = m.client.PrepareCommand(command.CommandID, arguments)
406		}
407		if err != nil {
408			m.TextArea.SetValue(input)
409			m.AddError(err.Error())
410			return
411		}
412		m.pendingUsers = append(m.pendingUsers, PendingUserTurn{Text: trimmed, At: time.Now().UnixMilli(), Handle: handle, OperationID: handle.ID()})
413		m.isSending, m.sentHere, m.Follow = true, true, true
414		m.TurnFailed, m.Stopped = false, false
415		*cmds = append(*cmds, m.sendCmd(handle, trimmed, nil, nil, false), m.startAnimation())
416		m.refreshViewportContent()
417		return
418	}
419
420	switch trimmed {
421	case "/agents":
422		emit(ChatOpenAgentsMsg{})
423	case "/a", "/sessions":
424		emit(ChatBackToSessionsMsg{})
425	case "/q", "/quit", "/exit":
426		m.Close()
427		emit(ChatQuitMsg{})
428	case "/new":
429		emit(ChatNewSessionMsg{})
430	case "/model":
431		emit(ChatOpenModelPickerMsg{})
432	case "/extensions", "/plugins":
433		emit(ChatOpenExtensionPickerMsg{})
434	case "/tree":
435		emit(ChatOpenTreePickerMsg{})
436	case "/quota":
437		emit(ChatOpenQuotaMsg{})
438	case "/context":
439		emit(ChatOpenContextInspectorMsg{})
440	case "/ttl", "/requests":
441		emit(ChatOpenPageMsg{Command: trimmed})
442	case "/webhooks":
443		emit(ChatOpenWebhooksPageMsg{})
444	case "/skills", "/instructions", "/mcp":
445		emit(ChatOpenCapabilityPageMsg{Kind: trimmed[1:]})
446	case "/t", "/thinking", "/v", "/verbose":
447		if trimmed == "/t" || trimmed == "/thinking" {
448			m.Flags.Thinking = !m.Flags.Thinking
449		} else {
450			m.Flags.Tools = !m.Flags.Tools
451		}
452		m.rebuildSettledLines()
453		m.refreshViewportContent()
454	case "/status":
455		statusText := fmt.Sprintf("session: %s\nworkspace: %s\nmodel: %s", m.SessionID, m.Workspace, m.Model)
456		if m.Effort != "" {
457			statusText += "\neffort: " + m.Effort
458		}
459		if m.Usage != nil {
460			statusText += "\ntokens: " + formatUsage(m.Usage)
461		}
462		m.appendSettledEntry(HistoryEntry{Kind: EntryNote, Text: statusText})
463		m.refreshViewportContent()
464	default:
465		switch {
466		case strings.HasPrefix(trimmed, "/model "):
467			emit(ChatExecuteCommandMsg{Name: "/model", Args: strings.TrimSpace(trimmed[7:])})
468		case strings.HasPrefix(trimmed, "/login"):
469			emit(ChatOpenLoginMsg{Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "/login"))})
470		default:
471			for _, cmd := range m.CommandMenu.Catalog {
472				if cmd.Name == trimmed && cmd.Page != nil && *cmd.Page {
473					emit(ChatOpenPageMsg{Command: trimmed})
474					return
475				}
476			}
477			cmdName, cmdArgs, _ := strings.Cut(trimmed, " ")
478			emit(ChatExecuteCommandMsg{Name: cmdName, Args: cmdArgs})
479		}
480	}
481}