okami.mom/albedo

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

dawntui: clear stopping state when session becomes idlebb7b485

main
9.5 KiB281 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"context"
6	"errors"
7	"fmt"
8	"slices"
9	"strings"
10	"time"
11
12	tea "charm.land/bubbletea/v2"
13)
14
15// Moved takes the session's new folder, then sends again the turn a
16// missing folder refused.
17func (m *ChatModel) Moved(moved daemon.Session, retry *WorkspaceRetry) tea.Cmd {
18	workspace, from := moved.Workspace, m.Workspace
19	m.dropSignIn() // the offer was for the old folder's host
20	// a hint from an earlier move names a folder this one did not leave
21	m.Notices = slices.DeleteFunc(m.Notices, func(n Notice) bool { return strings.HasPrefix(n.Message, linkHintText) })
22	m.Notices.AddNotice(linkHint(from, workspace, retry))
23	m.syncViewportHeight()
24	m.Workspace, m.Host, m.hostHome = workspace, sessionHost(moved), ""
25	m.Renderer.Workspace = workspace
26	m.rebuildSettledLines() // settled rows name paths from the old workspace
27	m.refreshViewportContent()
28	return m.resend(retry)
29}
30
31const linkHintText = "to share memory and work with the folder you left: /link add "
32
33// linkHint offers linking the folder a session just left with the one it
34// moved to, as a command to paste. The folder is named as stored, so the
35// link lands on the key the left folder's memory and work items use. A
36// session that moved because its folder went missing has nothing to link.
37func linkHint(from, to string, retry *WorkspaceRetry) string {
38	if from == "" || from == to || retry != nil {
39		return ""
40	}
41	return linkHintText + from
42}
43
44// resend sends again a turn the daemon refused, once what refused it is fixed.
45func (m *ChatModel) resend(retry *WorkspaceRetry) tea.Cmd {
46	if retry == nil {
47		return nil
48	}
49	m.Images.restore(unseen(retry.Images))
50	m.Pastes.restore(retry.Pastes)
51	m.TextArea.Reset()
52	m.syncLayout()
53	var cmds []tea.Cmd
54	prompt := retry.Prompt
55	if retry.Continue {
56		prompt = "."
57	}
58	m.submitInput(prompt, &cmds)
59	return tea.Batch(cmds...)
60}
61
62func (m ChatModel) queryOperationCmd(handle *daemon.OperationHandle) tea.Cmd {
63	client, id, gen, ctx := m.client, m.SessionID, m.Generation, m.streamCtx
64	return func() tea.Msg {
65		receipt, err := client.ResolveOperation(ctx, handle)
66		return ChatOperationResolvedMsg{SessionID: id, Generation: gen, Handle: handle, Receipt: receipt, Err: err}
67	}
68}
69
70func (m ChatModel) resolveOperationCmd(handle *daemon.OperationHandle) tea.Cmd {
71	id, gen := m.SessionID, m.Generation
72	return tea.Tick(15*time.Second, func(time.Time) tea.Msg { return ChatOperationPollMsg{SessionID: id, Generation: gen, Handle: handle} })
73}
74
75func (m *ChatModel) sendCmd(handle *daemon.OperationHandle, prompt string, images []daemon.ImageAttachment, pastes []string, isCont bool) tea.Cmd {
76	client, id, gen := m.client, m.SessionID, m.Generation
77	return func() tea.Msg {
78		msg := ChatTurnSentMsg{SessionID: id, Generation: gen, Prompt: prompt, Images: images, Pastes: pastes, Continue: isCont, Handle: handle, OperationID: handle.ID()}
79		result, err := client.SubmitOperation(context.Background(), handle)
80		msg.Err = err
81		if err == nil && result != nil {
82			msg.OK, msg.Queued, msg.AcceptanceOrder = result.OK, result.Queued, result.AcceptanceOrder
83		}
84		return msg
85	}
86}
87
88func (m *ChatModel) interruptCmd() tea.Cmd {
89	client, id, gen := m.client, m.SessionID, m.Generation
90	status := m.Status
91	return func() tea.Msg {
92		ok, err := client.InterruptObserved(context.Background(), status)
93		return ChatInterruptMsg{SessionID: id, Generation: gen, Interrupted: ok, Err: err}
94	}
95}
96
97func (m ChatModel) pendingSendCount() int {
98	count := 0
99	for _, pending := range m.pendingUsers {
100		if !pending.Expired {
101			count++
102		}
103	}
104	return count
105}
106
107func (m ChatModel) operationRecoverable(id string) bool {
108	for _, pending := range m.pendingUsers {
109		if pending.OperationID == id {
110			return !pending.Expired
111		}
112	}
113	pending, exists := m.pendingContinuations[id]
114	return exists && !pending.Expired
115}
116
117func (m *ChatModel) reconcileStopped() {
118	if m.Stopping && !m.isSending && !m.interruptDeferred && m.pendingSendCount() == 0 && (!m.Status.Running || m.Status.Idle) {
119		m.Stopping = false
120		m.Stopped = true
121		m.clearAction()
122	}
123}
124
125func (m *ChatModel) applyInputReceipt(receipt daemon.InputReceipt) {
126	m.Status.InputOrder = max(m.Status.InputOrder, receipt.InputOrder())
127	for i := range m.pendingUsers {
128		if m.pendingUsers[i].OperationID == receipt.ID {
129			m.pendingUsers[i].Queued = receipt.Pending()
130			if receipt.Settled() {
131				m.pendingUsers = slices.Delete(m.pendingUsers, i, i+1)
132			}
133			m.reconcileStopped()
134			return
135		}
136	}
137	if receipt.Settled() {
138		delete(m.pendingContinuations, receipt.ID)
139	}
140	m.reconcileStopped()
141}
142
143func (m *ChatModel) handleOperationResults(msg tea.Msg) (tea.Cmd, bool) {
144	var cmds []tea.Cmd
145	switch msg := msg.(type) {
146	case ChatOperationPollMsg:
147		if msg.SessionID != m.SessionID || msg.Generation != m.Generation || !m.operationRecoverable(msg.Handle.ID()) {
148			return nil, true
149		}
150		return m.queryOperationCmd(msg.Handle), true
151	case ChatOperationResolvedMsg:
152		if msg.SessionID != m.SessionID || msg.Generation != m.Generation {
153			return nil, true
154		}
155		index := slices.IndexFunc(m.pendingUsers, func(p PendingUserTurn) bool { return p.OperationID == msg.Handle.ID() })
156		if !m.operationRecoverable(msg.Handle.ID()) {
157			return nil, true
158		}
159		if daemon.IsOperationExpired(msg.Err) {
160			if index >= 0 {
161				m.pendingUsers[index].Expired = true
162			} else {
163				pending := m.pendingContinuations[msg.Handle.ID()]
164				pending.Expired = true
165				m.pendingContinuations[msg.Handle.ID()] = pending
166			}
167			m.refreshViewportContent()
168			return nil, true
169		}
170		if msg.Err == nil && (msg.Receipt.Admission == "rejected" || msg.Receipt.Settled()) {
171			delete(m.pendingContinuations, msg.Handle.ID())
172			m.dropSignIn()
173			if rejection := msg.Receipt.Rejection(); rejection != nil {
174				m.ClearNotices()
175				m.AddError(rejection.Error())
176			}
177			if index >= 0 {
178				m.pendingUsers = slices.Delete(m.pendingUsers, index, index+1)
179			}
180			m.refreshViewportContent()
181			m.reconcileStopped()
182			return nil, true
183		}
184		if msg.Err == nil {
185			m.Status.InputOrder = max(m.Status.InputOrder, msg.Receipt.InputOrder())
186		}
187		if index >= 0 && msg.Err == nil {
188			m.pendingUsers[index].Queued = true
189			m.pendingUsers[index].BlockingReason = msg.Receipt.BlockingDetail()
190			m.refreshViewportContent()
191		}
192		return tea.Batch(m.resolveOperationCmd(msg.Handle), m.hostAuthCmd(msg.Receipt.BlockingDetail())), true
193	case ChatTurnSentMsg:
194		if msg.SessionID != m.SessionID || msg.Generation != m.Generation {
195			return nil, true
196		}
197		m.isSending = false
198		if msg.Err == nil {
199			m.Status.InputOrder = max(m.Status.InputOrder, msg.AcceptanceOrder)
200		}
201		if msg.Err != nil {
202			m.interruptDeferred = false
203			if _, uncertain := errors.AsType[*daemon.UncertainOutcomeError](msg.Err); uncertain {
204				if msg.Handle != nil && m.operationRecoverable(msg.Handle.ID()) && daemon.IsOperationExpired(msg.Err) {
205					updated, cmd := m.Update(ChatOperationResolvedMsg{SessionID: m.SessionID, Generation: m.Generation, Handle: msg.Handle, Err: msg.Err})
206					*m = updated
207					return cmd, true
208				}
209				message := "Message admission is uncertain. Checking its operation receipt."
210				if msg.Continue {
211					message = "Continue admission is uncertain. Checking its operation receipt."
212				}
213				m.AddError(message)
214				m.refreshViewportContent()
215				if msg.Handle != nil && m.operationRecoverable(msg.Handle.ID()) {
216					return m.resolveOperationCmd(msg.Handle), true
217				}
218				return nil, true
219			}
220			delete(m.pendingContinuations, msg.OperationID)
221			if !msg.Continue {
222				if i := slices.IndexFunc(m.pendingUsers, func(p PendingUserTurn) bool { return p.OperationID == msg.OperationID }); i >= 0 {
223					m.pendingUsers = slices.Delete(m.pendingUsers, i, i+1)
224				}
225				if m.TextArea.Value() == "" {
226					m.TextArea.SetValue(msg.Prompt)
227					m.Images.restore(unseen(msg.Images))
228					m.Pastes.restore(msg.Pastes)
229				}
230			}
231			if apiErr, ok := errors.AsType[*daemon.APIError](msg.Err); ok && (apiErr.Code == "workspace_invalid" || apiErr.Code == "workspace_unavailable") && m.Workspace != "" {
232				m.Status.Running, m.Status.Idle = false, true
233				retry := &WorkspaceRetry{Missing: m.Workspace, Prompt: msg.Prompt, Continue: msg.Continue, Images: msg.Images, Pastes: msg.Pastes}
234				cmds = append(cmds, func() tea.Msg { return ChatOpenFolderPickerMsg{Retry: retry} })
235			} else {
236				m.AddError(fmt.Sprintf("Could not send the message: %v", msg.Err))
237				errText := msg.Err.Error()
238				if msg.Queued {
239					errText = "Message was not queued: " + errText
240				}
241				m.appendSettledEntry(HistoryEntry{Kind: EntryError, Text: errText})
242			}
243			m.refreshViewportContent()
244			return tea.Batch(cmds...), true
245		}
246
247		if msg.Queued {
248			if i := slices.IndexFunc(m.pendingUsers, func(p PendingUserTurn) bool {
249				return p.OperationID == msg.OperationID && !p.Queued
250			}); i >= 0 {
251				m.pendingUsers[i].Queued = true
252			}
253		}
254		if msg.Handle != nil && (msg.Continue || slices.ContainsFunc(m.pendingUsers, func(p PendingUserTurn) bool { return p.OperationID == msg.OperationID })) {
255			cmds = append(cmds, m.resolveOperationCmd(msg.Handle))
256		}
257
258		// If user pressed Esc while this send was in flight, dispatch interrupt now that send is accepted
259		if m.interruptDeferred {
260			m.interruptDeferred = false
261			m.Stopping = true
262			cmds = append(cmds, m.interruptCmd())
263		} else {
264			m.reconcileStopped()
265		}
266		return tea.Batch(cmds...), true
267	case ChatInterruptMsg:
268		if msg.SessionID != m.SessionID || msg.Generation != m.Generation {
269			return nil, true
270		}
271		if msg.Err != nil || !msg.Interrupted {
272			m.Stopping = false
273			if msg.Err != nil {
274				m.appendSettledEntry(HistoryEntry{Kind: EntryError, Text: "Could not stop the current reply: " + msg.Err.Error()})
275			}
276		}
277		m.reconcileStopped()
278		return nil, true
279	}
280	return nil, false
281}