okami.mom/albedo

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

nekomimi.petfeat(usage): time provider calls and show tok/s on the turn signoff0a3eddd

main
12.2 KiB398 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/daemon"
5	"cmp"
6	"fmt"
7	"path/filepath"
8	"strconv"
9	"strings"
10
11	"charm.land/lipgloss/v2"
12	"github.com/charmbracelet/x/ansi"
13)
14
15func formatUsage(u *daemon.Usage) string {
16	if u == nil {
17		return "—"
18	}
19	total := 0
20	if u.TotalTokens != nil {
21		total = *u.TotalTokens
22	}
23	rate := ""
24	if u.TokensPerSecond != nil && *u.TokensPerSecond > 0 {
25		rate = " · " + formatRate(*u.TokensPerSecond)
26	}
27	return formatTokens(total) + " tokens" + rate
28}
29
30func formatRate(tokensPerSecond float64) string {
31	return fmt.Sprintf("%.1f tok/s", tokensPerSecond)
32}
33
34func formatTokens(val int) string {
35	switch {
36	case val >= 1_000_000:
37		return fmt.Sprintf("%.1fm", float64(val)/1_000_000.0)
38	case val >= 1_000:
39		return fmt.Sprintf("%.1fk", float64(val)/1_000.0)
40	default:
41		return strconv.Itoa(val)
42	}
43}
44
45func (m ChatModel) padding() int {
46	if m.Width >= 50 {
47		return 2
48	}
49	return 1
50}
51
52func (m ChatModel) chatWidth() int { return max(1, m.Width-2*m.padding()) }
53
54// sidebarWidth is 0 without glances or room for them. The transcript keeps at
55// least 72 columns beside a sidebar of 20 to 32.
56func (m ChatModel) sidebarWidth() int {
57	room := m.chatWidth() - 72 - 2
58	if room < 20 || len(m.activeGlances()) == 0 {
59		return 0
60	}
61	return min(32, room)
62}
63
64// actionLabel is the action row for a call, live from its progress and then
65// from its result, so the row holds the last action until the next begins.
66func actionLabel(progress *daemon.ToolProgress, result *HistoryEntry) string {
67	switch {
68	case result != nil:
69		head, tail := toolRowParts(*result, toolFailed(*result), "")
70		return head + tail
71	case progress.Phase == "generating":
72		return "generating " + progress.Name
73	case progress.Name == "python":
74		if line := pythonSourceLine(progress.Code); line != "" {
75			return "running " + line
76		}
77	}
78	return "running " + progress.Name
79}
80
81// pythonSourceLine returns the first useful source line from a cell preview.
82func pythonSourceLine(preview *daemon.ToolCodePreview) string {
83	if preview == nil {
84		return ""
85	}
86	for line := range strings.SplitSeq(preview.Text, "\n") {
87		line = strings.TrimSpace(line)
88		if line != "" && !strings.HasPrefix(line, "#") {
89			return line
90		}
91	}
92	return ""
93}
94
95// renderProgress holds the last tool action in one row; a call still being
96// generated also shows the newest end of its code.
97func (m ChatModel) renderProgress() string {
98	width := max(1, m.Renderer.BodyWidth-railWidth)
99	row := oneLine(m.toolLabel())
100	if progress := m.latestProgress(); progress != nil && progress.Phase == "generating" && progress.Code != nil {
101		code := progress.Code
102		if text := oneLine(code.Text); text != "" {
103			row += " · "
104			if room := width - ansi.StringWidth(row); room >= 12 && ansi.StringWidth(text) > room {
105				text = ansi.TruncateLeft(text, ansi.StringWidth(text)-room+1, "…")
106			}
107			row += text
108		}
109	}
110	return markChrome + DefaultStyles.Faint.Render(ansi.Truncate(row, width, "…"))
111}
112
113// renderThought follows the newest line while the thought streams.
114func (m ChatModel) renderThought() string {
115	header := cmp.Or(thinkingLine(m.transcript.activeText), "thinking")
116	width := max(1, m.Renderer.BodyWidth-railWidth)
117	return markChrome + DefaultStyles.Faint.Render(ansi.Truncate(header+"…", width, "…"))
118}
119
120func (m ChatModel) statusLine() string {
121	if m.TurnFailed || m.Notices.HasError() {
122		return "Reply failed · see error above"
123	}
124	if m.Stopping {
125		return "stopping…"
126	}
127	// an idle session says nothing unless verbose: the transcript already
128	// ends in the turn's signoff.
129	if m.Stopped {
130		if m.Flags.Tools {
131			return "stopped"
132		}
133		return ""
134	}
135	if host := m.reaching(); host != "" {
136		return reachingText(host, m.Status.KernelStep)
137	}
138	// the port owner gave up and forgot the kernel, so nothing reattaches
139	if host, _ := daemon.SplitLocation(m.Workspace); host != "" && m.Status.KernelLink == "lost" {
140		return "kernel on " + cmp.Or(m.Host, host) + " lost · the next turn starts a fresh one"
141	}
142	if m.isSending || m.pendingSendCount() > 0 && !m.Status.Running {
143		return "preparing"
144	}
145	if progress := m.latestProgress(); progress != nil {
146		if progress.Phase == "generating" {
147			return "generating call"
148		}
149		// the action row above already names what runs
150		return "running"
151	}
152	if m.Status.Running && !m.Status.Idle {
153		if m.transcript.activeKind == StreamKindText {
154			return "responding"
155		}
156		if m.Status.Phase != nil {
157			switch *m.Status.Phase {
158			case daemon.PhaseTool:
159				return "running tool"
160			case daemon.PhaseCompacting:
161				return "compacting context"
162			case daemon.PhasePreparing:
163				return "preparing"
164			}
165		}
166		return "thinking"
167	}
168	if m.connecting() {
169		return "connecting…"
170	}
171	if count := m.backgroundJobCount(); count > 0 {
172		label := "1 background job running"
173		if count > 1 {
174			label = fmt.Sprintf("%d background jobs running", count)
175		}
176		if command := m.agedJobCommand(); command != "" {
177			label += " · " + command
178		}
179		return label
180	}
181	if m.Flags.Tools {
182		return "ready"
183	}
184	return ""
185}
186
187// Phase is optional display metadata, not evidence of an unanswered status.
188func (m ChatModel) connecting() bool {
189	return m.Status.Phase == nil && !m.Status.Running && !m.Status.Idle
190}
191
192// phaseMood is the face class for the phase statusLine names.
193func (m ChatModel) phaseMood() mood {
194	switch {
195	case m.Stopping:
196		return moodStopping
197	case m.reaching() != "":
198		return moodConnecting
199	case m.isSending || m.pendingSendCount() > 0 && !m.Status.Running:
200		return moodPreparing
201	case m.latestProgress() != nil || m.Status.Idle && m.backgroundJobCount() > 0:
202		return moodWorking
203	case m.transcript.activeKind == StreamKindText:
204		return moodResponding
205	case m.Status.Phase != nil:
206		switch *m.Status.Phase {
207		case daemon.PhaseTool:
208			return moodWorking
209		case daemon.PhaseCompacting:
210			return moodCompacting
211		case daemon.PhasePreparing:
212			return moodPreparing
213		}
214	}
215	return moodThinking
216}
217
218// header fits the workspace and the model on one rule without cutting either
219// short, shedding the least useful parts first: the middle of the path, the
220// brand, the glance counts from the last, then the path down to its name,
221// which alone may squeeze the rule to one cell.
222func (m ChatModel) header(width int) string {
223	host, workspace := daemon.SplitLocation(cmp.Or(m.Workspace, "chat"))
224	// a remote workspace's host stays whole in every layout
225	lead := ""
226	if host == "" {
227		workspace = homePath(workspace)
228	} else {
229		host = cmp.Or(m.Host, host)
230		lead = host + ":"
231		workspace = scpRelative(underHome(workspace, m.hostHome))
232	}
233	model := m.Model
234	if m.Effort != "" {
235		model += ":" + m.Effort
236	}
237	counts := m.glanceCounts()
238	folds := pathFolds(workspace)
239	type layout struct {
240		place  string
241		counts int
242		rule   int
243		brand  bool
244	}
245	var layouts []layout
246	for _, place := range folds {
247		layouts = append(layouts, layout{brand: true, place: place, counts: len(counts), rule: 3})
248	}
249	for n := len(counts); n >= 0; n-- {
250		layouts = append(layouts, layout{brand: false, place: folds[len(folds)-1], counts: n, rule: 3})
251	}
252	layouts = append(layouts, layout{brand: false, place: filepath.Base(workspace), counts: 0, rule: 1})
253	for _, l := range layouts {
254		right := strings.Join(append(counts[:l.counts:l.counts], model), "  ")
255		left := lead + l.place
256		if l.brand {
257			left = "✦ " + m.AgentName + " on " + left
258		}
259		if lipgloss.Width(left)+lipgloss.Width(right)+l.rule+2 > width {
260			continue
261		}
262		place := DefaultStyles.Muted.Render(l.place)
263		if host != "" {
264			place = m.hostSegment(host) + DefaultStyles.Muted.Render(":"+l.place)
265		}
266		if l.brand {
267			return titleRule(width, brand(m.AgentName)+DefaultStyles.Faint.Render(" on ")+place, DefaultStyles.Faint.Render(right))
268		}
269		return titleRule(width, place, DefaultStyles.Faint.Render(right))
270	}
271	return DefaultStyles.Faint.Render(ansi.Truncate(model, width, "…"))
272}
273
274// hostSegment is the header's host in its own color once the kernel there is
275// attached: faint while it boots or reattaches, the error color once lost.
276func (m ChatModel) hostSegment(host string) string {
277	switch m.Status.KernelLink {
278	case "booting", "reattaching":
279		return DefaultStyles.Faint.Render(host)
280	case "lost":
281		return DefaultStyles.Error.Render(host)
282	}
283	return hostStyle(host).Render(host)
284}
285
286// reaching is the remote host a booting or reattaching kernel is on.
287func (m ChatModel) reaching() string {
288	host, _ := daemon.SplitLocation(m.Workspace)
289	if link := m.Status.KernelLink; host == "" || link != "booting" && link != "reattaching" {
290		return ""
291	}
292	return cmp.Or(m.Host, host)
293}
294
295func (m ChatModel) View() string {
296	width := m.chatWidth()
297	pad := strings.Repeat(" ", m.padding())
298	var rows []string
299	rows = append(rows, m.header(width), "")
300	for _, n := range m.Notices {
301		if n.Error {
302			rows = append(rows, m.Renderer.errorRow(n.Message))
303		} else {
304			rows = append(rows, DefaultStyles.Faint.Render(n.Message))
305		}
306	}
307	if len(m.Notices) > 0 {
308		rows = append(rows, "")
309	}
310
311	shown := m.Viewport
312	if m.History.Len() == 0 && len(m.pendingUsers) == 0 && m.transcript.activeText == "" && m.toolLabel() == "" {
313		textWidth := max(1, min(m.Renderer.BodyWidth, m.Viewport.Width())-railWidth)
314		shown.rows = nil
315		for _, line := range wrapOrChunkLine("What would you like to work on?", textWidth) {
316			shown.rows = append(shown.rows, m.Renderer.rail(laneNone)+DefaultStyles.Faint.Render(line))
317		}
318	}
319	content := shown.lines()
320	if m.sidebarWidth() > 0 {
321		// the transcript keeps its full width, so the sidebar holds its column
322		for i, line := range content {
323			content[i] = line + strings.Repeat(" ", max(0, shown.width-ansi.StringWidth(line)))
324		}
325		joined := lipgloss.JoinHorizontal(lipgloss.Top, strings.Join(content, "\n"), "  ", m.renderGlances())
326		content = pane{rows: strings.Split(joined, "\n"), height: shown.height}.lines()
327	}
328	if m.dragAnchor != nil {
329		// the selection is in transcript rows; the viewport shows from scrollOffset
330		anchor, head := *m.dragAnchor, m.dragHead
331		anchor.Row, head.Row = anchor.Row-m.scrollOffset, head.Row-m.scrollOffset
332		content = HighlightSelection(content, Selection{Anchor: anchor, Head: head, Gutter: railWidth})
333	}
334	rows = append(rows, content...)
335	// one row: a job's command may span lines
336	status := oneLine(m.statusLine())
337	// the face trails the text, so its frames never move anything
338	if m.animating() && !m.TurnFailed && !m.Notices.HasError() {
339		face := m.phaseMood().frame(m.moodSeed, m.ProgressFrame)
340		if host := m.reaching(); host != "" {
341			face = connectingFace(host, m.ProgressFrame) // the picker's face for this host
342		}
343		if m.Width > 0 {
344			status = ansi.Truncate(status, max(1, m.chatWidth()-ansi.StringWidth(face)-1), "…")
345		}
346		status = DefaultStyles.Faint.Render(status) + " " + DefaultStyles.Agent.Render(face)
347	}
348	if !m.Follow {
349		status = fmt.Sprintf("history · %d rows below · pgdn", max(0, m.scrollLimit-m.scrollOffset))
350	}
351	if m.CopyStatus != "" {
352		status = m.CopyStatus
353	}
354	statusStyle := DefaultStyles.Faint
355	if m.TurnFailed || m.Notices.HasError() {
356		statusStyle = DefaultStyles.Error
357	}
358	rows = append(rows, statusStyle.Render(status))
359	if len(m.effortOptions) == 0 {
360		rows = append(rows, m.chipStrip(width)...)
361	}
362	rows = append(rows, DefaultStyles.Decor.Render(strings.Repeat("─", width)))
363	if len(m.effortOptions) > 0 {
364		rows = append(rows, "", DefaultStyles.Bold.Render(ansi.Truncate("Reasoning effort", width, "")), m.effortSelectorView(), DefaultStyles.Faint.Render(ansi.Truncate("← → choose  ·  enter apply  ·  esc cancel", width, "")))
365	} else {
366		rows = append(rows, strings.Split(strings.TrimSuffix(m.composerView(), "\n"), "\n")...)
367		if menu := m.CommandMenu.View(m.TextArea.Value()); menu != "" {
368			rows = append(rows, strings.Split(strings.TrimSuffix(menu, "\n"), "\n")...)
369		}
370	}
371	rows = append(rows, DefaultStyles.Decor.Render(strings.Repeat("─", width)))
372	rows = append(rows, m.renderFooter())
373	for i, row := range rows {
374		rows[i] = pad + row
375	}
376	return strings.Join(rows, "\n")
377}
378
379func (m ChatModel) composerView() string {
380	ta := m.TextArea
381	if m.waitingForInput() && ta.Value() == "" {
382		ta.Placeholder = "ctrl+g editor"
383	} else {
384		ta.Placeholder = ""
385	}
386	lines := strings.Split(ta.View(), "\n")
387	h := m.promptHeight()
388	if h < len(lines) {
389		lines = lines[:h]
390	}
391	return strings.Join(lines, "\n")
392}
393
394// waitingForInput reports an opened session with no turn in flight, so an
395// empty composer reads as the agent's cue rather than a stalled turn.
396func (m ChatModel) waitingForInput() bool {
397	return !m.connecting() && !m.animating()
398}