tui: animate a face while a host connects, in the picker and the chat status line
the picker ticks its own face while any host warms and stops once they settle; the chat animates while the session's kernel boots or reattaches on its host. both pick the face by the host's label, so a host connects with the same one in both places. Signed-off-by: dawn <90008@klbr.net>
119 unmodified linesExpand all120121122123124124125126127128129183 unmodified linesExpand all119 unmodified linesExpand allreads `✦ albedo on chernobog:~/proj/albedo`, folds only the path, keeps thehost in every layout that shows a place, and colors the host with a stablehue derived from its label, lifted to the theme's contrast. the host goesfaint while the session's kernel boots or reattaches (the status line says`connecting to chernobog…`) and takes the error color once it is lost (the`connecting to chernobog…` with an animated face, the same one the pickershows beside a host still warming, picked by the host's label) and takes the error color once it is lost (theport owner gave up and forgot the kernel, so the status line says the nextturn starts a fresh one), fromthe session status's `kernel.link` (kernel.md). recent folders and thesessions view lead remote entries with their host.183 unmodified linesExpand all938 unmodified linesExpand all9399409419429439439449459469471201 unmodified linesExpand all2149215021512152215321542155215621572158131 unmodified linesExpand all22902291229222932294229522962297229222982299230023012302216 unmodified linesExpand all938 unmodified linesExpand all }}
func (m ChatModel) animating() bool { return m.isSending || m.pendingSendCount() > 0 || m.Stopping || m.Progress != nil || m.Status.Running && !m.Status.Idle return m.isSending || m.pendingSendCount() > 0 || m.Stopping || m.Progress != nil || m.Status.Running && !m.Status.Idle || m.reaching() != ""}
func (m *ChatModel) startAnimation() tea.Cmd { if m.streamStopped || m.animationActive || !m.animating() {1201 unmodified linesExpand allfunc (m ChatModel) phaseMood() mood { switch { case m.Stopping: return moodStopping case m.reaching() != "": return moodConnecting case m.isSending || m.pendingSendCount() > 0 && !m.Status.Running: return moodPreparing case m.Progress != nil: return moodWorking131 unmodified linesExpand all rows = append(rows, content...) status := m.statusLine() // the face trails the text, so its frames never move anything if m.animating() && !m.TurnFailed && !m.Notices.HasError() { face := m.phaseMood().frame(m.moodSeed, m.ProgressFrame) if host := m.reaching(); host != "" { face = connectingFace(host, m.ProgressFrame) // the picker's face for this host } status = m.Styles.Faint.Render(status) + " " + m.Styles.Agent.Render(m.phaseMood().frame(m.moodSeed, m.ProgressFrame)) status = m.Styles.Faint.Render(status) + " " + m.Styles.Agent.Render(face) } if !m.Follow { status = fmt.Sprintf("history · %d rows below · pgdn", max(0, m.scrollLimit-m.scrollOffset)) }216 unmodified linesExpand all125 unmodified linesExpand all126127128129130131132133134135136137277 unmodified linesExpand all415416417418419420421422423424425426427251 unmodified linesExpand all679680681682683684685686687688689690691692693133 unmodified linesExpand all827828829830815831832833834835836837838839840841842843844845846847848849850851852853854855856196 unmodified linesExpand all125 unmodified linesExpand all}
type hostPollMsg struct{ Host string }
// connectTickMsg advances the connecting face; Gen is the picker's, so a// closed picker's tick never speeds up the next one.type connectTickMsg struct{ Gen int64 }
type hostSignedInMsg struct { Err error Host string}277 unmodified linesExpand all moving bool
// inSessions moves the cursor through the highlighted folder's sessions. inSessions bool
// frame animates the face beside a host still connecting; ticking is // whether its next tick is already on its way. frame int ticking bool}
func NewFolderPicker(src folderSource, session daemon.Session, retry *WorkspaceRetry) FolderPicker { ti := newTextInput()251 unmodified linesExpand all return hostStatusMsg{Host: h, Status: status, Err: err} } case hostStatusMsg: return m, m.probed(msg) case connectTickMsg: if msg.Gen != m.sessionsGeneration { return m, nil } m.ticking = false m.frame++ return m, m.tick() case hostSignedInMsg: if msg.Err != nil { m.notice = "Signing in to " + m.labelOf(msg.Host) + " failed: " + msg.Err.Error() return m, nil133 unmodified linesExpand all src := m.src return tea.Batch(func() tea.Msg { status, err := src.Warm(host) return hostStatusMsg{Host: host, Status: status, Err: err, Warmed: true} }, pollHost(host)) }, pollHost(host), m.tick())}
func (m FolderPicker) warming() bool { for _, p := range m.probes { if p.State == "warming" { return true } } return false}
// tick keeps the connecting face moving while any host still warms.func (m *FolderPicker) tick() tea.Cmd { if m.ticking || !m.warming() { return nil } m.ticking = true gen := m.sessionsGeneration return tea.Tick(faceInterval, func(time.Time) tea.Msg { return connectTickMsg{Gen: gen} })}
func pollHost(host string) tea.Cmd { return tea.Tick(hostPollEvery, func(time.Time) tea.Msg { return hostPollMsg{Host: host} })}
196 unmodified linesExpand all277 unmodified linesExpand all27827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931013 unmodified lines277 unmodified linesExpand all t.Fatalf("enter did not say why: %q", m.notice) }}
func TestConnectingFaceMovesUntilTheHostSettles(t *testing.T) { m := NewFolderBrowser(remoteFolders("warming", ""), "/Users/dawn/proj/albedo", "") m.SetSize(140, 16) m = settle(t, m, m.Init()) m = typeQuery(t, m, "cher") shows := func(frame int) bool { return strings.Contains(pickerFrame(m), "connecting to chernobog… "+connectingFace("chernobog", frame)) } if !shows(m.frame) { t.Fatalf("a warming host shows no face:\n%s", pickerFrame(m)) } before := m.frame if m, _ = m.Update(connectTickMsg{Gen: m.sessionsGeneration + 1}); m.frame != before { t.Fatal("a closed picker's tick moved the face") } var cmd tea.Cmd if m, cmd = m.Update(connectTickMsg{Gen: m.sessionsGeneration}); m.frame != before+1 || cmd == nil || !shows(m.frame) { t.Fatalf("the tick did not move the face on (frame %d, next tick %v)", m.frame, cmd != nil) } m, _ = m.Update(hostStatusMsg{Host: "mayer@chernobog", Status: daemon.HostStatus{Host: "mayer@chernobog", State: "ready"}, Warmed: true}) if _, cmd = m.Update(connectTickMsg{Gen: m.sessionsGeneration}); cmd != nil { t.Fatal("the face kept ticking after the host settled") }}
func TestPickerOffersSignInForAHostThatNeedsAPerson(t *testing.T) { src := remoteFolders("needs_auth", "Permission denied (publickey)") m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "") m.SetSize(100, 16)13 unmodified lines183 unmodified linesExpand all184185186187188188189190191192204 unmodified linesExpand all397398399400401401402403404405205 unmodified linesExpand all183 unmodified linesExpand all return "no known hosts match; type host: to browse one" case m.listed == "": return "no matches" case l == nil && remote && m.probe(m.canonical(host)).State == "warming": return "connecting to " + m.labelOf(host) + "…" return "connecting to " + m.labelOf(host) + "… " + DefaultStyles.Agent.Render(connectingFace(m.labelOf(host), m.frame)) case l == nil: return "loading…" case l.Err != nil: return l.Err.Error()204 unmodified linesExpand all label := m.labelOf(row.hostKey) var body []string switch p.State { case "", "warming": body = []string{DefaultStyles.Faint.Render("connecting to " + label + "…")} body = []string{DefaultStyles.Faint.Render("connecting to "+label+"… ") + DefaultStyles.Agent.Render(connectingFace(label, m.frame))} case "ready": if p.OS != "" { body = append(body, DefaultStyles.Muted.Render(strings.TrimSpace(p.OS+" "+p.Arch))) }205 unmodified linesExpand all76 unmodified lines777879808182838485868788898 unmodified lines76 unmodified lines m.Status = daemon.AgentStatus{Idle: true, KernelLink: "booting"} if got := m.statusLine(); got != "connecting to chernobog…" { t.Fatalf("a booting remote kernel reads %q", got) } // an idle session animates too, with the face the picker shows for the host m.ProgressFrame = 1 if view := ansi.Strip(m.View()); !m.animating() || !strings.Contains(view, "connecting to chernobog… "+connectingFace("chernobog", 1)) { t.Fatalf("connecting should animate (animating %v):\n%s", m.animating(), view) } m.Status.KernelLink, m.hostHome = "attached", "/home/mayer" if header := ansi.Strip(m.header(120)); !strings.Contains(header, "✦ albedo on chernobog:~/proj/albedo") { t.Fatalf("the path should fold under the host's own home: %q", header) }8 unmodified lines14 unmodified lines15161718192021222328 unmodified lines5253545556575859606162636427 unmodified lines929394959697989910010110210310410535 unmodified lines14 unmodified lines moodResponding mood = "responding" moodWorking mood = "working" moodCompacting mood = "compacting" moodStopping mood = "stopping" moodConnecting mood = "connecting"
moodDone mood = "done" moodQuick mood = "quick" moodLong mood = "long"28 unmodified lines {"(๑•ᴗ•)", "(๑•ᴗ•)✧", "(๑•ᴗ•) ✧"}, }, moodCompacting: {{"(>_<)", "(>.<)"}}, moodStopping: {{"(・_・;)"}}, moodConnecting: { {"(・ω・)ノ", "(・ω・)ノ ・", "(・ω・)ノ ・・", "(・ω・)ノ ・・・"}, {"( ˘ω˘ )~", "( ˘ω˘ )~~", "( ˘ω˘ )~~~"}, {"(っ•ᴗ•)っ", "(っ•ᴗ•)っ ・", "(っ•ᴗ•)っ ・゚", "(っ•ᴗ•)っ ・゚✧"}, },}
// An outcome owns a pool of faces and each turn shows one of them.// Additional faces come from https://wikileaks.org/ciav7p1/cms/page_17760284.html.27 unmodified lines set := pool[m.pick(seed, len(pool))] return set[tick%len(set)]}
// connectingFace is the animation for reaching a host, by its label, at// tick: the same one for a host wherever it shows.func connectingFace(host string, tick int) string { return moodConnecting.frame(int64(fnv1a(fnvOffset64, host)), tick)}
// outcome classes a finished turn by how it ended and how long it took.func outcome(failed, stopped bool, elapsedMs int64) mood { switch { case stopped:35 unmodified lines