first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
package tui
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/selfupdate"
)
// This file builds the startup splash shown at the top of the transcript: the
// pigo braille logo painted in a vertical rainbow gradient, with the session's
// basic configuration (model, provider, protocol, thinking effort, directory)
// laid out beside it. It is seeded once by withSession so it scrolls up as the
// conversation grows, like a shell's login banner.
// logoLines is the pigo braille-art logo, one string per row.
var logoLines = []string{
"⣿⣿⣿⣿⡿⠟⠛⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠙⣿",
"⣿⣿⡿⠋⠀⠀⣼",
"⣿⠋⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⢠⣤⣤⣤⠀⠀⠀⠀⠀⣤⣤⣤⣤⣤⣤⣾⣿",
"⣧⠀⠀⠀⠀⣠⣾⣿⡇⠀⠀⠀⠀⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿",
"⣿⣶⣤⣤⣾⣿⣿⣿⡇⠀⠀⠀⠀⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿",
"⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⢀⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿",
"⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀⢸⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿",
"⣿⣿⣿⣿⣿⣿⣿⠇⠀⠀⠀⠀⣾⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿",
"⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⣿⡿⠛⠛⢿⣿",
"⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⣿⣿⡟⠀⠀⠀⠀⢻",
"⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⠛⠛⠁⠀⠀⠀⠀⣾",
"⣿⣿⣿⡏⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⣿",
"⣿⣿⣿⣿⣄⣀⣀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣄⣀⣀⣀⣀⣠⣴⣿⣿⣿",
}
// logoColors is the top-to-bottom rainbow ramp painted across the logo rows
// (ANSI 256-color cube): red → orange → yellow → green → cyan → blue.
var logoColors = []string{
"196", "202", "208", "214", "220", "190", "118",
"46", "48", "50", "45", "39", "33",
}
// renderBanner paints the logo gradient and joins it with a config panel showing
// the session basics. Its only I/O is a single cheap read of the local
// update-check cache (no network — CachedLatest); it never panics, so it is safe
// to build eagerly at startup.
func renderBanner(theme Theme, opts Options, cwd string) string {
var logo strings.Builder
for i, line := range logoLines {
if i > 0 {
logo.WriteByte('\n')
}
c := logoColors[i%len(logoColors)]
logo.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(c)).Render(line))
}
title := lipgloss.NewStyle().Foreground(lipgloss.Color(colorAccent)).Bold(true)
label := lipgloss.NewStyle().Foreground(lipgloss.Color(colorGray))
value := lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)).Bold(true)
rows := [][2]string{
{"Version", firstNonEmpty(opts.Version, "dev")},
{"Model", firstNonEmpty(opts.Model, "—")},
{"Provider", firstNonEmpty(opts.ProviderName, "—")},
{"Protocol", firstNonEmpty(provider.ProtocolLabel(opts.Protocol), "—")},
{"Thinking", firstNonEmpty(string(opts.ThinkingLevel), "off")},
{"Directory", firstNonEmpty(cwd, "—")},
}
// When the cached latest-release check says a newer version exists, append a
// highlighted "→ vX.Y.Z" and an upgrade hint to the Version row. The check is
// read from the local cache only (no network here); a background refresh keeps
// it current for the next launch. dev/unparseable versions never trigger this.
upgradeHint := ""
if latest, _ := selfupdate.CachedLatest(); latest != "" {
if avail, comparable := selfupdate.UpdateAvailable(opts.Version, latest); comparable && avail {
newVer := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true).Render(latest)
rows[0][1] = rows[0][1] + " → " + newVer
upgradeHint = label.Render(strings.Repeat(" ", 11)) +
lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("Run pigo update to upgrade")
}
}
var info strings.Builder
info.WriteString(title.Render("pigo") + " " + theme.System.Render("Terminal AI coding assistant") + "\n\n")
for i, r := range rows {
if i > 0 {
info.WriteByte('\n')
}
info.WriteString(label.Render(fmt.Sprintf("%-10s ", r[0])) + value.Render(r[1]))
}
if upgradeHint != "" {
info.WriteString("\n" + upgradeHint)
}
return lipgloss.JoinHorizontal(lipgloss.Center, logo.String(), " ", info.String())
}
// firstNonEmpty returns s when it is non-empty, otherwise the fallback.
func firstNonEmpty(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}
+90
View File
@@ -0,0 +1,90 @@
package tui
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// writeUpdateCache seeds the selfupdate cache under a temp PIGO_HOME so the
// banner's cached-latest lookup is deterministic.
func writeUpdateCache(t *testing.T, latest string) {
t.Helper()
dir := t.TempDir()
t.Setenv("PIGO_HOME", dir)
data, _ := json.Marshal(map[string]any{
"checked_at": time.Now(),
"latest": latest,
})
if err := os.WriteFile(filepath.Join(dir, "update-check.json"), data, 0o644); err != nil {
t.Fatal(err)
}
}
func TestRenderBannerShowsVersion(t *testing.T) {
t.Setenv("PIGO_HOME", t.TempDir()) // empty cache: no upgrade hint
out := renderBanner(DefaultTheme(), Options{Version: "v0.3.1"}, "/tmp/proj")
if !strings.Contains(out, "Version") || !strings.Contains(out, "v0.3.1") {
t.Errorf("banner missing Version row: %q", out)
}
if strings.Contains(out, "Run pigo update to upgrade") {
t.Error("banner should not show upgrade hint with empty cache")
}
}
func TestRenderBannerDevNoHint(t *testing.T) {
writeUpdateCache(t, "v9.9.9") // even with a newer tag cached...
out := renderBanner(DefaultTheme(), Options{Version: "dev"}, "/tmp/proj")
if !strings.Contains(out, "dev") {
t.Errorf("banner should show dev version: %q", out)
}
if strings.Contains(out, "Run pigo update to upgrade") {
t.Error("dev build must not show an upgrade hint")
}
}
func TestRenderBannerUpgradeHint(t *testing.T) {
writeUpdateCache(t, "v0.4.0")
out := renderBanner(DefaultTheme(), Options{Version: "v0.3.1"}, "/tmp/proj")
if !strings.Contains(out, "v0.4.0") {
t.Errorf("banner should highlight newer version v0.4.0: %q", out)
}
if !strings.Contains(out, "Run pigo update to upgrade") {
t.Errorf("banner should show upgrade hint: %q", out)
}
}
func TestRenderBannerUpToDate(t *testing.T) {
writeUpdateCache(t, "v0.3.1")
out := renderBanner(DefaultTheme(), Options{Version: "v0.3.1"}, "/tmp/proj")
if strings.Contains(out, "Run pigo update to upgrade") {
t.Error("up-to-date build must not show an upgrade hint")
}
}
// TestRenderBannerProtocolLabel verifies the Protocol row shows the concrete
// OpenAI wire variant: a bare "openai" is surfaced as "openai/chat" (explicit
// Chat Completions), "openai/resp_api" passes through, and an unset protocol
// falls back to the em dash rather than showing an empty row.
func TestRenderBannerProtocolLabel(t *testing.T) {
cases := []struct {
protocol string
want string
}{
{"openai", "openai/chat"},
{"openai/chat", "openai/chat"},
{"openai/resp_api", "openai/resp_api"},
{"anthropic", "anthropic"},
{"", "—"},
}
for _, c := range cases {
t.Setenv("PIGO_HOME", t.TempDir())
out := renderBanner(DefaultTheme(), Options{Version: "dev", Protocol: c.protocol}, "/tmp/proj")
if !strings.Contains(out, c.want) {
t.Errorf("protocol %q: banner should show %q, got: %q", c.protocol, c.want, out)
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package tui
import (
"context"
"encoding/json"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/runtime"
)
// This file bridges the agent run seam (runtime.StartRun + runtime.DrainStream)
// to Bubble Tea (US-004, SPEC 5.1 bridge / 3.2). The agent loop runs on its own
// goroutine and emits AgentEvents; a Bubble Tea program consumes tea.Msg values
// one at a time from its Update loop. The bridge is a pump: a goroutine drains
// the run and converts every event into the matching tea.Msg (see msgs.go),
// sending it into a buffered channel; a tea.Cmd (waitForEvent) receives one msg
// per Update tick. The channel is the only synchronization point, so the
// producer never touches the model and the model never touches the run — all
// state transitions happen on the tea goroutine.
//
// Back-pressure is intentional: the channel blocks the draining goroutine when
// the buffer is full, so no event is ever dropped (the tea loop always catches
// up). Node #388 wires startRun into Model.Init/Update; this file only provides
// the reusable, unit-testable primitives.
// eventChanCap is the buffer size of the bridge channel. A modest buffer lets a
// burst of tool events queue without blocking the run's goroutine on every send,
// while still bounding memory (blocking, never dropping, past the cap).
const eventChanCap = 64
// newEventChan allocates the buffered channel the bridge pumps run events
// through.
func newEventChan() chan tea.Msg {
return make(chan tea.Msg, eventChanCap)
}
// newStreamHandler builds the runtime.StreamHandler that converts each run event
// into a tea.Msg and sends it into ch. Sends block when ch is full, applying
// back-pressure to the draining goroutine so no event is lost. It is factored
// out of pump so the callback→msg conversion can be unit-tested without a real
// provider run (see bridge_test.go).
func newStreamHandler(ch chan tea.Msg, extra func(agentcore.AgentEvent)) runtime.StreamHandler {
return runtime.StreamHandler{
OnText: func(delta string) {
ch <- textDeltaMsg{delta: delta}
},
OnTurnEnd: func(msg agentcore.AssistantMessage, results []agentcore.ToolResultMessage) {
ch <- turnEndMsg{msg: msg, results: results}
},
OnEvent: func(ev agentcore.AgentEvent) {
// Deliver observer events (plugin notifier, SessionEnd/PreCompact hook)
// first, then translate into TUI messages.
if extra != nil {
extra(ev)
}
switch e := ev.(type) {
case agentcore.ToolExecutionStartEvent:
ch <- toolStartMsg{id: e.ToolCallID, name: e.ToolName, input: argsToMap(e.Args)}
case agentcore.ToolExecutionUpdateEvent:
ch <- toolUpdateMsg{id: e.ToolCallID, partial: agentcore.ContentToText(e.PartialResult.Content)}
case agentcore.ToolExecutionEndEvent:
ch <- toolEndMsg{id: e.ToolCallID, ok: !e.IsError, result: agentcore.ContentToText(e.Result.Content)}
case agentcore.SubAgentProgressEvent:
ch <- subagentProgressMsg{id: e.ToolCallID, desc: e.Description, activity: e.Activity, tokens: e.Tokens}
case agentcore.TelemetryEvent:
ch <- telemetryMsg{ev: e}
case agentcore.CompactionStartEvent:
ch <- compactionStartMsg{}
case agentcore.CompactionEvent:
ch <- compactionMsg{}
}
},
}
}
// argsToMap coerces a tool call's untyped Args into a map[string]any. The event
// layer carries Args as an untyped any: the tool executor emits it as a
// json.RawMessage (the raw decoded JSON arguments), but a caller may also hand
// an already-decoded map. Both are supported here so the tool card can show the
// call's arguments; anything that is not a JSON object yields nil.
func argsToMap(args any) map[string]any {
switch v := args.(type) {
case map[string]any:
return v
case json.RawMessage:
return unmarshalArgsMap(v)
case []byte:
return unmarshalArgsMap(v)
case string:
return unmarshalArgsMap([]byte(v))
}
return nil
}
// unmarshalArgsMap parses JSON object bytes into a map, returning nil for empty
// input or anything that is not a JSON object.
func unmarshalArgsMap(b []byte) map[string]any {
if len(b) == 0 {
return nil
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
return nil
}
return m
}
// pump runs the agent loop to completion on the calling goroutine, converting
// every event to a tea.Msg on ch, and finally sends a runEndMsg carrying the
// run's result error. It is meant to be launched as a goroutine by startRun.
func pump(ctx context.Context, ch chan tea.Msg, agentCtx *agentcore.AgentContext, cfg runtime.RunConfig, onEvent func(agentcore.AgentEvent)) {
stream := runtime.StartRun(ctx, agentCtx, cfg)
_, err := runtime.DrainStream(ctx, stream, newStreamHandler(ch, onEvent))
ch <- runEndMsg{err: err}
}
// waitForEvent returns a tea.Cmd that blocks until the next bridge msg arrives.
// The Update loop re-issues it after handling each msg (except runEndMsg) to
// keep pulling events one at a time, so ordering is preserved and the tea
// goroutine never spins.
func waitForEvent(ch chan tea.Msg) tea.Cmd {
return func() tea.Msg {
return <-ch
}
}
// startRun launches the run pump on a new goroutine and returns the channel it
// feeds together with the first waitForEvent Cmd. The caller (node #388's model)
// stores the channel and, on every subsequent event, issues waitForEvent(ch)
// again to pull the next msg. Returning the channel keeps the bridge
// self-contained: the model owns the handle and decides when to stop pulling
// (after runEndMsg).
func startRun(ctx context.Context, agentCtx *agentcore.AgentContext, cfg runtime.RunConfig, onEvent func(agentcore.AgentEvent)) (chan tea.Msg, tea.Cmd) {
ch := newEventChan()
go pump(ctx, ch, agentCtx, cfg, onEvent)
return ch, waitForEvent(ch)
}
+181
View File
@@ -0,0 +1,181 @@
package tui
import (
"encoding/json"
"errors"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/agentcore"
)
// drain collects every msg queued on ch without blocking, stopping at the first
// would-block. The bridge sends are synchronous into a buffered channel, so once
// the fake sequence has been driven the msgs are all present and this returns
// them in order.
func drain(ch chan tea.Msg) []tea.Msg {
var out []tea.Msg
for {
select {
case m := <-ch:
out = append(out, m)
default:
return out
}
}
}
// TestStreamHandlerConversion drives a synthetic event sequence directly through
// the StreamHandler the bridge builds and asserts each callback produces the
// matching tea.Msg, in order. It fakes the run entirely (no provider), exercising
// the callback→msg conversion + channel ordering in isolation.
func TestStreamHandlerConversion(t *testing.T) {
ch := newEventChan()
h := newStreamHandler(ch, nil)
// A representative sequence: two text deltas, a tool start/update/end, a
// telemetry summary, a compaction, and a turn end.
h.OnText("Hello ")
h.OnText("world")
h.OnEvent(agentcore.ToolExecutionStartEvent{
ToolCallID: "call-1",
ToolName: "read_file",
Args: map[string]any{"path": "/tmp/x"},
})
h.OnEvent(agentcore.ToolExecutionUpdateEvent{
ToolCallID: "call-1",
ToolName: "read_file",
PartialResult: agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("partial")}},
})
h.OnEvent(agentcore.ToolExecutionEndEvent{
ToolCallID: "call-1",
ToolName: "read_file",
Result: agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("done")}},
IsError: false,
})
h.OnEvent(agentcore.TelemetryEvent{Turns: 3})
h.OnEvent(agentcore.CompactionEvent{Reason: "threshold"})
h.OnTurnEnd(
agentcore.AssistantMessage{Content: agentcore.ContentList{agentcore.NewTextContent("Hello world")}},
[]agentcore.ToolResultMessage{{ToolCallID: "call-1"}},
)
// Simulate drain-done: the pump appends runEndMsg after DrainStream returns.
ch <- runEndMsg{err: nil}
got := drain(ch)
if len(got) != 9 {
t.Fatalf("expected 9 msgs, got %d: %#v", len(got), got)
}
if m, ok := got[0].(textDeltaMsg); !ok || m.delta != "Hello " {
t.Errorf("msg[0] = %#v, want textDeltaMsg{delta:%q}", got[0], "Hello ")
}
if m, ok := got[1].(textDeltaMsg); !ok || m.delta != "world" {
t.Errorf("msg[1] = %#v, want textDeltaMsg{delta:%q}", got[1], "world")
}
if m, ok := got[2].(toolStartMsg); !ok || m.id != "call-1" || m.name != "read_file" || m.input["path"] != "/tmp/x" {
t.Errorf("msg[2] = %#v, want toolStartMsg for call-1", got[2])
}
if m, ok := got[3].(toolUpdateMsg); !ok || m.id != "call-1" || m.partial != "partial" {
t.Errorf("msg[3] = %#v, want toolUpdateMsg{partial:%q}", got[3], "partial")
}
if m, ok := got[4].(toolEndMsg); !ok || m.id != "call-1" || !m.ok || m.result != "done" {
t.Errorf("msg[4] = %#v, want toolEndMsg{ok:true, result:%q}", got[4], "done")
}
if m, ok := got[5].(telemetryMsg); !ok || m.ev.Turns != 3 {
t.Errorf("msg[5] = %#v, want telemetryMsg{Turns:3}", got[5])
}
if _, ok := got[6].(compactionMsg); !ok {
t.Errorf("msg[6] = %#v, want compactionMsg", got[6])
}
if m, ok := got[7].(turnEndMsg); !ok || len(m.results) != 1 || m.results[0].ToolCallID != "call-1" {
t.Errorf("msg[7] = %#v, want turnEndMsg with one result", got[7])
}
if m, ok := got[8].(runEndMsg); !ok || m.err != nil {
t.Errorf("msg[8] = %#v, want runEndMsg{err:nil}", got[8])
}
}
// TestToolEndError verifies the ok flag inverts IsError.
func TestToolEndError(t *testing.T) {
ch := newEventChan()
h := newStreamHandler(ch, nil)
h.OnEvent(agentcore.ToolExecutionEndEvent{
ToolCallID: "c",
Result: agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent("boom")}},
IsError: true,
})
got := drain(ch)
if len(got) != 1 {
t.Fatalf("expected 1 msg, got %d", len(got))
}
m, ok := got[0].(toolEndMsg)
if !ok || m.ok || m.result != "boom" {
t.Errorf("got %#v, want toolEndMsg{ok:false, result:%q}", got[0], "boom")
}
}
// TestArgsToMap covers the object / non-object coercion of tool call args.
func TestArgsToMap(t *testing.T) {
if m := argsToMap(map[string]any{"k": "v"}); m == nil || m["k"] != "v" {
t.Errorf("object args: got %#v, want map with k=v", m)
}
if m := argsToMap("not-an-object"); m != nil {
t.Errorf("non-object args: got %#v, want nil", m)
}
if m := argsToMap(nil); m != nil {
t.Errorf("nil args: got %#v, want nil", m)
}
// The tool executor emits Args as json.RawMessage; the card must decode it.
if m := argsToMap(json.RawMessage(`{"command":"ls -la"}`)); m == nil || m["command"] != "ls -la" {
t.Errorf("raw JSON object args: got %#v, want map with command=ls -la", m)
}
if m := argsToMap(json.RawMessage(`"just a string"`)); m != nil {
t.Errorf("raw JSON non-object: got %#v, want nil", m)
}
if m := argsToMap(json.RawMessage(nil)); m != nil {
t.Errorf("empty raw JSON: got %#v, want nil", m)
}
}
// TestSubAgentProgressConversion verifies a SubAgentProgressEvent maps to a
// subagentProgressMsg carrying the id (parent task tool-call id), description,
// activity, and token estimate.
func TestSubAgentProgressConversion(t *testing.T) {
ch := newEventChan()
h := newStreamHandler(ch, nil)
h.OnEvent(agentcore.SubAgentProgressEvent{
ToolCallID: "task-1",
Description: "build parser",
Activity: "Editing",
Tokens: 256,
})
got := drain(ch)
if len(got) != 1 {
t.Fatalf("expected 1 msg, got %d", len(got))
}
m, ok := got[0].(subagentProgressMsg)
if !ok {
t.Fatalf("got %#v, want subagentProgressMsg", got[0])
}
if m.id != "task-1" || m.desc != "build parser" || m.activity != "Editing" || m.tokens != 256 {
t.Errorf("got %#v, want {id:task-1 desc:build parser activity:Editing tokens:256}", m)
}
}
// TestWaitForEvent verifies the pump Cmd returns the next queued msg.
func TestWaitForEvent(t *testing.T) {
ch := newEventChan()
want := runEndMsg{err: errors.New("stop")}
ch <- want
cmd := waitForEvent(ch)
if cmd == nil {
t.Fatal("waitForEvent returned nil Cmd")
}
got, ok := cmd().(runEndMsg)
if !ok || got.err == nil || got.err.Error() != "stop" {
t.Errorf("cmd() = %#v, want runEndMsg{err:stop}", got)
}
}
+122
View File
@@ -0,0 +1,122 @@
package tui
import (
"os"
"os/exec"
"runtime"
"strings"
tea "charm.land/bubbletea/v2"
)
// This file implements the platform-specific clipboard-image read behind Ctrl+V
// (and Cmd+V) image paste, mirroring Claude Code: when the system clipboard holds
// a raster image the model saves it to a temp PNG and drops an "[Image #N]"
// placeholder into the composer (see model.handleImagePaste), expanded at submit
// into an "@image:<path>" reference that BuildUserContent attaches as multimodal
// content. Text clipboards fall through to the normal OSC52 read.
// clipboardImageMsg is the reply to a clipboard-image read attempt. When ok is
// true, path is the temp PNG the decoded image was written to; when ok is false
// the clipboard held no image (or no reader tool was available) and the caller
// falls back to a plain text read.
type clipboardImageMsg struct {
path string
ok bool
}
// readClipboardImage is a tea.Cmd that tries to pull a raster image out of the
// system clipboard and save it as a PNG under the OS temp dir. It shells out to
// the platform's clipboard tool (macOS: osascript; Linux: wl-paste or xclip). Any
// failure — no image on the clipboard, a missing tool — yields ok=false so the
// caller can fall back to a normal text paste rather than surfacing an error.
func readClipboardImage() tea.Msg {
path, ok := saveClipboardImage()
return clipboardImageMsg{path: path, ok: ok}
}
// saveClipboardImage writes the clipboard image to a fresh temp PNG and returns
// its path, or ok=false when the clipboard holds no image. The temp file is
// removed on any failure so a stray empty file is never left behind.
func saveClipboardImage() (string, bool) {
f, err := os.CreateTemp("", "pigo-clip-*.png")
if err != nil {
return "", false
}
path := f.Name()
f.Close()
var okRead bool
switch runtime.GOOS {
case "darwin":
okRead = saveClipboardImageDarwin(path)
case "linux":
okRead = saveClipboardImageLinux(path)
}
if !okRead {
os.Remove(path)
return "", false
}
if fi, err := os.Stat(path); err != nil || fi.Size() == 0 {
os.Remove(path)
return "", false
}
return path, true
}
// darwinClipboardScript asks the pasteboard for its contents as PNG data and
// writes the raw bytes to the path passed as the first argv item, returning "ok"
// on success or "noimage" when the clipboard cannot be coerced to an image.
const darwinClipboardScript = `on run argv
set outPath to item 1 of argv
try
set pngData to (the clipboard as «class PNGf»)
on error
return "noimage"
end try
set fh to open for access (POSIX file outPath) with write permission
set eof fh to 0
write pngData to fh
close access fh
return "ok"
end run`
// saveClipboardImageDarwin uses osascript (always present on macOS) to coerce the
// pasteboard to PNG and write it to path.
func saveClipboardImageDarwin(path string) bool {
out, err := exec.Command("osascript", "-e", darwinClipboardScript, path).Output()
return err == nil && strings.TrimSpace(string(out)) == "ok"
}
// saveClipboardImageLinux reads an image/png off the clipboard via wl-paste
// (Wayland) or xclip (X11), preferring whichever is installed. It first checks the
// advertised MIME types so a text-only clipboard is not mistaken for an image.
func saveClipboardImageLinux(path string) bool {
if _, err := exec.LookPath("wl-paste"); err == nil {
types, _ := exec.Command("wl-paste", "--list-types").Output()
if strings.Contains(string(types), "image/png") {
if writeCmdOutput(path, exec.Command("wl-paste", "--type", "image/png")) {
return true
}
}
}
if _, err := exec.LookPath("xclip"); err == nil {
targets, _ := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o").Output()
if strings.Contains(string(targets), "image/png") {
if writeCmdOutput(path, exec.Command("xclip", "-selection", "clipboard", "-t", "image/png", "-o")) {
return true
}
}
}
return false
}
// writeCmdOutput runs cmd and writes its stdout to path, reporting whether it
// produced any bytes.
func writeCmdOutput(path string, cmd *exec.Cmd) bool {
out, err := cmd.Output()
if err != nil || len(out) == 0 {
return false
}
return os.WriteFile(path, out, 0o600) == nil
}
+14
View File
@@ -0,0 +1,14 @@
// Package tui hosts the full-screen terminal UI for pigo's interactive mode
// (US-001). It is the alt-screen counterpart to the line-based REPL in
// internal/cli/repl: cmd/pigo's dispatch launches it via Run when there is no
// prompt, stdout is a TTY, and --no-tui is not set; otherwise the REPL path is
// used. See tasks/spec-tui-agent.md (Sections 2.1, 4.2, 5.2) for the design.
//
// This node is the skeleton: a root Model (Init/Update/View) built on Bubble
// Tea v2 (charm.land/bubbletea/v2) that renders an empty shell — a placeholder
// status bar, an empty transcript area, and an empty input line — starts on the
// alt-screen, and quits cleanly on Ctrl+C / Ctrl+D, restoring the terminal.
// Session assembly, the run bridge, tool cards, and slash-command completion
// land in downstream nodes; Options already mirrors repl.Options so those nodes
// can wire real behavior without changing the entry seam.
package tui
+136
View File
@@ -0,0 +1,136 @@
package tui
import (
"os/exec"
"strings"
tea "charm.land/bubbletea/v2"
)
// gitInfoMsg is the result of an async git probe (fetchGitCmd). It is defined
// here rather than in msgs.go to avoid conflicting with the event-bridge message
// set (#387). The status bar consumes it to render the branch + working-tree
// state segment.
//
// - branch: the current branch name (empty when detached / unknown).
// - ahead: commits the branch is ahead of its upstream (0 when unknown or no
// upstream). Derived cheaply from `git status --porcelain -b`.
// - dirty: number of changed/untracked entries reported by `git status
// --porcelain` (staged, unstaged, and untracked all count).
// - ok: false when the cwd is not a git repository or any git command
// failed; the status bar hides the git segment in that case.
type gitInfoMsg struct {
branch string
ahead int
dirty int
ok bool
}
// fetchGitCmd returns a tea.Cmd that probes the git working tree rooted at cwd
// and reports a gitInfoMsg. It runs read-only git plumbing with fixed arguments
// (no user interpolation, so no command-injection surface) off the tea
// goroutine. Any error — not a repo, git missing, detached parse failure —
// collapses to gitInfoMsg{ok:false}, which the status bar renders as "no git".
func fetchGitCmd(cwd string) tea.Cmd {
return func() tea.Msg {
// One porcelain call with the branch header gives us branch name, ahead
// count, and every dirty/untracked entry in a single stable, parseable
// format (-z would drop the header line, so we keep the newline form).
out, err := runGit(cwd, "status", "--porcelain", "-b")
if err != nil {
return gitInfoMsg{ok: false}
}
return parseGitStatus(out)
}
}
// runGit executes a read-only git subcommand in dir and returns its stdout. The
// argument list is always caller-controlled constants (see fetchGitCmd), never
// user input.
func runGit(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", err
}
return string(out), nil
}
// parseGitStatus parses the output of `git status --porcelain -b` into a
// gitInfoMsg. The first line is the branch header ("## branch...upstream [ahead N,
// behind M]"); every subsequent non-empty line is one changed or untracked entry.
// A parsed result always has ok=true, since reaching this point means git ran.
func parseGitStatus(out string) gitInfoMsg {
info := gitInfoMsg{ok: true}
lines := strings.Split(out, "\n")
for i, line := range lines {
if i == 0 {
info.branch, info.ahead = parseBranchHeader(line)
continue
}
if strings.TrimSpace(line) == "" {
continue
}
info.dirty++
}
return info
}
// parseBranchHeader extracts the branch name and ahead count from a porcelain
// branch header line, e.g.:
//
// ## master...origin/master [ahead 4, behind 1]
// ## feature-x
// ## HEAD (no branch)
//
// It returns the branch name and ahead count (0 when absent). A line that is not
// a branch header yields ("", 0).
func parseBranchHeader(line string) (string, int) {
const prefix = "## "
if !strings.HasPrefix(line, prefix) {
return "", 0
}
rest := strings.TrimPrefix(line, prefix)
// Split off the optional " [ahead N, behind M]" tracking suffix.
branchPart := rest
ahead := 0
if idx := strings.Index(rest, " ["); idx >= 0 {
branchPart = rest[:idx]
ahead = parseAhead(rest[idx:])
}
// "## HEAD (no branch)" — detached; keep the raw token as the branch label.
branchPart = strings.TrimSpace(branchPart)
// Trim the "...upstream" tracking-branch tail if present.
if idx := strings.Index(branchPart, "..."); idx >= 0 {
branchPart = branchPart[:idx]
}
return branchPart, ahead
}
// parseAhead pulls the integer following "ahead " out of a tracking suffix such
// as "[ahead 4, behind 1]". It returns 0 when no ahead count is present.
func parseAhead(suffix string) int {
const marker = "ahead "
idx := strings.Index(suffix, marker)
if idx < 0 {
return 0
}
digits := suffix[idx+len(marker):]
n := 0
found := false
for _, r := range digits {
if r < '0' || r > '9' {
break
}
n = n*10 + int(r-'0')
found = true
}
if !found {
return 0
}
return n
}
+99
View File
@@ -0,0 +1,99 @@
package tui
import "testing"
func TestParseGitStatusDirtyCount(t *testing.T) {
// Sample `git status --porcelain -b` output: header + 4 entries (staged,
// modified, deleted, untracked).
out := "## master...origin/master [ahead 4, behind 1]\n" +
"M cmd/pigo/main.go\n" +
" M internal/foo.go\n" +
"D cmd/pigo/run.go\n" +
"?? new_file.go\n"
info := parseGitStatus(out)
if !info.ok {
t.Fatal("parseGitStatus should report ok=true")
}
if info.branch != "master" {
t.Errorf("branch = %q, want master", info.branch)
}
if info.dirty != 4 {
t.Errorf("dirty = %d, want 4", info.dirty)
}
if info.ahead != 4 {
t.Errorf("ahead = %d, want 4", info.ahead)
}
}
func TestParseGitStatusCleanTree(t *testing.T) {
out := "## main...origin/main\n"
info := parseGitStatus(out)
if !info.ok {
t.Fatal("ok should be true")
}
if info.branch != "main" {
t.Errorf("branch = %q, want main", info.branch)
}
if info.dirty != 0 {
t.Errorf("dirty = %d, want 0", info.dirty)
}
if info.ahead != 0 {
t.Errorf("ahead = %d, want 0", info.ahead)
}
}
func TestParseGitStatusNoUpstream(t *testing.T) {
out := "## feature-x\n M a.go\n"
info := parseGitStatus(out)
if info.branch != "feature-x" {
t.Errorf("branch = %q, want feature-x", info.branch)
}
if info.ahead != 0 {
t.Errorf("ahead = %d, want 0", info.ahead)
}
if info.dirty != 1 {
t.Errorf("dirty = %d, want 1", info.dirty)
}
}
func TestParseGitStatusDetached(t *testing.T) {
out := "## HEAD (no branch)\n"
info := parseGitStatus(out)
if info.branch != "HEAD (no branch)" {
t.Errorf("branch = %q, want %q", info.branch, "HEAD (no branch)")
}
}
func TestParseBranchHeaderAhead(t *testing.T) {
branch, ahead := parseBranchHeader("## dev...origin/dev [ahead 12]")
if branch != "dev" {
t.Errorf("branch = %q, want dev", branch)
}
if ahead != 12 {
t.Errorf("ahead = %d, want 12", ahead)
}
}
func TestParseBranchHeaderBehindOnly(t *testing.T) {
branch, ahead := parseBranchHeader("## dev...origin/dev [behind 3]")
if branch != "dev" {
t.Errorf("branch = %q, want dev", branch)
}
if ahead != 0 {
t.Errorf("ahead = %d, want 0 (behind only)", ahead)
}
}
func TestFetchGitCmdNonRepo(t *testing.T) {
// A path that is not a git repository should collapse to ok=false. /tmp is
// (almost) never a repo; if it somehow is on this host, skip.
msg := fetchGitCmd("/")()
git, ok := msg.(gitInfoMsg)
if !ok {
t.Fatalf("expected gitInfoMsg, got %T", msg)
}
if git.ok {
t.Skip("host root unexpectedly reports a git repo; skipping")
}
}
+91
View File
@@ -0,0 +1,91 @@
// This file makes runSession satisfy cli.Host: the accessor and mutator methods
// let the /status command (and future /goal, /btw) read the session's live
// collaborators and mutable state through the cli.Host contract rather than the
// concrete aggregate — the same seam the REPL's replDeps implements. The
// compile-time assertion below fails the build if runSession drifts out of
// conformance.
package tui
import (
"bufio"
"fmt"
"io"
"sync"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/plugin"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/session"
"github.com/smallnest/pigo/internal/trust"
)
var _ cli.Host = (*runSession)(nil)
func (s *runSession) Store() *session.Store { return s.store }
func (s *runSession) Header() session.SessionHeader { return s.header }
func (s *runSession) AgentCtx() *agentcore.AgentContext { return s.agentCtx }
func (s *runSession) Live() *cli.LiveConfig { return s.live }
func (s *runSession) Registry() *agenttool.ToolRegistry { return s.reg }
func (s *runSession) Reminders() *runtime.ReminderRegistry { return s.reminders }
func (s *runSession) Slash() *runtime.SlashRegistry { return s.slash }
func (s *runSession) Creds() *provider.CredentialStore { return s.creds }
func (s *runSession) Notifier() *plugin.EventNotifier { return nil }
func (s *runSession) NotifierHandle() func(agentcore.AgentEvent) { return s.onEvent }
func (s *runSession) Trust() *trust.Manager { return s.trust }
func (s *runSession) Goal() *agenttool.GoalState { return nil }
func (s *runSession) Telemetry() *cli.TelemetryHolder { return s.telemetry }
func (s *runSession) Dispatcher() *hooks.Dispatcher { return s.dispatcher }
func (s *runSession) HookDeps() run.HookDeps { return s.hookDeps }
func (s *runSession) Cwd() string { return s.cwd }
func (s *runSession) Input() *bufio.Reader { return nil }
func (s *runSession) ConfirmMu() *sync.Mutex { return nil }
func (s *runSession) CurLeaf() string { return s.curLeaf }
func (s *runSession) SetCurLeaf(id string) { s.curLeaf = id }
func (s *runSession) Persisted() int { return s.persisted }
func (s *runSession) SetPersisted(n int) { s.persisted = n }
func (s *runSession) LastBtw() *agentcore.AgentContext { return s.lastBtw }
func (s *runSession) SetLastBtw(ctx *agentcore.AgentContext) { s.lastBtw = ctx }
func (s *runSession) LastBtwBase() int { return s.lastBtwBase }
func (s *runSession) SetLastBtwBase(n int) { s.lastBtwBase = n }
// renderSession writes the /session summary (US-009, #125) to out — the same
// format the REPL's runSession prints: session id, message count, estimated
// token usage, model/provider, creation time, and compaction-checkpoint count.
// It lives on runSession so the TUI's /session intercept and the REPL share one
// rendering; counts derive from the in-memory context (the source of truth for
// the live turn), so unsaved messages are counted too.
func (s *runSession) renderSession(out io.Writer) {
msgs := s.agentCtx.Messages
tokens := compaction.EstimateContextTokens(msgs).Tokens
compactions := 0
for _, m := range msgs {
if _, ok := m.(agentcore.CompactionMessage); ok {
compactions++
}
}
fmt.Fprintf(out, "session: %s\n", s.header.ID)
fmt.Fprintf(out, "messages: %d\n", len(msgs))
fmt.Fprintf(out, "tokens (est): %d\n", tokens)
model := s.live.Model
providerName := s.live.ProviderName
if model == "" {
model = s.header.Model
}
if providerName == "" {
providerName = s.header.Provider
}
fmt.Fprintf(out, "model: %s (provider: %s)\n", model, providerName)
if !s.header.CreatedAt.IsZero() {
fmt.Fprintf(out, "created: %s\n", s.header.CreatedAt.Format(time.RFC3339))
}
fmt.Fprintf(out, "compactions: %d\n", compactions)
}
+167
View File
@@ -0,0 +1,167 @@
package tui
import (
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// This file implements the prompt input field of the full-screen TUI (US-007,
// FR-11/13/14). It wraps charm.land/bubbles/v2/textarea into a small `input`
// component so the model can embed a real multi-line editor instead of the
// throwaway string buffer the skeleton shipped with.
//
// Why textarea rather than a hand-rolled buffer: textarea edits by grapheme /
// rune, so CJK and emoji are inserted and deleted whole. This is exactly the
// class of bug the old REPL input had — it keyed on byte length (len==1) and
// silently dropped the trailing bytes of every multi-byte rune. We deliberately
// delegate all character handling to textarea and never touch bytes ourselves.
//
// Shift+Enter inserts a newline so the editor is a true multi-line composer;
// plain Enter submits (intercepted by the model, never reaching textarea). The
// default InsertNewline binding (Enter) is therefore rebound to Shift+Enter. See
// model.handleKey.
// maxInputRows caps how tall the editor grows as the user adds lines. Past this
// the buffer keeps growing but textarea scrolls its own viewport, so the shell's
// row accounting stays bounded and the transcript never collapses to nothing.
const maxInputRows = 6
// input is the prompt editor. It embeds a textarea.Model and exposes just the
// surface the root model needs: value/clear, focus/blur (input is blurred while
// a run is in flight so keystrokes never corrupt an in-flight prompt), a width
// setter driven by tea.WindowSizeMsg, and a render string for View.
type input struct {
ta textarea.Model
// width is the full editor width (terminal columns) last set via SetWidth. It
// is the span of the top/bottom rules drawn around the editor (Claude-Code
// style), kept separately because textarea's own Width() reports only the
// inner text area (prompt column excluded).
width int
}
// newInput builds a focused editor. It starts one row tall and grows with the
// buffer (up to maxInputRows) as the user inserts newlines with Enter. The
// buffer itself is unbounded; beyond maxInputRows textarea scrolls internally.
func newInput() input {
ta := textarea.New()
ta.Prompt = "> "
ta.Placeholder = "Type a message… (Enter to send, Shift+Enter for newline)"
ta.ShowLineNumbers = false
ta.CharLimit = 0
// Let the textarea own its own height: DynamicHeight grows/shrinks it to the
// content between MinHeight (1) and MaxHeight (maxInputRows), and — critically
// — fixes the viewport scroll offset in the same pass. Doing it manually (an
// after-the-fact SetHeight in syncHeight) left a stale scroll offset: inserting
// a newline scrolled the cursor into view while the editor was still 1 row
// tall, pushing the first line off the top, and the later SetHeight never
// scrolled it back — so a two-line buffer rendered as two blank lines.
ta.MinHeight = 1
ta.MaxHeight = maxInputRows
ta.DynamicHeight = true
// textarea.New starts at defaultHeight (6). DynamicHeight only recomputes on
// edits, so pin the empty editor to one row up front — otherwise the shell
// would reserve six rows before the user has typed anything.
ta.SetHeight(1)
// Rebind InsertNewline from its default (Enter) to the newline keys, since
// plain Enter is the model's submit key (handleKey intercepts it before
// textarea sees it). Shift+Enter is the primary, advertised binding: Bubble
// Tea v2 already enables the Kitty keyboard protocol's disambiguate flag
// (flag 1) on every View, so capable terminals (kitty, ghostty, wezterm,
// recent iTerm2) report Shift+Enter as a distinct CSI-u sequence rather than
// a bare CR. Crucially this is flag 1, NOT flag 8 (ReportAllKeysAsEscapeCodes)
// — flag 8 broke IME / CJK input because it strips associated text, whereas
// flag 1 only disambiguates special keys and leaves text entry untouched.
// On terminals without the protocol (macOS Terminal.app, tmux by default)
// Shift+Enter arrives byte-identical to Enter and would submit, so Ctrl+J (a
// literal LF, always distinct from Enter's CR) and Alt+Enter (ESC-prefixed,
// always distinct) are kept as silent fallbacks — a newline is guaranteed to
// work everywhere. All three split the line at the cursor and keep typed text.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("shift+enter", "ctrl+j", "alt+enter"),
key.WithHelp("shift+enter", "insert newline"),
)
// Draw the cursor into the rendered string: the model composes View as a
// plain string rather than driving textarea's real cursor reporting.
ta.SetVirtualCursor(true)
// Drop the default cursor-line background highlight so the composer is framed
// only by the top/bottom rules (see View), matching Claude Code — no fill.
styles := ta.Styles()
styles.Focused.CursorLine = lipgloss.NewStyle()
styles.Blurred.CursorLine = lipgloss.NewStyle()
ta.SetStyles(styles)
ta.Focus()
return input{ta: ta}
}
// Update forwards a message (typically a key press) to the underlying textarea
// and returns the updated component. The model calls this only for keys it does
// not intercept itself (submit / interrupt / quit), so textarea sees ordinary
// editing keys — including Enter (newline) and CJK / emoji runes, which it
// inserts whole. Height is owned by textarea's DynamicHeight (see newInput), so
// there is nothing to re-sync here.
func (in input) Update(msg tea.Msg) (input, tea.Cmd) {
var cmd tea.Cmd
in.ta, cmd = in.ta.Update(msg)
return in, cmd
}
// Height reports the current visible row count of the editor so the model can
// reserve that many rows in its View layout. It includes the two rule rows (top
// and bottom) drawn around the textarea.
func (in input) Height() int { return in.ta.Height() + 2 }
// Value returns the current buffer contents, including any embedded newlines.
func (in input) Value() string { return in.ta.Value() }
// SetValue replaces the buffer contents and moves the cursor to the end. It is
// used by slash autocomplete (Tab) to complete the buffer to the chosen command.
func (in *input) SetValue(s string) {
in.ta.SetValue(s)
}
// Clear empties the buffer and resets the cursor to the start.
func (in *input) Clear() {
in.ta.Reset()
}
// Focus enables editing and returns the cursor-blink Cmd.
func (in *input) Focus() tea.Cmd { return in.ta.Focus() }
// Blur disables editing (used while a run is in flight).
func (in *input) Blur() { in.ta.Blur() }
// Focused reports whether the editor currently accepts input.
func (in input) Focused() bool { return in.ta.Focused() }
// Line reports the zero-based index of the line the cursor is on, and LineCount
// the total number of lines in the buffer. The model uses them to decide whether
// ↑/↓ should walk the prompt history (caret on the first / last line) or move the
// caret within a multi-line draft.
func (in input) Line() int { return in.ta.Line() }
func (in input) LineCount() int { return in.ta.LineCount() }
// SetWidth resizes the editor to the terminal width so wrapping and the prompt
// column line up with the rest of the shell.
func (in *input) SetWidth(w int) {
if w < 0 {
w = 0
}
in.width = w
in.ta.SetWidth(w)
}
// View renders the editor to a string for embedding in the model's View. The
// textarea is framed with a top and bottom rule (no side borders) in the muted
// gray, mirroring Claude Code's composer — a pair of horizontal lines rather
// than a background fill. The rules span the full editor width.
func (in input) View() string {
style := lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, true, false).
BorderForeground(lipgloss.Color(colorGray))
if in.width > 0 {
style = style.Width(in.width)
}
return style.Render(in.ta.View())
}
+205
View File
@@ -0,0 +1,205 @@
package tui
import (
"strings"
"testing"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
)
// runeKey builds a printable-character key press carrying r, mirroring what a
// terminal sends for a typed rune: Code is the rune and Text is its UTF-8
// encoding (textarea inserts from Text). This is how CJK / emoji reach the
// component.
func runeKey(r rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: r, Text: string(r)}
}
// TestInputCJKByRune drives the input with the runes of "你好" and asserts the
// buffer holds the full multi-byte string with the cursor left on a rune
// boundary. This guards against the old REPL bug that keyed on byte length
// (len==1) and dropped the trailing bytes of every multi-byte rune.
func TestInputCJKByRune(t *testing.T) {
in := newInput()
for _, r := range "你好" {
var cmd tea.Cmd
in, cmd = in.Update(runeKey(r))
_ = cmd
}
got := in.Value()
if got != "你好" {
t.Fatalf("Value() = %q, want %q", got, "你好")
}
if !utf8.ValidString(got) {
t.Fatalf("Value() is not valid UTF-8: %q", got)
}
if n := utf8.RuneCountInString(got); n != 2 {
t.Fatalf("rune count = %d, want 2 (no dropped chars)", n)
}
// The cursor column is a rune index into the line; after two runes it must be
// 2, proving textarea advanced by whole runes rather than bytes.
if col := in.ta.Column(); col != 2 {
t.Errorf("cursor column = %d, want 2 (rune boundary)", col)
}
}
// TestInputEmojiByRune confirms a multi-byte emoji is inserted whole.
func TestInputEmojiByRune(t *testing.T) {
in := newInput()
in, _ = in.Update(runeKey('🚀'))
if got := in.Value(); got != "🚀" {
t.Fatalf("Value() = %q, want %q", got, "🚀")
}
}
// TestInputNewlineKeys verifies each newline binding — Shift+Enter (primary),
// Ctrl+J and Alt+Enter (fallbacks) — inserts a newline into the buffer while
// plain runes fill each line.
func TestInputNewlineKeys(t *testing.T) {
cases := []struct {
name string
key tea.KeyPressMsg
}{
{"shift+enter", tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift}},
{"ctrl+j", tea.KeyPressMsg{Code: 'j', Mod: tea.ModCtrl}},
{"alt+enter", tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in := newInput()
in, _ = in.Update(runeKey('你'))
in, _ = in.Update(tc.key)
in, _ = in.Update(runeKey('好'))
if got := in.Value(); got != "你\n好" {
t.Fatalf("Value() = %q, want %q", got, "你\n好")
}
})
}
}
// TestInputEnterIsNotNewline confirms plain Enter does NOT insert a newline in
// the editor: the model intercepts it as submit, so the editor must leave it
// alone (only Shift+Enter breaks a line).
func TestInputEnterIsNotNewline(t *testing.T) {
in := newInput()
in, _ = in.Update(runeKey('a'))
in, _ = in.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if got := in.Value(); got != "a" {
t.Fatalf("Value() = %q, want %q (Enter must not add a newline)", got, "a")
}
}
// TestInputNewlineRendersBothLines guards the viewport-offset regression: after
// 你 + Shift+Enter + 好 the buffer is "你\n好", but the editor once rendered two
// blank rows because a manual SetHeight left the textarea's viewport scrolled
// past the first line. DynamicHeight now resets the scroll offset in the same
// pass, so both runes must appear in the rendered View.
func TestInputNewlineRendersBothLines(t *testing.T) {
in := newInput()
in.SetWidth(40)
in, _ = in.Update(runeKey('你'))
in, _ = in.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
in, _ = in.Update(runeKey('好'))
view := in.View()
if !strings.Contains(view, "你") || !strings.Contains(view, "好") {
t.Fatalf("rendered view missing content, want both 你 and 好:\n%s", view)
}
}
// TestInputClearBlurFocus exercises the lifecycle methods the model relies on
// while gating input during a run.
func TestInputClearBlurFocus(t *testing.T) {
in := newInput()
in, _ = in.Update(runeKey('x'))
in.Clear()
if got := in.Value(); got != "" {
t.Errorf("after Clear, Value() = %q, want empty", got)
}
if !in.Focused() {
t.Error("newInput should start focused")
}
in.Blur()
if in.Focused() {
t.Error("after Blur, Focused() should be false")
}
in.Focus()
if !in.Focused() {
t.Error("after Focus, Focused() should be true")
}
}
// TestModelEnterSubmits feeds a typed line and Enter to the model and asserts
// the prompt is submitted: the user turn lands in the transcript and the editor
// is cleared. Enter submits; Shift+Enter is the newline key in the multi-line
// composer. With no startRunFn wired the model stays idle and records the
// pre-#392 system note.
func TestModelEnterSubmits(t *testing.T) {
m := NewModel(Options{})
var model tea.Model = m
for _, r := range "你好世界" {
model, _ = model.Update(runeKey(r))
}
model, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
got := model.(Model)
if got.input.Value() != "" {
t.Errorf("after submit, input = %q, want cleared", got.input.Value())
}
joined := strings.Join(blockTexts(got.transcript), "\n")
if !strings.Contains(joined, "你好世界") {
t.Errorf("submitted prompt missing from transcript: %q", joined)
}
}
// TestModelTwoStageInterrupt verifies FR-14: while running, Esc / Ctrl+C
// interrupts the in-flight run (calls interruptFn) and does NOT quit; while
// idle, the same keys quit the program.
func TestModelTwoStageInterrupt(t *testing.T) {
for _, key := range []tea.KeyPressMsg{
{Code: tea.KeyEscape},
{Code: 'c', Mod: tea.ModCtrl},
} {
// Running: first press interrupts, no quit.
interrupted := false
running := NewModel(Options{})
running.running = true
running.interruptFn = func() { interrupted = true }
next, cmd := running.Update(key)
if !interrupted {
t.Errorf("%s while running: interruptFn was not called", key.String())
}
if next.(Model).quitting {
t.Errorf("%s while running: model should not be quitting", key.String())
}
if cmd != nil {
if _, isQuit := cmd().(tea.QuitMsg); isQuit {
t.Errorf("%s while running: should not quit", key.String())
}
}
// Idle: the same key quits.
idle := NewModel(Options{})
got, cmd := idle.Update(key)
if cmd == nil {
t.Fatalf("%s while idle: expected a quit command", key.String())
}
if _, isQuit := cmd().(tea.QuitMsg); !isQuit {
t.Errorf("%s while idle: cmd should be tea.Quit", key.String())
}
if !got.(Model).quitting {
t.Errorf("%s while idle: model should be marked quitting", key.String())
}
}
}
// blockTexts extracts the raw text of every transcript block for assertions.
func blockTexts(t transcript) []string {
out := make([]string, len(t.blocks))
for i, b := range t.blocks {
out[i] = b.text
}
return out
}
+122
View File
@@ -0,0 +1,122 @@
package tui
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/x/ansi"
"github.com/smallnest/pigo/internal/cli/ui"
)
// This file renders finalized assistant turns as Markdown inside the TUI
// transcript (fix #3, mirroring the REPL's ui.RenderMarkdown). The REPL renders
// once at turn-end because Markdown can only be laid out when the whole block is
// known; the transcript does the same — only a finalized assistant block is
// passed through here, never the still-streaming one.
//
// Unlike the REPL's shared renderer (WithWordWrap(0), which relies on the raw
// terminal to soft-wrap), the transcript lives inside a fixed-width viewport
// that does NOT soft-wrap, so we must wrap the Markdown to the content width
// ourselves. Renderers are therefore cached per width and rebuilt when the width
// changes (a resize), which is rare enough that the rebuild cost is negligible.
var (
mdMu sync.Mutex
mdCache = map[int]*glamour.TermRenderer{}
// mdDark selects the glamour style: a dark palette when true (the default,
// matching most terminals), a light palette when false. It is set once from
// the terminal's real background via SetMarkdownDark and never queried at
// render time — see the comment there.
mdDark = true
)
// SetMarkdownDark records whether the terminal has a dark background and drops
// the renderer cache so the next render rebuilds with the matching style.
//
// This is the fix for the escape-sequence leak into the input box: glamour's
// WithAutoStyle() detects the palette by issuing its OWN synchronous OSC 11
// background-color query and reading the reply straight from the tty. Under the
// alt-screen, bubbletea already owns the input reader, so that reply
// (\x1b]11;rgb:…\x07) races with — and is swallowed by — bubbletea's parser,
// which then leaks the unparsed tail (e.g. "1;rgb:0000/0000/0000" plus a stray
// SGR mouse report) into the textarea as literal text. We instead let bubbletea
// detect the background the parser-safe way (RequestBackgroundColor →
// BackgroundColorMsg) and feed the result here, then build glamour with a fixed
// WithStandardStyle so it never touches the terminal.
func SetMarkdownDark(dark bool) {
mdMu.Lock()
defer mdMu.Unlock()
if dark == mdDark {
return
}
mdDark = dark
mdCache = map[int]*glamour.TermRenderer{}
}
// rendererFor returns a glamour renderer that word-wraps to width columns,
// building and caching one per distinct width. A build failure caches nothing
// and returns nil so callers fall back to the raw source. The style is fixed
// (WithStandardStyle) rather than auto-detected, so building a renderer never
// queries the terminal.
func rendererFor(width int) *glamour.TermRenderer {
mdMu.Lock()
defer mdMu.Unlock()
if r, ok := mdCache[width]; ok {
return r
}
wrap := width
if wrap < 0 {
wrap = 0
}
style := "dark"
if !mdDark {
style = "light"
}
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle(style),
glamour.WithWordWrap(wrap),
)
if err != nil {
return nil
}
mdCache[width] = r
return r
}
// renderMarkdown returns src rendered as styled terminal Markdown wrapped to
// width columns. It is gated exactly like the REPL's renderer: when output is
// not an interactive terminal (pipes, tests) the raw source is returned so
// golden tests and machine consumers are unaffected. A nil/broken renderer or a
// render error also returns the raw source, so content is never dropped. The
// trailing newline glamour appends is trimmed so the block joins cleanly with
// its neighbors in the transcript.
func renderMarkdown(src string, width int) string {
if !ui.Enabled() {
return src
}
if strings.TrimSpace(src) == "" {
return src
}
r := rendererFor(width)
if r == nil {
return src
}
out, err := r.Render(src)
if err != nil {
return src
}
// Glamour word-wraps prose to width, but its document margin and
// non-wrapping elements (code blocks, tables) can still emit lines wider than
// the content column. The transcript viewport does not clip horizontally, so
// an over-wide line would spill into (and visually erase) the persistent
// scrollbar column on its right. Hard-wrap the rendered output — ANSI- and
// wide-char-aware — so every line fits within width and the scrollbar stays
// put. Prose already within width is untouched.
trimmed := strings.Trim(out, "\n")
if width > 0 {
trimmed = ansi.Hardwrap(trimmed, width, false)
}
return trimmed
}
File diff suppressed because it is too large Load Diff
+519
View File
@@ -0,0 +1,519 @@
package tui
import (
"fmt"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/cli/ui"
)
// TestModelQuitKeys verifies the root model returns tea.Quit on the standard
// exit keys (Ctrl+C / Ctrl+D), which is how Bubble Tea tears down the program
// and restores the terminal from the alt-screen.
func TestModelQuitKeys(t *testing.T) {
for _, key := range []string{"ctrl+c", "ctrl+d"} {
m := NewModel(Options{})
got, cmd := m.Update(keyPress(key))
if cmd == nil {
t.Fatalf("%s: expected a quit command, got nil", key)
}
if msg := cmd(); msg != (tea.QuitMsg{}) {
t.Errorf("%s: cmd produced %T, want tea.QuitMsg", key, msg)
}
if !got.(Model).quitting {
t.Errorf("%s: model should be marked quitting", key)
}
}
}
// TestModelViewShell verifies the empty shell renders on the alt-screen and,
// once a size is known, occupies the full terminal height (empty transcript rows
// + status bar + input line), with the real status bar (#386) painting its
// fields.
func TestModelViewShell(t *testing.T) {
m := NewModel(Options{Model: "test-model"})
next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 10})
view := next.View()
if !view.AltScreen {
t.Error("View should request the alt-screen")
}
if got := strings.Count(view.Content, "\n"); got != 9 {
t.Errorf("newline count = %d, want 9 (10 rows)", got)
}
if !strings.Contains(view.Content, "test-model") {
t.Errorf("status bar model field missing from view: %q", view.Content)
}
}
// TestModelNewlineKeys verifies that Shift+Enter inserts a line break at the
// cursor and preserves the already-typed text, rather than submitting. Plain
// Enter still submits, so it does not leave a newline in the buffer. Shift+Enter
// is the primary newline key (reported distinctly by terminals speaking the
// Kitty disambiguate protocol, which Bubble Tea enables by default); Ctrl+J and
// Alt+Enter are fallbacks for terminals that collapse Shift+Enter to a bare CR.
func TestModelNewlineKeys(t *testing.T) {
var mm tea.Model = NewModel(Options{})
mm, _ = mm.Update(tea.WindowSizeMsg{Width: 60, Height: 10})
for _, r := range "abc" {
mm, _ = mm.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
}
mm, _ = mm.Update(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
for _, r := range "def" {
mm, _ = mm.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
}
if got := mm.(Model).input.Value(); got != "abc\ndef" {
t.Errorf("input = %q, want %q", got, "abc\ndef")
}
}
// TestModelSelectionCopy drives a mouse selection over a transcript line and
// asserts Ctrl+C copies the selected text (over OSC52) and clears the selection.
func TestModelSelectionCopy(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m.transcript.addUser("hello world")
// Locate the rendered screen cell where the text begins so the test does not
// hard-code the transcript's bottom-stick row.
rows := strings.Split(m.renderContent(), "\n")
y, x := -1, -1
for i, r := range rows {
plain := stripANSI(r)
if idx := strings.Index(plain, "hello world"); idx >= 0 {
y = i
x = ui.Width(plain[:idx])
break
}
}
if y < 0 {
t.Fatal("rendered screen did not contain the transcript text")
}
// Select exactly "hello world" (11 display cells) on that row.
m.sel = selection{active: true, anchor: point{x, y}, cursor: point{x + 11, y}}
next, cmd := m.Update(keyPress("ctrl+c"))
if cmd == nil {
t.Fatal("ctrl+c with a selection should emit a clipboard command")
}
if got := fmt.Sprintf("%s", cmd()); got != "hello world" {
t.Errorf("copied %q, want %q", got, "hello world")
}
if !next.(Model).sel.empty() {
t.Error("selection should be cleared after Ctrl+C copies it")
}
}
// TestModelCtrlCFallsBackToQuit verifies Ctrl+C with no selection keeps its
// interrupt/quit role (idle → quit).
func TestModelCtrlCFallsBackToQuit(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
next, cmd := m.Update(keyPress("ctrl+c"))
if cmd == nil || cmd() != (tea.QuitMsg{}) {
t.Fatal("ctrl+c without a selection should quit when idle")
}
if !next.(Model).quitting {
t.Error("model should be marked quitting")
}
}
// TestModelImagePasteInsertsPlaceholder verifies a clipboard image (already saved
// to a temp file) is stashed and shown in the composer as a compact "[Image #N]"
// placeholder, and that expandImages swaps it for an "@image:<path>" reference at
// submit so BuildUserContent attaches it as multimodal content.
func TestModelImagePasteInsertsPlaceholder(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
next, _ := m.Update(clipboardImageMsg{path: "/tmp/pigo-clip-1.png", ok: true})
m = next.(Model)
if got, want := m.input.Value(), "[Image #1]"; got != want {
t.Errorf("composer showed %q, want placeholder %q", got, want)
}
if got := m.expandImages(m.input.Value()); got != "@image:/tmp/pigo-clip-1.png" {
t.Errorf("expandImages = %q, want the @image reference", got)
}
}
// TestModelImagePasteFallsBackToText verifies an empty clipboard image reply
// (ok=false) falls back to an OSC52 text read rather than inserting anything.
func TestModelImagePasteFallsBackToText(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
next, cmd := m.Update(clipboardImageMsg{ok: false})
if cmd == nil {
t.Fatal("no image on the clipboard should fall back to a text read command")
}
if got := next.(Model).input.Value(); got != "" {
t.Errorf("composer should stay empty on fallback, got %q", got)
}
}
// TestModelExpandImagesUnknownID verifies an unknown image id is left untouched.
func TestModelExpandImagesUnknownID(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, clipboardImageMsg{path: "/tmp/a.png", ok: true})
got := m.expandImages("see [Image #1] and [Image #7]")
want := "see @image:/tmp/a.png and [Image #7]"
if got != want {
t.Errorf("expandImages = %q, want %q", got, want)
}
}
// keyPress builds a KeyPressMsg matching String()==s for the simple keys used
// in these tests (ctrl+<letter>).
func keyPress(s string) tea.KeyPressMsg {
switch s {
case "ctrl+c":
return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
case "ctrl+d":
return tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}
case "ctrl+y":
return tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}
case "super+c":
return tea.KeyPressMsg{Code: 'c', Mod: tea.ModSuper}
case "super+v":
return tea.KeyPressMsg{Code: 'v', Mod: tea.ModSuper}
default:
return tea.KeyPressMsg{}
}
}
// TestModelPasteSingleLineInsertsVerbatim verifies a single-line bracketed paste
// is inserted into the editor as-is (no placeholder collapsing).
func TestModelPasteSingleLineInsertsVerbatim(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, tea.PasteMsg{Content: "hello world"})
if got := m.input.Value(); got != "hello world" {
t.Errorf("input after paste = %q, want %q", got, "hello world")
}
}
// TestModelPasteMultilineCollapses verifies a multi-line paste is collapsed to a
// compact "[Pasted text #N +M lines]" placeholder in the composer (Claude Code
// style) while the full body is stashed and expanded back at submit.
func TestModelPasteMultilineCollapses(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, tea.PasteMsg{Content: "line1\nline2\nline3"})
if got, want := m.input.Value(), "[Pasted text #1 +3 lines]"; got != want {
t.Errorf("composer showed %q, want placeholder %q", got, want)
}
if got := m.expandPastes(m.input.Value()); got != "line1\nline2\nline3" {
t.Errorf("expandPastes = %q, want the original body", got)
}
}
// TestModelExpandPastesMultiple verifies several collapsed pastes each expand
// back to their own body, and an unknown id is left untouched.
func TestModelExpandPastesMultiple(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, tea.PasteMsg{Content: "aaa\nbbb"})
m = apply(t, m, tea.PasteMsg{Content: "ccc\nddd"})
got := m.expandPastes("x [Pasted text #1 +2 lines] y [Pasted text #2 +2 lines] [Pasted text #9 +9 lines]")
want := "x aaa\nbbb y ccc\nddd [Pasted text #9 +9 lines]"
if got != want {
t.Errorf("expandPastes = %q, want %q", got, want)
}
}
// TestModelClipboardReadInsertsIntoInput verifies an OSC52 clipboard read reply
// (tea.ClipboardMsg, the response to Ctrl+V) is inserted into the editor.
func TestModelClipboardReadInsertsIntoInput(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, tea.ClipboardMsg{Content: "pasted"})
if got := m.input.Value(); got != "pasted" {
t.Errorf("input after clipboard read = %q, want %q", got, "pasted")
}
}
// TestModelCopyToClipboard verifies Ctrl+Y emits an OSC52 SetClipboard command
// carrying the current buffer, and is a no-op on an empty buffer.
func TestModelCopyToClipboard(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
// Empty buffer: no command.
if _, cmd := m.Update(keyPress("ctrl+y")); cmd != nil {
t.Errorf("ctrl+y on empty buffer should be a no-op, got a command")
}
m = apply(t, m, tea.PasteMsg{Content: "copy me"})
_, cmd := m.Update(keyPress("ctrl+y"))
if cmd == nil {
t.Fatal("ctrl+y with content should emit a clipboard command")
}
// SetClipboard yields an unexported string-underlying message; format it to
// read its payload without depending on the tea-internal type.
if got := fmt.Sprintf("%s", cmd()); got != "copy me" {
t.Errorf("clipboard command carried %q, want %q", got, "copy me")
}
}
// TestModelSuperCCopiesSelection verifies Cmd+C (super+c) copies the mouse
// selection just like Ctrl+C, but with an empty buffer and no selection it is a
// no-op rather than quitting — Cmd+C is "copy" on macOS, never interrupt/quit.
func TestModelSuperCCopiesSelection(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
// No selection, empty buffer: no-op, and never a quit command.
if next, cmd := m.Update(keyPress("super+c")); cmd != nil {
t.Errorf("super+c with nothing to copy should be a no-op, got a command")
} else if next.(Model).quitting {
t.Error("super+c must never quit")
}
// No selection, non-empty buffer: copies the whole buffer.
m = apply(t, m, tea.PasteMsg{Content: "buffer text"})
if _, cmd := m.Update(keyPress("super+c")); cmd == nil {
t.Fatal("super+c with buffer content should emit a clipboard command")
} else if got := fmt.Sprintf("%s", cmd()); got != "buffer text" {
t.Errorf("super+c copied %q, want %q", got, "buffer text")
}
}
// TestModelSuperVPastes verifies Cmd+V (super+v) requests the clipboard over
// OSC52 when idle, like Ctrl+V.
func TestModelSuperVPastes(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
if _, cmd := m.Update(keyPress("super+v")); cmd == nil {
t.Fatal("super+v should emit a clipboard read command when idle")
}
}
// TestModelSubagentPanelLifecycle drives the sub-agent status panel through a
// task tool's lifecycle on the running model: a toolStartMsg(name=="task") opens
// a row, subagentProgressMsg refreshes it and it appears in the rendered View
// above the input, and the task's toolEndMsg retires it (empty panel → no rows).
func TestModelSubagentPanelLifecycle(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 20})
m.running = true // the panel only renders while a run is in flight
m.spinner.begin(time.Now(), "")
m = apply(t, m, toolStartMsg{id: "task-1", name: "task", input: map[string]any{"description": "build parser"}})
if got := m.subagents.active(); got != 1 {
t.Fatalf("active after task start = %d, want 1", got)
}
m = apply(t, m, subagentProgressMsg{id: "task-1", desc: "build parser", activity: "Editing", tokens: 64})
if row := m.subagents.byID["task-1"]; row == nil || row.activity != "Editing" {
t.Fatalf("row after progress = %+v, want activity=Editing", row)
}
// The panel line is identified by its ⏺ glyph (distinct from the tool card,
// which also mentions the description) plus the live activity.
if view := m.View().Content; !strings.Contains(view, "⏺") || !strings.Contains(view, "Editing") {
t.Errorf("view missing panel line: %q", view)
}
// A non-task tool must not open a panel row.
m = apply(t, m, toolStartMsg{id: "read-1", name: "read_file", input: map[string]any{"path": "/x"}})
if got := m.subagents.active(); got != 1 {
t.Errorf("active after non-task start = %d, want 1", got)
}
m = apply(t, m, toolEndMsg{id: "task-1", ok: true, result: "done"})
if got := m.subagents.active(); got != 0 {
t.Errorf("active after task end = %d, want 0", got)
}
if view := m.View().Content; strings.Contains(view, "⏺") {
t.Errorf("view still shows retired panel line: %q", view)
}
}
// TestModelCompactionIndicator verifies compactionStartMsg pins the spinner to
// "Compacting conversation…" while summarization runs, and compactionMsg clears
// the label and records the "(context compacted)" system note.
func TestModelCompactionIndicator(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 20})
m.running = true
m.spinner.begin(time.Now(), "")
m = apply(t, m, compactionStartMsg{})
if view := stripANSI(m.spinner.view(120)); !strings.Contains(view, "Compacting conversation…") {
t.Errorf("spinner view %q should show the compaction label", view)
}
m = apply(t, m, compactionMsg{})
if m.spinner.pinned != "" {
t.Errorf("compactionMsg should unpin the spinner, got %q", m.spinner.pinned)
}
if joined := strings.Join(blockTexts(m.transcript), "\n"); !strings.Contains(joined, "(context compacted)") {
t.Errorf("transcript should note the compaction, got:\n%s", joined)
}
}
// TestModelSubagentPanelHeightReservation verifies the panel's rows are reserved
// out of the transcript height so the total shell height is unchanged whether or
// not sub-agents are active.
func TestModelSubagentPanelHeightReservation(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 20})
m.running = true
m.spinner.begin(time.Now(), "")
m.relayout()
base := m.transcript.viewportHeight()
m = apply(t, m, toolStartMsg{id: "t1", name: "task", input: map[string]any{"description": "a"}})
m = apply(t, m, toolStartMsg{id: "t2", name: "task", input: map[string]any{"description": "b"}})
if got := m.transcript.viewportHeight(); got != base-2 {
t.Errorf("transcript height with 2 panel rows = %d, want %d (base %d - 2)", got, base-2, base)
}
// Every rendered frame stays exactly Height rows tall regardless of the panel.
if got := strings.Count(m.View().Content, "\n"); got != 19 {
t.Errorf("newline count = %d, want 19 (20 rows)", got)
}
}
// TestModelSubagentPanelNavigation verifies that while a run streams and the
// composer is empty, ↓/↑ move the panel cursor, Enter expands the selected row's
// accumulated output inline (fed by tool-update deltas), and Esc collapses/clears
// the selection rather than interrupting the run.
func TestModelSubagentPanelNavigation(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 24})
m.running = true
m.spinner.begin(time.Now(), "")
m = apply(t, m, toolStartMsg{id: "a", name: "task", input: map[string]any{"description": "task A"}})
m = apply(t, m, toolStartMsg{id: "b", name: "task", input: map[string]any{"description": "task B"}})
// A sub-agent's forwarded text arrives as an incremental tool-update delta.
m = apply(t, m, toolUpdateMsg{id: "b", partial: "output of B"})
// ↓ selects the top row, a second ↓ moves to row b.
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
if !m.subagents.hasSelection() || m.subagents.selected != 0 {
t.Fatalf("after down: selected=%d hasSel=%v, want 0/true", m.subagents.selected, m.subagents.hasSelection())
}
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
// Enter expands the selected row; its accumulated output shows in the render.
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
if got := m.subagents.expandedID(); got != "b" {
t.Fatalf("expandedID after enter = %q, want b", got)
}
if !strings.Contains(m.renderContent(), "output of B") {
t.Errorf("expanded render missing sub-agent output:\n%s", m.renderContent())
}
// Esc collapses/clears the selection and does NOT quit (no quit command).
next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
m = next.(Model)
if m.subagents.hasSelection() {
t.Error("esc should clear the panel selection")
}
if cmd != nil {
if _, isQuit := cmd().(tea.QuitMsg); isQuit {
t.Error("esc with an active selection should not quit the program")
}
}
}
// TestModelSubagentEscReturnsToInput verifies the one-key escape ("escape hatch: one key back to the input box"):
// while a sub-agent runs the composer is blurred (no typing), and after arrowing
// into the panel a single Esc both clears the selection and re-focuses the input
// box — so returning to the composer never requires more than one press and never
// interrupts the run.
func TestModelSubagentEscReturnsToInput(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 24})
m.running = true
m.spinner.begin(time.Now(), "")
m.input.Blur() // the composer is blurred for the duration of a run (startPrompt)
m = apply(t, m, toolStartMsg{id: "a", name: "task", input: map[string]any{"description": "task A"}})
// Arrow into the panel: a selection is now active while the input stays blurred.
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
if !m.subagents.hasSelection() {
t.Fatal("down should select a sub-agent row")
}
if m.input.Focused() {
t.Fatal("the composer should be blurred while a sub-agent run streams")
}
// One Esc escapes: selection cleared AND the input box re-focused, in a single
// press, without interrupting the run.
next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
m = next.(Model)
if m.subagents.hasSelection() {
t.Error("one Esc should clear the panel selection")
}
if !m.input.Focused() {
t.Error("one Esc should re-focus the input box (return to the composer)")
}
if !m.running {
t.Error("escaping the panel selection must not interrupt the run")
}
}
// TestModelPromptHistoryNavigation verifies shell-like prompt history: after
// submitting two prompts, ↑ from an empty composer recalls the most recent, a
// second ↑ walks further back, ↓ walks forward again, and a final ↓ restores the
// (empty) live draft. With no run starter wired, submit records history and
// leaves the composer idle/focused, so the arrow keys route to historyPrev /
// historyNext.
func TestModelPromptHistoryNavigation(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 12})
submit := func(s string) {
for _, r := range s {
m = apply(t, m, runeKey(r))
}
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
}
submit("first prompt")
submit("second prompt")
if m.input.Value() != "" {
t.Fatalf("composer should be empty after submit, got %q", m.input.Value())
}
// ↑ recalls the newest entry, a second ↑ the older one.
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyUp})
if got := m.input.Value(); got != "second prompt" {
t.Errorf("first ↑ recalled %q, want %q", got, "second prompt")
}
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyUp})
if got := m.input.Value(); got != "first prompt" {
t.Errorf("second ↑ recalled %q, want %q", got, "first prompt")
}
// ↓ walks forward to the newer entry, then past it to restore the live draft.
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
if got := m.input.Value(); got != "second prompt" {
t.Errorf("↓ walked to %q, want %q", got, "second prompt")
}
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
if got := m.input.Value(); got != "" {
t.Errorf("↓ past newest should restore the empty draft, got %q", got)
}
}
// TestModelPromptHistoryDedupsAndStashesDraft verifies two shell-like behaviors:
// a consecutive-duplicate submit is not stored twice, and an in-progress draft is
// stashed when browsing begins so ↓ past the newest entry brings it back.
func TestModelPromptHistoryDedupsAndStashesDraft(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 80, Height: 12})
submit := func(s string) {
for _, r := range s {
m = apply(t, m, runeKey(r))
}
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
}
submit("same")
submit("same") // consecutive duplicate — must not be stored twice
if len(m.history) != 1 {
t.Fatalf("history = %v, want a single deduped entry", m.history)
}
// Type a fresh draft, then browse: ↑ stashes the draft and recalls history,
// ↓ past the newest entry restores the stashed draft verbatim.
for _, r := range "draft" {
m = apply(t, m, runeKey(r))
}
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyUp})
if got := m.input.Value(); got != "same" {
t.Errorf("↑ recalled %q, want %q", got, "same")
}
m = apply(t, m, tea.KeyPressMsg{Code: tea.KeyDown})
if got := m.input.Value(); got != "draft" {
t.Errorf("↓ should restore the stashed draft %q, got %q", "draft", got)
}
}
+82
View File
@@ -0,0 +1,82 @@
package tui
import "github.com/smallnest/pigo/internal/agentcore"
// This file defines the tea.Msg types the event bridge (bridge.go) produces from
// a run's AgentEvents (US-004, SPEC 5.1). Each raw runtime signal is converted to
// exactly one of these value types so the Bubble Tea Update loop can dispatch on
// them with a plain type switch, keeping all run-time state changes on the tea
// goroutine (node #388 wires them into Model.Update). Every type is a value (not
// a pointer) so it flows through the tea.Msg (any) channel without aliasing the
// producer goroutine's state.
// textDeltaMsg carries the newest suffix of streaming assistant text — the bytes
// produced since the previous delta for the current turn (see DrainStream's
// OnText contract).
type textDeltaMsg struct{ delta string }
// turnEndMsg fires once per completed turn with the final assistant message and
// the tool results produced during it.
type turnEndMsg struct {
msg agentcore.AssistantMessage
results []agentcore.ToolResultMessage
}
// toolStartMsg is emitted before a tool runs. input holds the decoded call
// arguments when they are a JSON object; it is nil otherwise (the raw Args are
// an untyped any at the event layer).
type toolStartMsg struct {
id string
name string
input map[string]any
}
// toolUpdateMsg carries a partial result streamed during a tool's execution.
type toolUpdateMsg struct {
id string
partial string
}
// toolEndMsg is emitted when a tool finishes. ok is false when the tool reported
// an error; result is the tool's textual output.
type toolEndMsg struct {
id string
ok bool
result string
}
// subagentProgressMsg carries a running sub-agent's structured progress
// (translated from agentcore.SubAgentProgressEvent). id is the parent task
// tool-call id (the row key, matching the task's toolStartMsg/toolEndMsg id);
// desc is the task description (may be empty); activity is the current phase
// ("Reading"/"Editing"/…, never empty); tokens is a coarse output estimate
// (0 = unknown). Elapsed is NOT carried — the model computes it from the row's
// start time so the panel stays live without an event per frame.
type subagentProgressMsg struct {
id string
desc string
activity string
tokens int
}
// telemetryMsg carries the run's end-of-run telemetry summary.
type telemetryMsg struct{ ev agentcore.TelemetryEvent }
// compactionStartMsg signals that the loop is about to compact the context
// window. It pins the spinner to a "Compacting conversation…" label while the
// summarization request is in flight; compactionMsg clears it.
type compactionStartMsg struct{}
// compactionMsg signals that the loop compacted the context window. The event's
// details are not needed by the transcript, so it is a bare signal.
type compactionMsg struct{}
// runEndMsg is the final message: the run has fully drained. err is non-nil when
// the run ended in error (or was interrupted).
type runEndMsg struct{ err error }
// remoteInputMsg carries a prompt submitted from the paired remote browser
// (remote-control, #443). The listener Cmd (Model.waitRemoteInput) blocks on the
// bridge's RemoteInput channel and emits one per submission, re-issued after each
// so successive remote prompts keep arriving.
type remoteInputMsg struct{ text string }
+59
View File
@@ -0,0 +1,59 @@
package tui
import (
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/plugin"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
)
// Options carries the resolved run configuration into Run. It deliberately
// mirrors repl.Options (see internal/cli/repl/interactive.go) field-for-field so
// cmd/pigo's dispatch can map the same assembled environment to either path, and
// so downstream nodes can port the REPL's session/live/slash/trust wiring into
// the TUI without reshaping the entry seam. This skeleton node does not yet
// consume most fields — they are here to lock the contract.
type Options struct {
Model string
ProviderName string
Provider provider.Provider
BaseURL string
APIKey string
Protocol string
// Version is the running build version (main.version), shown in the startup
// banner. Empty or "dev"/"unknown" renders as-is with no update hint.
Version string
// ThinkingLevel is the resolved reasoning-effort level (US-023): it seeds the
// live run config so every turn requests it, until a control command changes
// it.
ThinkingLevel agentcore.ThinkingLevel
Tools []agentcore.AgentTool
SysPrompt string
// ResumeID, when non-empty, resumes an existing session: its messages seed
// the context and replayed transcript. Otherwise a fresh session is created.
ResumeID string
// Approve, when true, grants the launch directory session trust before the
// run so the first-launch trust prompt is skipped and side-effect tools run
// without per-call confirmation (mirrors pi's --approve/-a).
Approve bool
// Skills is the pre-loaded skill set (loaded once by run.SetupEnv, shared with
// prompt injection). Each is registered as a /skill-name command. Empty under
// --no-skills, so nothing is registered.
Skills []*runtime.Skill
// Plugins holds the loaded plugin manager so the TUI can deliver lifecycle
// events to subscribed plugins (US-017, #133). It may be nil (no plugins).
Plugins *plugin.Manager
// ConfigPrompts holds prompt-template paths from the config.toml `prompts`
// array (settings tier); each is a file or dir loaded non-recursively.
ConfigPrompts []string
// CliPrompts holds --prompt-template paths (CLI tier, repeatable).
CliPrompts []string
// NoPromptTemplates disables all prompt-template discovery (global, project,
// settings, CLI); built-in slash commands are unaffected. Independent of
// --no-skills.
NoPromptTemplates bool
}
+206
View File
@@ -0,0 +1,206 @@
// This file wires the remote-control bridge (internal/remotecontrol, #442) into
// the full-screen TUI, the counterpart to internal/cli/repl/remotecontrol.go.
// It adds the "/remote-control" command that starts/stops an in-process
// HTTP+WebSocket server mirroring the session to a paired browser on the LAN,
// mirrors transcript output to that browser, surfaces browser-submitted prompts
// as a tea.Msg, and routes side-effect tool-call confirmations to the browser
// while a client is connected.
//
// The non-remote path is unchanged: when no session is active the mirror is a
// no-op, waitRemoteInput returns a nil Cmd, and buildConfig installs no
// BeforeToolCall (tools run under the up-front trust the TUI already grants).
package tui
import (
"context"
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/remotecontrol"
"github.com/smallnest/pigo/internal/trust"
)
// remoteSession owns the running server + bridge for one /remote-control
// activation. It is stored on runSession (so buildConfig can reach it to install
// the confirm seam) and is nil until the command starts a session.
type remoteSession struct {
server *remotecontrol.Server
bridge *remotecontrol.Bridge
url string
}
// hasClient reports whether a browser is currently paired and connected.
func (rs *remoteSession) hasClient() bool {
return rs != nil && rs.bridge != nil && rs.bridge.Enabled()
}
// sendOutput mirrors session text to the remote browser. It records into the
// server's replay ring even when no client is connected, so a browser that pairs
// mid-session is replayed the recent scrollback.
func (rs *remoteSession) sendOutput(text string) {
if rs == nil || rs.server == nil || text == "" {
return
}
rs.server.SendOutput(text)
}
// startRemote builds and starts the server+bridge, storing the session on the
// runSession. It returns the pairing URL, or an error if a server is already
// running or the listener could not bind.
func (s *runSession) startRemote() (string, error) {
if s.remote != nil {
return s.remote.url, fmt.Errorf("already running")
}
// Break the server↔bridge construction cycle: build the server (Sink), then
// the bridge over it, then route client frames back to the bridge.
srv := remotecontrol.NewServer(remotecontrol.Config{}, nil)
bridge := remotecontrol.NewBridge(srv)
srv.SetHandler(bridge)
url, err := srv.Start()
if err != nil {
return "", err
}
s.remote = &remoteSession{server: srv, bridge: bridge, url: url}
return url, nil
}
// stopRemote shuts down the running server and clears the session. It is a no-op
// when remote control is off.
func (s *runSession) stopRemote() {
if s.remote == nil {
return
}
_ = s.remote.server.Stop(context.Background())
s.remote = nil
}
// remoteConfirmSeam builds the BeforeToolCall seam that routes side-effect
// tool-call confirmations to the paired browser while one is connected. When no
// browser is connected (or the tool is not side-effecting, or the cwd is
// trusted) it returns nil so the tool runs under the up-front trust the TUI
// grants — the non-remote behavior is unchanged.
//
// A ctx cancellation (interrupt) makes Confirm return remote=false, which is
// treated as a denial so an interrupted run does not silently proceed.
func remoteConfirmSeam(rs *remoteSession, mgr *trust.Manager, cwd string) agentcore.BeforeToolCallFunc {
return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
if !rs.hasClient() || mgr == nil {
return nil
}
if !trust.SideEffectTools[call.Name] {
return nil
}
if mgr.IsTrusted(cwd) {
return nil
}
summary := trust.ToolCallSummary(call)
d, remote := rs.bridge.Confirm(ctx, call.Name, summary)
if !remote {
return blockRemoteToolCall(call, cwd)
}
if d.Always {
mgr.SetSessionTrust(cwd)
}
if !d.Approve {
return blockRemoteToolCall(call, cwd)
}
return nil
}
}
func blockRemoteToolCall(call agentcore.AgentToolCall, cwd string) *agentcore.BeforeToolCallDecision {
msg := fmt.Sprintf("tool %q blocked: %s is not trusted (use /trust to trust this project)", call.Name, cwd)
return &agentcore.BeforeToolCallDecision{
Block: true,
Content: &agentcore.ContentList{agentcore.NewTextContent(msg)},
}
}
// runRemoteControl handles the /remote-control command and its stop/status
// subcommands. It mutates m.session.remote, folds a system block into the
// transcript, and returns the listener Cmd (waitRemoteInput) on a successful
// start so browser-submitted prompts begin arriving.
func (m Model) runRemoteControl(line string) (tea.Model, tea.Cmd) {
m.transcript.addUser(line)
m.input.Clear()
m.menu.close()
defer m.relayout()
if m.session == nil {
m.transcript.addSystem("(remote control unavailable: no active session)")
return m, nil
}
arg := strings.TrimSpace(strings.TrimPrefix(line, "/remote-control"))
switch arg {
case "stop":
if m.session.remote == nil {
m.transcript.addSystem("remote control is not running")
return m, nil
}
m.session.stopRemote()
m.transcript.addSystem("remote control stopped")
return m, nil
case "status":
if m.session.remote == nil {
m.transcript.addSystem("remote control: off")
return m, nil
}
state := "waiting for a browser to connect"
if m.session.remote.hasClient() {
state = "browser connected"
}
m.transcript.addSystem(fmt.Sprintf("remote control: on (%s)\n %s", state, m.session.remote.url))
return m, nil
case "":
if m.session.remote != nil {
m.transcript.addSystem("remote control already running:\n " + m.session.remote.url)
return m, nil
}
url, err := m.session.startRemote()
if err != nil {
m.transcript.addSystem("remote control: " + err.Error())
return m, nil
}
var b strings.Builder
fmt.Fprintf(&b, "Remote control started. Open this URL on a device on the same network:\n\n %s\n", url)
if qr, qerr := remotecontrol.Render(url); qerr == nil {
b.WriteString("\n" + qr)
}
b.WriteString("\nRun /remote-control stop to end the session.")
m.transcript.addSystem(b.String())
return m, m.waitRemoteInput()
default:
m.transcript.addSystem("usage: /remote-control [stop|status]")
return m, nil
}
}
// remoteEcho mirrors visible transcript text to the paired browser. It is a
// no-op when remote control is off, so callers can invoke it unconditionally at
// each point the transcript gains content.
func (m Model) remoteEcho(text string) {
if m.session != nil && m.session.remote != nil {
m.session.remote.sendOutput(text)
}
}
// waitRemoteInput returns a tea.Cmd that blocks on the bridge's remote-input
// channel and emits one remoteInputMsg per browser submission. The Update loop
// re-issues it after each so successive prompts keep arriving. It returns nil
// when remote control is off, which stops the listener.
func (m Model) waitRemoteInput() tea.Cmd {
if m.session == nil || m.session.remote == nil || m.session.remote.bridge == nil {
return nil
}
ch := m.session.remote.bridge.RemoteInput()
return func() tea.Msg {
text, ok := <-ch
if !ok {
return nil
}
return remoteInputMsg{text: text}
}
}
+145
View File
@@ -0,0 +1,145 @@
package tui
import (
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// newRemoteTestSession builds a fresh run session over a temp-dir store for the
// remote-control lifecycle tests (no resume, no tools).
func newRemoteTestSession(t *testing.T) *runSession {
t.Helper()
store := newTestStore(t)
s, _, err := newRunSessionWithStore(store, Options{})
if err != nil {
t.Fatalf("newRunSessionWithStore: %v", err)
}
return s
}
// TestStartStopRemote covers the start→already-running→stop lifecycle on
// runSession: startRemote binds a listener and stores the session, a second
// start reports "already running" without replacing it, and stopRemote clears
// the session so a later start can rebind.
func TestStartStopRemote(t *testing.T) {
s := newRemoteTestSession(t)
if s.remote != nil {
t.Fatal("remote should be nil before start")
}
url, err := s.startRemote()
if err != nil {
t.Fatalf("startRemote: %v", err)
}
if url == "" {
t.Fatal("startRemote returned empty url")
}
if s.remote == nil {
t.Fatal("remote should be set after start")
}
first := s.remote
// A second start is a no-op that reports the existing url and an error,
// leaving the running session untouched.
url2, err := s.startRemote()
if err == nil {
t.Error("second startRemote should report already-running error")
}
if url2 != url {
t.Errorf("second startRemote url = %q, want %q", url2, url)
}
if s.remote != first {
t.Error("second startRemote must not replace the running session")
}
s.stopRemote()
if s.remote != nil {
t.Error("remote should be nil after stop")
}
// Stop again is a no-op.
s.stopRemote()
// After stopping, a fresh start rebinds cleanly.
if _, err := s.startRemote(); err != nil {
t.Fatalf("restart after stop: %v", err)
}
s.stopRemote()
}
// TestBuildConfigInstallsRemoteSeam asserts buildConfig only installs the
// BeforeToolCall confirm seam while a remote session is present: off by default
// (up-front trust unchanged), wired once /remote-control is running.
func TestBuildConfigInstallsRemoteSeam(t *testing.T) {
s := newRemoteTestSession(t)
if cfg := s.buildConfig(); cfg.Batch.ToolExecutorConfig.BeforeToolCall != nil {
t.Error("BeforeToolCall should be nil when remote control is off")
}
if _, err := s.startRemote(); err != nil {
t.Fatalf("startRemote: %v", err)
}
defer s.stopRemote()
if cfg := s.buildConfig(); cfg.Batch.ToolExecutorConfig.BeforeToolCall == nil {
t.Error("BeforeToolCall should be installed when remote control is on")
}
}
// TestRemoteConfirmSeamAllowsWhenNoClient verifies the confirm seam is a no-op
// (returns nil = allow under up-front trust) when no browser is connected, so a
// running-but-unpaired server never blocks tool calls.
func TestRemoteConfirmSeamAllowsWhenNoClient(t *testing.T) {
s := newRemoteTestSession(t)
if _, err := s.startRemote(); err != nil {
t.Fatalf("startRemote: %v", err)
}
defer s.stopRemote()
// No client is paired, so hasClient() is false and the seam must allow.
seam := remoteConfirmSeam(s.remote, nil, "/tmp/project")
if d := seam(t.Context(), agentcore.AgentToolCall{Name: "bash"}); d != nil {
t.Errorf("seam should allow (nil) with no client, got %+v", d)
}
}
// TestRemoteInputIgnoredWhileRunning routes a remoteInputMsg into an idle vs a
// running Model: while a run is in flight the prompt is not started (a busy note
// is shown instead), and the listener is always re-issued so later submissions
// keep arriving.
func TestRemoteInputIgnoredWhileRunning(t *testing.T) {
s := newRemoteTestSession(t)
if _, err := s.startRemote(); err != nil {
t.Fatalf("startRemote: %v", err)
}
defer s.stopRemote()
m := NewModel(Options{})
m.session = s
m.running = true
updated, _ := m.Update(remoteInputMsg{text: "do something"})
got := updated.(Model)
if !hasSystemBlockContaining(got.transcript, "a run is in progress") {
t.Errorf("expected a busy note in the transcript while running, blocks=%v",
blockTexts(got.transcript))
}
// A run in progress must not consume the remote prompt as a new turn.
if !got.running {
t.Error("model should still be running; the remote prompt must not start a turn")
}
}
// hasSystemBlockContaining reports whether any transcript block's text contains
// sub (case-insensitive substring over the rendered block texts).
func hasSystemBlockContaining(t transcript, sub string) bool {
for _, s := range blockTexts(t) {
if strings.Contains(strings.ToLower(s), strings.ToLower(sub)) {
return true
}
}
return false
}
+24
View File
@@ -0,0 +1,24 @@
package tui
import (
tea "charm.land/bubbletea/v2"
)
// Run starts the full-screen TUI and blocks until the user quits (Ctrl+C /
// Ctrl+D) or the program errors. It is the alt-screen counterpart to repl.Run:
// cmd/pigo's dispatch calls it on the (no prompt + TTY + no --no-tui) path and
// maps its error to the process exit code. The alt-screen is entered/left via
// the View returned by the root Model, so a clean return here restores the
// terminal to the user's prior scrollback.
func Run(opts Options) error {
// Assemble the session (store, resume-or-fresh context, live config) before
// entering the alt-screen, mirroring repl.Run: a store/resume failure is a
// clean pre-launch error rather than a broken interactive session.
s, history, err := newRunSession(opts)
if err != nil {
return err
}
p := tea.NewProgram(NewModel(opts).withSession(s, history))
_, err = p.Run()
return err
}
+112
View File
@@ -0,0 +1,112 @@
package tui
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/smallnest/pigo/internal/cli/ui"
)
// This file implements mouse text selection over the rendered shell. Because the
// model paints the whole screen as one string (transcript + menu + input +
// status), a selection is expressed in screen cells (0-based, top-left origin)
// and spans any region uniformly — dragging across transcript output or the
// input line both work. The left mouse button starts a selection at the press
// cell and extends it on drag; the selection persists after release so Ctrl+C
// can copy it. A plain click (no drag) leaves an empty selection, which clears
// any prior highlight and lets Ctrl+C fall back to its interrupt/quit role.
// maxCol is a sentinel column that reaches past the end of any rendered row, so
// a multi-row selection's interior rows select through to their line end.
const maxCol = 1 << 30
// point is a screen cell: x is the column, y the row, both 0-based from the
// top-left of the rendered shell.
type point struct{ x, y int }
// selection is an in-progress or completed text selection. anchor is where the
// drag began and cursor is the latest drag point; active is set between the
// initial press and the next fresh press. It is a plain value so the Model can
// hold and copy it cheaply.
type selection struct {
active bool
anchor point
cursor point
}
// empty reports whether the selection covers no cells — either inactive or a
// bare click where the cursor never moved off the anchor. Ctrl+C treats an empty
// selection as "nothing to copy" and keeps its interrupt/quit behavior.
func (s selection) empty() bool {
return !s.active || s.anchor == s.cursor
}
// ordered returns the selection endpoints in reading order (top-to-bottom, and
// left-to-right within a row), so callers can walk rows start.y..end.y without
// re-checking which of anchor/cursor came first.
func (s selection) ordered() (start, end point) {
a, c := s.anchor, s.cursor
if a.y > c.y || (a.y == c.y && a.x > c.x) {
return c, a
}
return a, c
}
// rowRange computes the selected column span [c0, c1) on screen row y for a
// selection running start..end. Interior rows of a multi-row selection run from
// column 0 through the line end (maxCol); the first row starts at start.x and
// the last ends at end.x. ok is false when y falls outside the selection.
func rowRange(start, end point, y int) (c0, c1 int, ok bool) {
if y < start.y || y > end.y {
return 0, 0, false
}
c0, c1 = 0, maxCol
if y == start.y {
c0 = start.x
}
if y == end.y {
c1 = end.x
}
if c0 < 0 {
c0 = 0
}
if c1 < c0 {
c1 = c0
}
return c0, c1, true
}
// selectRow walks one rendered row (ANSI stripped to plain cells) and returns
// both the row with the selected span visually highlighted and the selected
// text itself. Columns are measured in display cells (ui.Width) so double-width
// runes are never split. The highlight is applied over plain text — the row's
// original coloring is dropped on the intersected row while a selection is live,
// which keeps the overlay ANSI-safe without parsing the embedded escapes.
func selectRow(row string, c0, c1 int, hi lipgloss.Style) (highlighted, text string) {
plain := ansi.Strip(row)
var out, sel, run strings.Builder
flush := func() {
if run.Len() > 0 {
out.WriteString(hi.Render(run.String()))
run.Reset()
}
}
col := 0
for _, r := range plain {
w := ui.Width(string(r))
if col >= c0 && col < c1 {
run.WriteRune(r)
sel.WriteRune(r)
} else {
flush()
out.WriteRune(r)
}
col += w
}
flush()
return out.String(), sel.String()
}
+59
View File
@@ -0,0 +1,59 @@
package tui
import (
"testing"
"charm.land/lipgloss/v2"
)
// TestRowRange checks the per-row column span for single- and multi-row
// selections, and that rows outside the range report ok=false.
func TestRowRange(t *testing.T) {
start, end := point{3, 1}, point{7, 3}
if _, _, ok := rowRange(start, end, 0); ok {
t.Error("row above the selection should not be selected")
}
if c0, c1, ok := rowRange(start, end, 1); !ok || c0 != 3 || c1 != maxCol {
t.Errorf("first row = (%d,%d,%v), want (3,maxCol,true)", c0, c1, ok)
}
if c0, c1, ok := rowRange(start, end, 2); !ok || c0 != 0 || c1 != maxCol {
t.Errorf("interior row = (%d,%d,%v), want (0,maxCol,true)", c0, c1, ok)
}
if c0, c1, ok := rowRange(start, end, 3); !ok || c0 != 0 || c1 != 7 {
t.Errorf("last row = (%d,%d,%v), want (0,7,true)", c0, c1, ok)
}
if _, _, ok := rowRange(start, end, 4); ok {
t.Error("row below the selection should not be selected")
}
// A single-row selection uses [start.x, end.x).
if c0, c1, ok := rowRange(point{2, 5}, point{9, 5}, 5); !ok || c0 != 2 || c1 != 9 {
t.Errorf("single row = (%d,%d,%v), want (2,9,true)", c0, c1, ok)
}
}
// TestSelectRowExtracts verifies the selected text is the column-clipped slice
// of the row, measured in display cells so CJK is never split, and that ANSI in
// the source row is stripped before slicing.
func TestSelectRowExtracts(t *testing.T) {
hi := lipgloss.NewStyle().Reverse(true)
if _, text := selectRow("hello world", 0, 5, hi); text != "hello" {
t.Errorf("selected %q, want %q", text, "hello")
}
if _, text := selectRow("hello world", 6, maxCol, hi); text != "world" {
t.Errorf("selected %q, want %q", text, "world")
}
// ANSI coloring in the source is stripped before the selection is measured.
styled := lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("hello")
if _, text := selectRow(styled, 0, maxCol, hi); text != "hello" {
t.Errorf("selected %q from styled row, want %q", text, "hello")
}
// CJK counts as two columns: selecting the first two cells yields one rune.
if _, text := selectRow("你好ab", 0, 2, hi); text != "你" {
t.Errorf("selected %q, want %q (double-width clipped on a cell boundary)", text, "你")
}
}
+491
View File
@@ -0,0 +1,491 @@
// This file binds the full-screen TUI to the real agent run seam and the local
// session store (US-009, FR-16/17). It is the TUI counterpart to the REPL's
// replDeps + streamRun + cli.PersistTurn plumbing (internal/cli/repl): it
// assembles an AgentContext + RunConfig from the model's Options, feeds them to
// the event bridge (bridge.go's startRun → runtime.StartRun/DrainStream), and
// persists the growing conversation to ~/.pigo/sessions after each turn.
//
// It deliberately imports the SHARED lower-level packages the REPL also uses
// (session, runtime, provider, cli, cli/run, cli/headless, cli/ui) rather than
// the repl package itself, so the two entry paths share one store and one
// run-config shape without an import cycle (repl and tui are siblings; prompts
// imports tui, so tui must not reach back into repl/prompts).
package tui
import (
"context"
"fmt"
"os"
"time"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/agenttool"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/headless"
"github.com/smallnest/pigo/internal/cli/run"
"github.com/smallnest/pigo/internal/cli/ui"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/hooks"
"github.com/smallnest/pigo/internal/memory"
"github.com/smallnest/pigo/internal/plugin"
"github.com/smallnest/pigo/internal/provider"
"github.com/smallnest/pigo/internal/runtime"
"github.com/smallnest/pigo/internal/session"
"github.com/smallnest/pigo/internal/trust"
)
// runSession holds the assembled per-session state for a TUI run: the persisted
// store + header, the growing conversation context, the live (mutable) run
// config, and the tool/credential collaborators. It mirrors the subset of
// repl.replDeps the TUI needs, and owns the same session-tree cursor bookkeeping
// (curLeaf / persisted) so each turn is persisted as a branch rather than a
// flattening rewrite.
type runSession struct {
store *session.Store
header session.SessionHeader
agentCtx *agentcore.AgentContext
live *cli.LiveConfig
reg *agenttool.ToolRegistry
reminders *runtime.ReminderRegistry
creds *provider.CredentialStore
// cwd is the directory pigo was launched in, captured once at session
// assembly. It is the trust key and the /status environment display.
cwd string
// trust persists project-trust decisions (US-018, #134). It is nil when
// trust is disabled (store could not be loaded / no cwd); when nil /status
// reports "disabled" and the trust-gated hook layer is skipped.
trust *trust.Manager
// slash is the shared slash-command registry the TUI consults exactly as the
// REPL does. It is assembled per-session against the live config (withSession
// rebinds the model's registry to this one) so /model switches reach it.
slash *runtime.SlashRegistry
// telemetry holds the retained per-run telemetry events (US-001, #291) and
// the cumulative accumulator that sums metrics across all runs in the
// session. The run loop folds each run's TelemetryEvent into it; /status
// reads it back through the Host contract.
telemetry *cli.TelemetryHolder
// memoryRoot is the persistent-memory Store root (empty when memory is
// disabled). It routes auto-compaction checkpoints and /rebuild recovery to
// <memoryRoot>/sessions/<id>/, the canonical checkpoint location.
memoryRoot string
// memstore is the live persistent-memory Store (nil when memory is disabled).
// It lets /memory inspect entry counts without re-opening the database.
memstore *memory.Store
// dispatcher is the session's hook dispatcher, nil when no hooks are
// configured (FR-18). hookDeps carries the session id / project dir stamped
// onto every HookInput and hook process environment.
dispatcher *hooks.Dispatcher
hookDeps run.HookDeps
// onEvent is the observer chain delivered to every run: the plugin notifier
// (US-017) with the SessionEnd/PreCompact hook notifier chained after it.
onEvent func(agentcore.AgentEvent)
// curLeaf is the id of the on-disk entry the next turn descends from; persisted
// is the number of agentCtx.Messages already written. persist() appends only
// Messages[persisted:] as a branch from curLeaf (see cli.PersistTurn).
curLeaf string
persisted int
// compacted is set when the run loop compacted the context (CompactionEvent):
// compaction rewrites Messages into a summary + recent tail, which both shrinks
// the slice below persisted (so an incremental Messages[persisted:] would panic)
// and invalidates the branch prefix. persist() honors this by re-saving the
// flattened context linearly and resetting the branch cursor, then clears it.
compacted bool
// cancelRun cancels the in-flight run's context; startRun sets it and the
// two-stage interrupt (Model.interruptFn → interrupt) calls it. It is nil
// before the first run and after a run is cancelled.
cancelRun context.CancelFunc
// lastBtw is the /btw side thread's context from this process and
// lastBtwBase the background-message index it diverged from. Both are
// carried on the Host contract for parity with the REPL; the TUI does not
// run /btw today, so they stay nil/0.
lastBtw *agentcore.AgentContext
lastBtwBase int
// remote owns the running remote-control server+bridge (remotecontrol.go),
// nil when /remote-control is off. buildConfig reads it to install the remote
// confirm seam so risky tool calls route to the paired browser while connected.
remote *remoteSession
}
// newRunSession assembles the run session from the resolved Options, opening the
// shared ~/.pigo/sessions store. When Options carries a ResumeID it loads that
// session's entries and rebuilds the context (the returned history seeds the
// replayed transcript); otherwise it starts a fresh session with a new header.
// It is the production entry; newRunSessionWithStore holds the store-agnostic
// core so tests can drive it against a temp-dir store.
func newRunSession(opts Options) (*runSession, []agentcore.Message, error) {
store, err := headless.SessionStore()
if err != nil {
return nil, nil, err
}
return newRunSessionWithStore(store, opts)
}
// newRunSessionWithStore is the store-agnostic core of newRunSession: given an
// already-opened store it resolves resume-vs-fresh, builds the live config and
// collaborators, and returns the session plus the resumed history (nil for a
// fresh session).
func newRunSessionWithStore(store *session.Store, opts Options) (*runSession, []agentcore.Message, error) {
creds := provider.NewCredentialStore(nil)
creds.SetOverride(opts.ProviderName, opts.APIKey)
// cwd is the launch directory, captured once: it stamps fresh sessions, is
// the trust key, and feeds /status's environment section and the hook layer.
cwd, _ := os.Getwd()
now := time.Now().UTC()
var (
agentCtx *agentcore.AgentContext
header session.SessionHeader
history []agentcore.Message
curLeaf string
)
if opts.ResumeID != "" {
h, entries, err := store.LoadEntries(opts.ResumeID)
if err != nil {
return nil, nil, err
}
msgs := make(agentcore.MessageList, len(entries))
for i, e := range entries {
msgs[i] = e.Message
}
if len(entries) > 0 {
curLeaf = entries[len(entries)-1].ID
}
header = h
sysPrompt := h.SystemPrompt
if sysPrompt == "" {
sysPrompt = opts.SysPrompt
}
agentCtx = &agentcore.AgentContext{SystemPrompt: sysPrompt, Messages: msgs, Tools: opts.Tools}
history = msgs
} else {
agentCtx = &agentcore.AgentContext{SystemPrompt: opts.SysPrompt, Tools: opts.Tools}
// Stamp the launch directory onto a fresh session (#526/#524) so the
// session is attributed to a project and a later /dream pass can distill it
// under the right scope, mirroring headless/REPL. An unresolvable cwd
// yields "" (session stays unattributed) rather than aborting.
header = session.SessionHeader{
ID: session.NewID(now),
CreatedAt: now,
UpdatedAt: now,
Model: opts.Model,
Provider: opts.ProviderName,
SystemPrompt: opts.SysPrompt,
Cwd: cwd,
}
}
live := &cli.LiveConfig{
Model: opts.Model,
ProviderName: opts.ProviderName,
Provider: opts.Provider,
BaseURL: opts.BaseURL,
Protocol: opts.Protocol,
ThinkingLevel: opts.ThinkingLevel,
ContextWindow: cli.DefaultContextWindow,
}
// Project trust (US-018, #134): load the persisted trust store for the
// launch directory, mirroring the REPL. A load failure (or an unresolvable
// cwd) is non-fatal: trust is disabled (mgr stays nil) and the TUI still
// runs — the store is surfaced rather than silently overwritten.
mgr, mgrErr := trust.NewManager(trust.DefaultPath())
if mgrErr != nil {
fmt.Fprintf(os.Stderr, "pigo: trust store unavailable, trust disabled: %v\n", mgrErr)
mgr = nil
}
if cwd == "" && mgr != nil {
fmt.Fprintf(os.Stderr, "pigo: cannot resolve working directory, trust disabled\n")
mgr = nil
}
s := &runSession{
store: store,
header: header,
agentCtx: agentCtx,
live: live,
reg: run.ToolRegistry(opts.Tools),
reminders: run.TodoReminders(opts.Tools),
creds: creds,
cwd: cwd,
trust: mgr,
slash: newSlashRegistry(opts, live),
telemetry: cli.NewTelemetryHolder(),
curLeaf: curLeaf,
persisted: len(history),
memoryRoot: run.MemoryRootFromTools(opts.Tools),
memstore: run.MemoryStoreFromTools(opts.Tools),
}
// /trust is a per-session command (its closure captures mgr + cwd), so it is
// registered here rather than in newSlashRegistry. A nil mgr is a no-op.
trust.RegisterCommand(s.slash, mgr, cwd)
// Wire hooks uniformly with every other driver (#425): resolve the trust-gated
// hook set, build the dispatcher, dispatch SessionStart once, and compose the
// SessionEnd/PreCompact observer with the plugin notifier. Trust is granted by
// --approve (Options.Approve) or the shared trust store; project-layer hooks
// only apply when trusted (FR-14). A malformed hook layer disables hooks with a
// warning rather than failing the TUI launch.
s.hookDeps = run.HookDeps{SessionID: header.ID, ProjectDir: cwd, WarnLog: os.Stderr}
trusted := opts.Approve || (mgr != nil && mgr.IsTrusted(cwd))
var baseOnEvent func(agentcore.AgentEvent)
if n := plugin.NewEventNotifier(opts.Plugins, os.Stderr); n != nil {
baseOnEvent = n.Handle
}
if set, err := run.ResolveHookSet(cwd, trusted); err != nil {
fmt.Fprintf(os.Stderr, "pigo: hooks disabled: %v\n", err)
s.onEvent = baseOnEvent
} else if d := run.BuildDispatcher(set, s.hookDeps); d != nil {
s.dispatcher = d
if s.reminders == nil {
s.reminders = runtime.NewReminderRegistry()
}
ssCfg := runtime.RunConfig{Reminders: s.reminders}
run.DispatchSessionStart(context.Background(), d, &ssCfg, s.hookDeps, sessionStartSource(opts))
s.reminders = ssCfg.Reminders
n := hooks.NewHookNotifier(d, s.hookDeps.SessionID, s.hookDeps.ProjectDir)
s.onEvent = chainTUIEvent(baseOnEvent, n.Handle)
} else {
s.onEvent = baseOnEvent
}
return s, history, nil
}
// sessionStartSource maps the resolved run options to the SessionStart source
// tag: "resume" when continuing an existing session, "startup" otherwise.
func sessionStartSource(opts Options) string {
if opts.ResumeID != "" {
return "resume"
}
return "startup"
}
// chainTUIEvent composes the plugin notifier with the hook notifier into one
// observer; a nil operand is identity.
func chainTUIEvent(prev, next func(agentcore.AgentEvent)) func(agentcore.AgentEvent) {
if prev == nil {
return next
}
if next == nil {
return prev
}
return func(ev agentcore.AgentEvent) {
prev(ev)
next(ev)
}
}
// buildConfig assembles the RunConfig for one turn from the live config and
// collaborators. It replicates repl.streamRun's assembly (same LoopConfig fields,
// tool registry and reminders) minus the interactive trust confirmation hook: the
// TUI has no stdin prompt to confirm side-effect tool calls on, so tools run
// under the trust granted up front by --approve (Options.Approve) rather than a
// per-call BeforeToolCall prompt. The stream fn is derived from the live provider
// and the API key resolved through the credential store, exactly as the REPL does.
func (s *runSession) buildConfig() runtime.RunConfig {
cfg := runtime.RunConfig{
LoopConfig: runtime.LoopConfig{
Model: s.live.Model,
Provider: s.live.ProviderName,
ThinkingLevel: s.live.ThinkingLevel,
Stream: provider.StreamFnFromProvider(s.live.Provider),
GetAPIKey: s.creds.GetAPIKey,
ContextWindow: s.live.ContextWindow,
Compaction: compaction.DefaultCompactionSettings,
},
Batch: agenttool.BatchConfig{
ToolExecutorConfig: agenttool.ToolExecutorConfig{
Registry: s.reg,
},
},
Reminders: s.reminders,
SessionID: s.header.ID,
MemoryRoot: s.memoryRoot,
}
// Per-turn wiring of the tool-execution + Stop seams; nil dispatcher is a
// no-op so the hot path pays nothing when no hooks are configured (FR-18).
if s.dispatcher != nil {
run.InstallSeams(&cfg, s.dispatcher, s.hookDeps)
}
// When remote control is active, route side-effect tool-call confirmations to
// the paired browser (no-op when no client is connected or the cwd is trusted,
// so the non-remote path is unchanged). The trust manager is read from the
// shared store; a nil manager disables the seam.
if s.remote != nil {
if mgr, err := trust.NewManager(trust.DefaultPath()); err == nil {
cfg.Batch.ToolExecutorConfig.BeforeToolCall = remoteConfirmSeam(s.remote, mgr, s.hookDeps.ProjectDir)
}
}
return cfg
}
// rebuildDoneMsg reports the outcome of a manual /rebuild to the model: summary
// is the status line to show in the transcript, err is set when the rebuild
// failed (the context is then left unchanged).
type rebuildDoneMsg struct {
summary string
err error
}
// rebuildCmd runs a context rebuild off the tea loop (the no-checkpoint fallback
// makes a summarization LLM call, so it must not block the UI goroutine) and
// yields a rebuildDoneMsg the model folds into the transcript. It mirrors the
// REPL's runManualRebuild.
func (s *runSession) rebuildCmd() tea.Cmd {
return func() tea.Msg {
summary, err := s.rebuild()
return rebuildDoneMsg{summary: summary, err: err}
}
}
// rebuild reconstructs the shared context from the session's persisted checkpoint
// (collapsing the pre-watermark prefix to the checkpoint summary and preserving
// the recent tail verbatim), falling back to lossy compaction when no checkpoint
// exists. It replaces agentCtx.Messages in place on success and flags compacted
// so persist() re-saves the flattened context linearly (as after a /compact).
func (s *runSession) rebuild() (string, error) {
msgs := s.agentCtx.Messages
before := compaction.EstimateContextTokens(msgs).Tokens
// Checkpoints live under <memoryRoot>/sessions/<id>/; recover from the same
// root the loop writes to. Empty when memory is disabled — RebuildFromCheckpoint
// then falls back to lossy compaction.
memoryRoot := s.memoryRoot
cfg := s.buildConfig()
res, err := runtime.RebuildFromCheckpoint(context.Background(), msgs, s.header.ID, memoryRoot, &cfg, nil)
if err != nil {
return "", err
}
if res.NoOp {
return fmt.Sprintf("nothing to rebuild (%d tokens, %d messages)", before, len(msgs)), nil
}
s.agentCtx.Messages = res.Messages
s.compacted = true
source := "checkpoint"
if !res.FromCheckpoint {
source = "compaction (no checkpoint)"
}
return fmt.Sprintf("context rebuilt from %s: %d → %d tokens, collapsed %d messages, kept %d",
source, res.TokensBefore, res.TokensAfter, res.SummarizedCount, res.KeptCount), nil
}
// prompt to the growing context as a user message, then hands the context and a
// freshly-built config to the event bridge (bridge.startRun → runtime.StartRun +
// DrainStream on a goroutine), returning the bridge channel and the first
// waitForEvent Cmd so Update can pump the run's events. The context grows in
// place (agentCtx is a pointer), so the next turn continues the conversation.
func (s *runSession) startRun(prompt string) (chan tea.Msg, tea.Cmd) {
content, err := ui.BuildUserContent(prompt)
if err != nil {
// A malformed image reference must not swallow the turn: fall back to the
// raw prompt as plain text so the run still starts.
content = agentcore.ContentList{agentcore.NewTextContent(prompt)}
}
// UserPromptSubmit runs before the prompt is committed to the context: a block
// aborts the turn (emitting a runEndMsg carrying the reason) without leaving a
// dangling user message; additionalContext is injected into this turn only.
if s.dispatcher != nil {
pc := runtime.RunConfig{Reminders: s.reminders}
if block, reason := run.DispatchUserPromptSubmit(context.Background(), s.dispatcher, &pc, s.hookDeps, prompt); block {
ch := newEventChan()
go func() { ch <- runEndMsg{err: fmt.Errorf("prompt blocked by hook: %s", reason)} }()
return ch, waitForEvent(ch)
}
s.reminders = pc.Reminders
}
s.agentCtx.Messages = append(s.agentCtx.Messages, agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: content,
})
// Use a cancellable context so the two-stage interrupt (FR-14) can stop this
// run: cancelling propagates through StartRun/DrainStream, which then emits a
// runEndMsg and the model returns to idle.
ctx, cancel := context.WithCancel(context.Background())
s.cancelRun = cancel
return startRun(ctx, s.agentCtx, s.buildConfig(), s.onEvent)
}
// interrupt cancels the in-flight run, if any. It is bound to Model.interruptFn
// by withSession so pressing Esc / Ctrl+C while running stops the current run
// instead of quitting the program (FR-14). Safe to call when no run is active.
func (s *runSession) interrupt() {
if s.cancelRun != nil {
s.cancelRun()
}
}
// persist writes the messages produced since the last persist as a new branch
// descending from the active leaf, advancing the leaf and the persisted cursor.
// It mirrors cli.PersistTurn: growing the on-disk tree with AppendBranch (rather
// than a linear rewrite) keeps history intact. A no-op when nothing new was
// produced, so an idle turn-end never regenerates entry ids.
func (s *runSession) persist() error {
// A compaction during the run rewrote Messages into a summary + recent tail,
// so the append-a-tail branch model no longer holds: the prefix changed and
// the slice may be shorter than persisted. Re-save the flattened context
// linearly and reset the branch cursor to the new leaf, mirroring the REPL's
// /compact handling.
if s.compacted || s.persisted > len(s.agentCtx.Messages) {
s.header.UpdatedAt = time.Now().UTC()
s.header.Model = s.live.Model
s.header.Provider = s.live.ProviderName
if err := s.store.Save(s.header, s.agentCtx.Messages); err != nil {
return err
}
s.persisted = len(s.agentCtx.Messages)
s.curLeaf = ""
if _, entries, err := s.store.LoadEntries(s.header.ID); err == nil && len(entries) > 0 {
s.curLeaf = entries[len(entries)-1].ID
}
s.compacted = false
return nil
}
tail := s.agentCtx.Messages[s.persisted:]
if len(tail) == 0 {
return nil
}
s.header.UpdatedAt = time.Now().UTC()
s.header.Model = s.live.Model
s.header.Provider = s.live.ProviderName
leaf, err := s.store.AppendBranch(s.header, s.curLeaf, tail)
if err != nil {
return err
}
s.curLeaf = leaf
s.persisted = len(s.agentCtx.Messages)
return nil
}
// seedTranscript replays a resumed session's prior messages into the transcript
// so the user sees the conversation so far before re-prompting (the TUI analogue
// of repl.replayTranscript). User and assistant text become their respective
// blocks; assistant tool calls render as system lines (tool cards land in #389).
// Tool-result messages are omitted here — their content is echoed live during a
// run, and replaying raw results would clutter the resumed view.
func seedTranscript(t *transcript, history []agentcore.Message) {
for _, m := range history {
switch msg := m.(type) {
case agentcore.UserMessage:
if text := agentcore.ContentToText(msg.Content); text != "" {
t.addUser(text)
}
case agentcore.AssistantMessage:
if text := agentcore.ContentToText(msg.Content); text != "" {
t.finalizeTurn(msg)
}
for _, c := range msg.ToolCalls() {
t.addSystem("· " + c.Name)
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package tui
import (
"testing"
"time"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/session"
)
// newTestStore opens a session store rooted at a temp dir so persistence/resume
// can be exercised without touching ~/.pigo.
func newTestStore(t *testing.T) *session.Store {
t.Helper()
store, err := session.NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return store
}
// saveSession writes a linear session with the given messages and returns its id.
func saveSession(t *testing.T, store *session.Store, msgs agentcore.MessageList) string {
t.Helper()
now := time.Now().UTC()
header := session.SessionHeader{
ID: session.NewID(now),
CreatedAt: now,
UpdatedAt: now,
Model: "test-model",
Provider: "test-provider",
}
if err := store.Save(header, msgs); err != nil {
t.Fatalf("Save: %v", err)
}
return header.ID
}
// TestResumeSeedsTranscript constructs a session with a few messages, resumes it
// through newRunSessionWithStore, seeds a transcript with the returned history,
// and asserts the initial transcript blocks carry those messages (FR-16 resume).
func TestResumeSeedsTranscript(t *testing.T) {
store := newTestStore(t)
id := saveSession(t, store, agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hello, world")}},
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("hello back")}},
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("second question")}},
})
s, history, err := newRunSessionWithStore(store, Options{ResumeID: id})
if err != nil {
t.Fatalf("newRunSessionWithStore: %v", err)
}
if len(history) != 3 {
t.Fatalf("history len = %d, want 3", len(history))
}
// The persisted cursor must cover the full resumed history so the first new
// turn appends only fresh messages, not a re-save of history.
if s.persisted != 3 {
t.Errorf("persisted = %d, want 3", s.persisted)
}
if s.curLeaf == "" {
t.Error("curLeaf should be the resumed leaf, got empty")
}
tr := newTranscript(DefaultTheme())
seedTranscript(&tr, history)
wantTexts := []string{"hello, world", "hello back", "second question"}
if len(tr.blocks) != len(wantTexts) {
t.Fatalf("transcript blocks = %d, want %d", len(tr.blocks), len(wantTexts))
}
for i, want := range wantTexts {
if tr.blocks[i].text != want {
t.Errorf("block[%d] = %q, want %q", i, tr.blocks[i].text, want)
}
}
}
// TestBuildConfigAssembly asserts the run-config assembly maps the live config
// onto RunConfig without a live provider: the model/provider/thinking/window
// fields flow through, compaction is enabled, and the tool registry is wired.
func TestBuildConfigAssembly(t *testing.T) {
store := newTestStore(t)
s, _, err := newRunSessionWithStore(store, Options{
Model: "opus-test",
ProviderName: "anthropic",
ThinkingLevel: agentcore.ThinkingLevel("high"),
})
if err != nil {
t.Fatalf("newRunSessionWithStore: %v", err)
}
cfg := s.buildConfig()
if cfg.Model != "opus-test" {
t.Errorf("cfg.Model = %q, want opus-test", cfg.Model)
}
if cfg.Provider != "anthropic" {
t.Errorf("cfg.Provider = %q, want anthropic", cfg.Provider)
}
if cfg.ThinkingLevel != agentcore.ThinkingLevel("high") {
t.Errorf("cfg.ThinkingLevel = %q, want high", cfg.ThinkingLevel)
}
if cfg.ContextWindow <= 0 {
t.Errorf("cfg.ContextWindow = %d, want a positive default", cfg.ContextWindow)
}
if !cfg.Compaction.Enabled {
t.Error("cfg.Compaction.Enabled = false, want true (DefaultCompactionSettings)")
}
if cfg.Batch.Registry == nil {
t.Error("cfg.Batch.Registry is nil, want the assembled tool registry")
}
if cfg.Stream == nil {
t.Error("cfg.Stream is nil, want a stream fn derived from the provider")
}
}
// TestFreshSessionPersists starts a fresh session, appends a turn to the context,
// persists it, and confirms it round-trips back through the store (FR-16 persist).
func TestFreshSessionPersists(t *testing.T) {
store := newTestStore(t)
s, history, err := newRunSessionWithStore(store, Options{Model: "m", ProviderName: "p"})
if err != nil {
t.Fatalf("newRunSessionWithStore: %v", err)
}
if history != nil {
t.Fatalf("fresh session history = %v, want nil", history)
}
s.agentCtx.Messages = append(s.agentCtx.Messages,
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hi")}},
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("yo")}},
)
if err := s.persist(); err != nil {
t.Fatalf("persist: %v", err)
}
if s.persisted != 2 {
t.Errorf("persisted = %d, want 2", s.persisted)
}
_, msgs, err := store.Load(s.header.ID)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("persisted messages = %d, want 2", len(msgs))
}
// A second persist with no new messages is a no-op.
before := s.curLeaf
if err := s.persist(); err != nil {
t.Fatalf("persist (no-op): %v", err)
}
if s.curLeaf != before {
t.Errorf("curLeaf changed on no-op persist: %q -> %q", before, s.curLeaf)
}
}
// TestPersistAfterCompaction reproduces the crash where an automatic compaction
// shrinks agentCtx.Messages below the persisted cursor: an incremental
// Messages[persisted:] would panic with a slice-bounds error. persist() must
// instead re-save the flattened context and reset the cursor to the new length.
func TestPersistAfterCompaction(t *testing.T) {
store := newTestStore(t)
s, _, err := newRunSessionWithStore(store, Options{Model: "m", ProviderName: "p"})
if err != nil {
t.Fatalf("newRunSessionWithStore: %v", err)
}
// Persist a few turns so the cursor advances past what compaction will keep.
for i := 0; i < 4; i++ {
s.agentCtx.Messages = append(s.agentCtx.Messages,
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("q")}},
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("a")}},
)
}
if err := s.persist(); err != nil {
t.Fatalf("persist: %v", err)
}
if s.persisted != 8 {
t.Fatalf("persisted = %d, want 8 before compaction", s.persisted)
}
// Simulate the run loop compacting: Messages is rewritten to a shorter
// summary + tail (here just a 2-message tail), and the loop signalled it via
// compactionMsg (which sets s.compacted).
s.agentCtx.Messages = agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("recent q")}},
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("recent a")}},
}
s.compacted = true
if err := s.persist(); err != nil {
t.Fatalf("persist after compaction: %v", err)
}
if s.compacted {
t.Error("compacted flag should be cleared after persist")
}
if s.persisted != 2 {
t.Errorf("persisted = %d, want 2 (the compacted length)", s.persisted)
}
_, msgs, err := store.Load(s.header.ID)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("persisted messages = %d, want 2 (flattened compacted context)", len(msgs))
}
}
+206
View File
@@ -0,0 +1,206 @@
// This file implements slash-commands and their autocomplete popup for the
// full-screen TUI (US-008, FR-15). It is the TUI counterpart to the REPL's
// slash handling (internal/cli/repl/repl.go): both front-ends consult the SAME
// shared registry assembled by internal/cli/prompts.BuildSlashRegistry (#383),
// so /model, /help, user-declared templates (~/.pigo/{commands,prompts}),
// config/CLI prompt templates, plugin commands and ~/.agents/skills /skill-name
// commands are identical across the two surfaces.
//
// tui deliberately imports prompts/runtime/cli (the shared lower layers), never
// repl: prompts sits below both front-ends, so there is no import cycle.
//
// The autocomplete popup (slashMenu) activates while the input buffer is a
// "/name" being typed (a leading "/" with no whitespace yet). It filters the
// registry by the typed prefix, is navigated with the arrow keys, completed with
// Tab, and run with Enter — the model intercepts those keys before delegating to
// the textarea (see model.handleKey).
package tui
import (
"fmt"
"os"
"strings"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/prompts"
"github.com/smallnest/pigo/internal/runtime"
)
// maxMenuRows caps how many candidate rows the popup shows at once; a longer
// filtered list scrolls a window around the selection so the overlay stays a few
// lines tall regardless of how many commands are registered.
const maxMenuRows = 8
// newSlashRegistry assembles the shared slash-command registry for the TUI the
// same way the REPL does: built-ins seeded from runtime, the live-state /model
// and /help commands bound to live (so a /model switch mutates the very config
// the run loop reads), user/plugin/skill/template commands from disk. A load
// error is non-fatal — BuildSlashRegistry still returns a registry with the
// built-ins, so the TUI stays usable and the failure is surfaced on stderr.
func newSlashRegistry(opts Options, live *cli.LiveConfig) *runtime.SlashRegistry {
reg, err := prompts.BuildSlashRegistry(live, opts.Skills, opts.Plugins, prompts.PromptTemplateSources{
Settings: opts.ConfigPrompts,
CLI: opts.CliPrompts,
Disable: opts.NoPromptTemplates,
})
if err != nil {
fmt.Fprintf(os.Stderr, "pigo: slash-commands: %v\n", err)
}
return reg
}
// slashMenu is the autocomplete popup state. It holds the candidates matching
// the current "/prefix" and the highlighted row; it is inactive (rendered as
// nothing) whenever the buffer is not a slash-command being typed or no command
// matches the prefix.
type slashMenu struct {
theme Theme
active bool
filtered []runtime.SlashCommand
selected int
}
// newSlashMenu builds an inactive menu bound to the theme used for its rows.
func newSlashMenu(theme Theme) slashMenu { return slashMenu{theme: theme} }
// slashToken reports whether buffer is a slash-command name still being typed
// and returns the text after the leading "/". It is true only for a leading "/"
// with no whitespace yet: once the user types a space the name is complete and
// the buffer has moved on to arguments, so name-completion stops.
func slashToken(buffer string) (token string, ok bool) {
trimmed := strings.TrimLeft(buffer, " \t")
if !strings.HasPrefix(trimmed, "/") {
return "", false
}
rest := trimmed[1:]
if strings.ContainsAny(rest, " \t\n") {
return "", false
}
return rest, true
}
// refresh recomputes the menu from the current buffer and registry. It activates
// only when the buffer is a "/name" prefix that matches at least one command;
// otherwise it deactivates and clears its candidates. The selection is clamped
// so it stays in range as the filtered set shrinks.
func (mn *slashMenu) refresh(buffer string, reg *runtime.SlashRegistry) {
token, ok := slashToken(buffer)
if !ok || reg == nil {
mn.close()
return
}
var out []runtime.SlashCommand
for _, c := range reg.List() {
if strings.HasPrefix(c.Name, token) {
out = append(out, c)
}
}
mn.filtered = out
mn.active = len(out) > 0
if mn.selected >= len(out) || mn.selected < 0 {
mn.selected = 0
}
}
// rows reports how many terminal rows the popup occupies when rendered, so the
// model can reserve that space above the input line during relayout. It is zero
// while inactive and otherwise the visible window height (min of the candidate
// count and maxMenuRows).
func (mn slashMenu) rows() int {
if !mn.active || len(mn.filtered) == 0 {
return 0
}
if len(mn.filtered) > maxMenuRows {
return maxMenuRows
}
return len(mn.filtered)
}
// close deactivates the menu and drops its candidates.
func (mn *slashMenu) close() {
mn.active = false
mn.filtered = nil
mn.selected = 0
}
// moveUp / moveDown cycle the highlighted candidate, wrapping at the ends so
// arrow navigation is continuous.
func (mn *slashMenu) moveUp() {
if len(mn.filtered) == 0 {
return
}
mn.selected--
if mn.selected < 0 {
mn.selected = len(mn.filtered) - 1
}
}
func (mn *slashMenu) moveDown() {
if len(mn.filtered) == 0 {
return
}
mn.selected++
if mn.selected >= len(mn.filtered) {
mn.selected = 0
}
}
// current returns the highlighted candidate, or ok=false when the menu is
// inactive / empty.
func (mn slashMenu) current() (runtime.SlashCommand, bool) {
if !mn.active || mn.selected < 0 || mn.selected >= len(mn.filtered) {
return runtime.SlashCommand{}, false
}
return mn.filtered[mn.selected], true
}
// view renders the popup as a block of up to maxMenuRows lines, the highlighted
// row marked with a "" caret and accented. Each row is "/name description",
// truncated to the width so it never wraps. Returns "" when inactive so the
// model omits the overlay entirely (and its row) while idle.
func (mn slashMenu) view(width int) string {
if !mn.active || len(mn.filtered) == 0 {
return ""
}
start, end := mn.window()
rowWidth := width - 2 // reserve the caret / indent column
if rowWidth < 1 {
rowWidth = width
}
var b strings.Builder
for i := start; i < end; i++ {
c := mn.filtered[i]
line := "/" + c.Name
if c.Description != "" {
line += " " + c.Description
}
line = TruncateToWidth(line, rowWidth)
if i == mn.selected {
b.WriteString(mn.theme.Accent.Render(" " + line))
} else {
b.WriteString(mn.theme.System.Render(" " + line))
}
if i < end-1 {
b.WriteByte('\n')
}
}
return b.String()
}
// window returns the [start,end) slice of filtered candidates to display,
// scrolled to keep the selection visible when the list is taller than
// maxMenuRows.
func (mn slashMenu) window() (int, int) {
n := len(mn.filtered)
if n <= maxMenuRows {
return 0, n
}
start := mn.selected - maxMenuRows + 1
if start < 0 {
start = 0
}
if start > n-maxMenuRows {
start = n - maxMenuRows
}
return start, start + maxMenuRows
}
+216
View File
@@ -0,0 +1,216 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/runtime"
)
// typeInto feeds each rune of s to the model as a key press, returning the
// evolved model. It mirrors how a terminal delivers typed characters (Code +
// Text), including the leading "/" of a slash-command.
func typeInto(t *testing.T, m tea.Model, s string) tea.Model {
t.Helper()
for _, r := range s {
m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
}
return m
}
// menuNames returns the "/name" of every candidate currently in the popup.
func menuNames(m Model) []string {
out := make([]string, len(m.menu.filtered))
for i, c := range m.menu.filtered {
out[i] = "/" + c.Name
}
return out
}
func containsAll(hay []string, needles ...string) bool {
set := make(map[string]bool, len(hay))
for _, h := range hay {
set[h] = true
}
for _, n := range needles {
if !set[n] {
return false
}
}
return true
}
// TestSlashMenuOpensOnSlash verifies that typing a bare "/" opens the popup with
// the built-in commands present (/model, /help among them).
func TestSlashMenuOpensOnSlash(t *testing.T) {
m := typeInto(t, NewModel(Options{}), "/").(Model)
if !m.menu.active {
t.Fatalf("menu should be active after typing '/'")
}
names := menuNames(m)
if !containsAll(names, "/model", "/help") {
t.Errorf("candidate set %v missing /model or /help", names)
}
}
// TestSlashMenuFiltersByPrefix verifies the popup narrows to the typed prefix:
// "/mo" keeps /model and /models but drops /help.
func TestSlashMenuFiltersByPrefix(t *testing.T) {
m := typeInto(t, NewModel(Options{}), "/mo").(Model)
if !m.menu.active {
t.Fatalf("menu should be active for '/mo'")
}
names := menuNames(m)
if !containsAll(names, "/model", "/models") {
t.Errorf("candidate set %v missing /model or /models", names)
}
for _, n := range names {
if !strings.HasPrefix(n, "/mo") {
t.Errorf("candidate %q does not match prefix /mo (set %v)", n, names)
}
}
}
// TestSlashMenuClosesOnSpace verifies name-completion stops once the buffer moves
// on to arguments (a space after the command name closes the popup).
func TestSlashMenuClosesOnSpace(t *testing.T) {
m := typeInto(t, NewModel(Options{}), "/model ").(Model)
if m.menu.active {
t.Errorf("menu should close once the command name is complete (buffer %q)", m.input.Value())
}
}
// TestSlashMenuNavigation verifies arrow keys move the highlighted candidate and
// wrap at the ends.
func TestSlashMenuNavigation(t *testing.T) {
m := typeInto(t, NewModel(Options{}), "/").(Model)
n := len(m.menu.filtered)
if n < 2 {
t.Fatalf("need at least two candidates to test navigation, got %d", n)
}
next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
if got := next.(Model).menu.selected; got != 1 {
t.Errorf("after Down, selected = %d, want 1", got)
}
// Up from index 0 wraps to the last candidate.
back, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
if got := back.(Model).menu.selected; got != n-1 {
t.Errorf("after Up from 0, selected = %d, want %d (wrap)", got, n-1)
}
}
// TestSlashTabCompletes verifies Tab fills the buffer with the highlighted
// command and closes the popup (ready for arguments).
func TestSlashTabCompletes(t *testing.T) {
m := typeInto(t, NewModel(Options{}), "/hel").(Model)
got, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyTab})
gm := got.(Model)
if gm.input.Value() != "/help " {
t.Errorf("after Tab, buffer = %q, want %q", gm.input.Value(), "/help ")
}
if gm.menu.active {
t.Errorf("menu should close after Tab completion")
}
}
// TestSlashHelpExecutesIntoTranscript verifies executing a built-in action
// command (/help) renders its output into the transcript as a system block —
// listing the available commands — without requiring a live provider.
func TestSlashHelpExecutesIntoTranscript(t *testing.T) {
m := typeInto(t, NewModel(Options{}), "/help").(Model)
got, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
gm := got.(Model)
// No run is started for an action command.
if cmd != nil {
if msg := cmd(); msg != nil {
if _, isQuit := msg.(tea.QuitMsg); isQuit {
t.Fatalf("/help should not quit")
}
}
}
if gm.running {
t.Errorf("/help is an action command; model should stay idle")
}
joined := strings.Join(blockTexts(gm.transcript), "\n")
if !strings.Contains(joined, "/help") || !strings.Contains(joined, "/model") {
t.Errorf("/help output should list commands (/help, /model); transcript:\n%s", joined)
}
if gm.input.Value() != "" {
t.Errorf("after executing /help, input = %q, want cleared", gm.input.Value())
}
}
// TestSlashUnknownCommandReported verifies an unknown "/name" surfaces the
// resolver error into the transcript rather than being sent to the agent.
func TestSlashUnknownCommandReported(t *testing.T) {
m := typeInto(t, NewModel(Options{}), "/definitelynotacommand").(Model)
// The popup filters to nothing, so it is inactive; Enter routes through submit.
got, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
gm := got.(Model)
if gm.running {
t.Errorf("an unknown command must not start a run")
}
joined := strings.Join(blockTexts(gm.transcript), "\n")
if !strings.Contains(joined, "unknown command") {
t.Errorf("expected an unknown-command notice in transcript, got:\n%s", joined)
}
}
// TestSlashMenuRendersAboveInput verifies the popup appears in the View while
// active, so the candidate list is visible above the input line.
func TestSlashMenuRendersAboveInput(t *testing.T) {
m := NewModel(Options{})
next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
m = typeInto(t, next, "/mo").(Model)
view := m.View()
if !strings.Contains(view.Content, "/model") {
t.Errorf("active popup should render /model in the view content")
}
}
// TestSlashPromptCommandStartsRun verifies a prompt (Expand) command's expanded
// text is fed to the run seam, not shown as a bare status. A stub startRunFn
// stands in for the live provider.
func TestSlashPromptCommandStartsRun(t *testing.T) {
m := NewModel(Options{})
// Inject a user prompt command directly into the registry.
m.slash.AddUser(runtime.SlashCommand{
Name: "greet",
Expand: func(args string) string { return "hello " + args },
})
var ran string
m.startRunFn = func(prompt string) (chan tea.Msg, tea.Cmd) {
ran = prompt
ch := make(chan tea.Msg, 1)
return ch, func() tea.Msg { return nil }
}
got, _ := m.runSlash("/greet world")
gm := got.(Model)
if ran != "hello world" {
t.Errorf("prompt command should start a run with expanded text; got %q", ran)
}
if !gm.running {
t.Errorf("model should be running after a prompt command")
}
}
// TestSlashExitQuits verifies that /exit and /quit typed in the TUI input box
// terminate the program (tea.Quit + quitting flag), mirroring the REPL loop
// which intercepts them before slash resolution.
func TestSlashExitQuits(t *testing.T) {
for _, cmd := range []string{"/exit", "/quit"} {
got, teaCmd := NewModel(Options{}).runSlash(cmd)
gm := got.(Model)
if !gm.quitting {
t.Errorf("%s: model should be marked quitting", cmd)
}
if teaCmd == nil {
t.Fatalf("%s: expected a tea.Quit command, got nil", cmd)
}
if _, isQuit := teaCmd().(tea.QuitMsg); !isQuit {
t.Errorf("%s: cmd should be tea.Quit", cmd)
}
}
}
+185
View File
@@ -0,0 +1,185 @@
package tui
import (
"fmt"
"math/rand"
"strings"
"time"
)
// This file implements the "working" spinner shown while an agent run is in
// flight, mirroring Claude Code's animated status line: a cycling asterisk
// glyph, a whimsical present-progressive verb ("Whirring…"), and a live stats
// readout — elapsed wall-clock time, an estimate of streamed output tokens, and
// the configured thinking effort. It renders on the row just above the input
// while running and disappears when the run ends.
// spinnerTickMsg advances the spinner animation. The model re-issues a tick
// after each frame while a run is in flight and lets the tick lapse once the run
// ends, so the animation stops without a running goroutine.
type spinnerTickMsg time.Time
// spinnerInterval is the frame cadence. ~120ms is brisk enough to read as motion
// without churning the render loop.
const spinnerInterval = 120 * time.Millisecond
// verbRerollFrames re-picks the verb roughly every this many frames (~5s) so a
// long run cycles through several verbs the way Claude Code does.
const verbRerollFrames = 40
// spinnerFrames is the asterisk animation cycled one glyph per tick. The glyphs
// grow from a dim dot to a full star and back, reading as a pulsing sparkle.
var spinnerFrames = []string{"·", "✢", "✳", "", "✺", "✻", "✽", "✻", "✺", "", "✳", "✢"}
// spinner is the animated working indicator. It is a plain value held by the
// Model: begin() arms it at run start, advance() steps the frame on each tick,
// addTokens() grows the streamed-token estimate, and view() renders the line.
type spinner struct {
theme Theme
running bool
frame int
verb string
start time.Time
chars int // runes streamed this run (the token estimate divides this)
thinking string // thinking-effort label, e.g. "medium"; "" hides that stat
pinned string // when set, overrides the random verb and stops re-rolling
}
// newSpinner builds an idle spinner bound to the theme.
func newSpinner(theme Theme) spinner {
return spinner{theme: theme}
}
// begin arms the spinner for a fresh run: it records the start time, picks the
// first verb, resets the frame and token estimate, and stores the thinking-effort
// label to show in the stats.
func (s *spinner) begin(now time.Time, thinking string) {
s.running = true
s.frame = 0
s.start = now
s.chars = 0
s.thinking = thinking
s.verb = randomVerb()
s.pinned = ""
}
// pin fixes the spinner label to a specific phrase (e.g. "Compacting
// conversation") and stops verb re-rolling until unpin, so a long-running phase
// reads as one steady message rather than cycling words.
func (s *spinner) pin(label string) { s.pinned = label }
// unpin restores the normal cycling verb after a pinned phase ends.
func (s *spinner) unpin() { s.pinned = "" }
// stop parks the spinner when a run ends so view() renders nothing.
func (s *spinner) stop() { s.running = false }
// advance steps the animation one frame and periodically re-rolls the verb so a
// long run does not sit on one word.
func (s *spinner) advance() {
s.frame++
if s.pinned == "" && s.frame%verbRerollFrames == 0 {
s.verb = randomVerb()
}
}
// addTokens folds a streamed text delta into the running output-token estimate.
// The count is approximate (≈4 chars per token) — enough for a live spinner
// readout, not billing.
func (s *spinner) addTokens(delta string) {
s.chars += len([]rune(delta))
}
// view renders the spinner line, e.g. "✻ Whirring… (1m 54s · ↓ 242 tokens ·
// medium effort)". It returns "" when not running or before a width is known.
// The glyph and verb take the accent color; the parenthetical stats are dim.
func (s spinner) view(width int) string {
if !s.running || width <= 0 {
return ""
}
glyph := spinnerFrames[s.frame%len(spinnerFrames)]
verb := s.verb
if s.pinned != "" {
verb = s.pinned
}
head := s.theme.Spinner.Render(glyph + " " + verb + "…")
var stats strings.Builder
fmt.Fprintf(&stats, "%s", formatElapsed(time.Since(s.start)))
if tokens := s.chars / 4; tokens > 0 {
fmt.Fprintf(&stats, " · ↓ %s tokens", humanizeInt(tokens))
}
if s.thinking != "" {
fmt.Fprintf(&stats, " · %s effort", s.thinking)
}
line := head + " " + s.theme.System.Render("("+stats.String()+")")
return TruncateToWidth(line, width)
}
// randomVerb picks one of the built-in present-progressive verbs.
func randomVerb() string {
return spinnerVerbs[rand.Intn(len(spinnerVerbs))]
}
// formatElapsed renders a duration compactly: "42s", "1m 54s", or "1h 2m".
func formatElapsed(d time.Duration) string {
if d < 0 {
d = 0
}
secs := int(d.Seconds())
if secs < 60 {
return fmt.Sprintf("%ds", secs)
}
mins := secs / 60
secs %= 60
if mins < 60 {
return fmt.Sprintf("%dm %ds", mins, secs)
}
hours := mins / 60
mins %= 60
return fmt.Sprintf("%dh %dm", hours, mins)
}
// spinnerVerbs is Claude Code's 185 built-in spinner verbs (present-progressive
// flavor words shown while working). Sourced from the community catalog at
// github.com/wynandw87/claude-code-spinner-verbs.
var spinnerVerbs = []string{
"Accomplishing", "Actioning", "Actualizing", "Architecting", "Baking",
"Beaming", "Beboppin'", "Befuddling", "Billowing", "Blanching",
"Bloviating", "Boogieing", "Boondoggling", "Booping", "Bootstrapping",
"Brewing", "Burrowing", "Calculating", "Canoodling", "Caramelizing",
"Cascading", "Catapulting", "Cerebrating", "Channeling", "Channelling",
"Choreographing", "Churning", "Clauding", "Coalescing", "Cogitating",
"Combobulating", "Composing", "Computing", "Concocting", "Considering",
"Contemplating", "Cooking", "Crafting", "Creating", "Crunching",
"Crystallizing", "Cultivating", "Deciphering", "Deliberating", "Determining",
"Dilly-dallying", "Discombobulating", "Doing", "Doodling", "Drizzling",
"Ebbing", "Effecting", "Elucidating", "Embellishing", "Enchanting",
"Envisioning", "Evaporating", "Fermenting", "Fiddle-faddling", "Finagling",
"Flambeing", "Flibbertigibbeting", "Flowing", "Flummoxing", "Fluttering",
"Forging", "Forming", "Frolicking", "Frosting", "Gallivanting",
"Galloping", "Garnishing", "Generating", "Germinating", "Gitifying",
"Grooving", "Gusting", "Harmonizing", "Hashing", "Hatching",
"Herding", "Honking", "Hullaballooing", "Hyperspacing", "Ideating",
"Imagining", "Improvising", "Incubating", "Inferring", "Infusing",
"Ionizing", "Jitterbugging", "Julienning", "Kneading", "Leavening",
"Levitating", "Lollygagging", "Manifesting", "Marinating", "Meandering",
"Metamorphosing", "Misting", "Moonwalking", "Moseying", "Mulling",
"Mustering", "Musing", "Nebulizing", "Nesting", "Newspapering",
"Noodling", "Nucleating", "Orbiting", "Orchestrating", "Osmosing",
"Perambulating", "Percolating", "Perusing", "Philosophising", "Photosynthesizing",
"Pollinating", "Pondering", "Pontificating", "Pouncing", "Precipitating",
"Prestidigitating", "Processing", "Proofing", "Propagating", "Puttering",
"Puzzling", "Quantumizing", "Razzle-dazzling", "Razzmatazzing", "Recombobulating",
"Reticulating", "Roosting", "Ruminating", "Sauteing", "Scampering",
"Schlepping", "Scurrying", "Seasoning", "Shenaniganing", "Shimmying",
"Simmering", "Skedaddling", "Sketching", "Slithering", "Smooshing",
"Sock-hopping", "Spelunking", "Spinning", "Sprouting", "Stewing",
"Sublimating", "Swirling", "Swooping", "Symbioting", "Synthesizing",
"Tempering", "Thinking", "Thundering", "Tinkering", "Tomfoolering",
"Topsy-turvying", "Transfiguring", "Transmuting", "Twisting", "Undulating",
"Unfurling", "Unravelling", "Vibing", "Waddling", "Wandering",
"Warping", "Whatchamacalliting", "Whirlpooling", "Whirring", "Whisking",
"Wibbling", "Working", "Wrangling", "Zesting", "Zigzagging",
}
+123
View File
@@ -0,0 +1,123 @@
package tui
import (
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
)
// TestFormatElapsed checks the compact duration formatting across the second,
// minute, and hour ranges.
func TestFormatElapsed(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{5 * time.Second, "5s"},
{59 * time.Second, "59s"},
{114 * time.Second, "1m 54s"},
{60 * time.Minute, "1h 0m"},
{62 * time.Minute, "1h 2m"},
{-3 * time.Second, "0s"},
}
for _, c := range cases {
if got := formatElapsed(c.d); got != c.want {
t.Errorf("formatElapsed(%s) = %q, want %q", c.d, got, c.want)
}
}
}
// TestSpinnerViewStats verifies a running spinner renders its verb with an
// ellipsis and the elapsed/token/effort stats, and that a stopped spinner
// renders nothing.
func TestSpinnerViewStats(t *testing.T) {
s := newSpinner(DefaultTheme())
s.begin(time.Now().Add(-114*time.Second), "medium")
s.chars = 968 // 968/4 = 242 estimated tokens
view := stripANSI(s.view(120))
if !strings.Contains(view, s.verb+"…") {
t.Errorf("view %q should contain the verb with an ellipsis", view)
}
for _, want := range []string{"1m 54s", "↓ 242 tokens", "medium effort"} {
if !strings.Contains(view, want) {
t.Errorf("view %q missing stat %q", view, want)
}
}
s.stop()
if got := s.view(120); got != "" {
t.Errorf("stopped spinner should render nothing, got %q", got)
}
}
// TestSpinnerPinOverridesVerb verifies a pinned label replaces the random verb
// and survives verb re-rolls, and that unpin restores the cycling verb.
func TestSpinnerPinOverridesVerb(t *testing.T) {
s := newSpinner(DefaultTheme())
s.begin(time.Now(), "")
s.pin("Compacting conversation")
// Advance well past the re-roll interval: a pinned label must not change.
for i := 0; i < verbRerollFrames*2; i++ {
s.advance()
}
view := stripANSI(s.view(120))
if !strings.Contains(view, "Compacting conversation…") {
t.Errorf("pinned spinner view %q should show the pinned label", view)
}
s.unpin()
if got := stripANSI(s.view(120)); strings.Contains(got, "Compacting conversation") {
t.Errorf("after unpin, view %q should not show the pinned label", got)
}
}
// tokens stream and the effort stat is hidden with no thinking level.
func TestSpinnerViewOmitsEmptyStats(t *testing.T) {
s := newSpinner(DefaultTheme())
s.begin(time.Now(), "")
view := stripANSI(s.view(120))
if strings.Contains(view, "tokens") {
t.Errorf("view %q should not show a token stat before any deltas", view)
}
if strings.Contains(view, "effort") {
t.Errorf("view %q should not show an effort stat with no thinking level", view)
}
}
// TestSpinnerAdvanceRerollsVerb verifies the animation frame advances and the
// verb is re-picked on the reroll cadence.
func TestSpinnerAdvanceRerollsVerb(t *testing.T) {
s := newSpinner(DefaultTheme())
s.begin(time.Now(), "")
if s.frame != 0 {
t.Fatalf("fresh spinner frame = %d, want 0", s.frame)
}
for i := 0; i < verbRerollFrames; i++ {
s.advance()
}
if s.frame != verbRerollFrames {
t.Errorf("frame after %d advances = %d", verbRerollFrames, s.frame)
}
}
// TestModelRunningShowsSpinnerRow verifies that while a run is in flight the
// spinner occupies its own row above the input and the shell still fills exactly
// the terminal height (relayout shrinks the transcript by the spinner row).
func TestModelRunningShowsSpinnerRow(t *testing.T) {
m := apply(t, NewModel(Options{Model: "test-model"}), tea.WindowSizeMsg{Width: 60, Height: 10})
m.running = true
m.spinner.begin(time.Now(), "medium")
m.relayout()
view := m.renderContent()
if got := strings.Count(view, "\n"); got != 9 {
t.Errorf("running newline count = %d, want 9 (10 rows)", got)
}
if !strings.Contains(stripANSI(view), m.spinner.verb+"…") {
t.Errorf("running view should contain the spinner verb, got:\n%s", stripANSI(view))
}
}
+219
View File
@@ -0,0 +1,219 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/agentcore"
)
// typeCommand feeds "/name" into the model and presses Enter, mirroring how a
// user runs a slash command from the composer (the popup is open at Enter, so it
// routes through submitSlashSelected, exactly like the REPL path).
func typeCommand(t *testing.T, m Model, cmd string) Model {
t.Helper()
m = typeInto(t, m, cmd).(Model)
got, c := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if c != nil {
if msg := c(); msg != nil {
if _, isQuit := msg.(tea.QuitMsg); isQuit {
t.Fatalf("%s should not quit", cmd)
}
}
}
return got.(Model)
}
// TestStatusWithSessionRendersSections drives /status on a session-bound model
// and asserts every report section appears in the transcript, with the model
// staying idle (no run is started).
func TestStatusWithSessionRendersSections(t *testing.T) {
store := newTestStore(t)
s, _, err := newRunSessionWithStore(store, Options{
Model: "status-model",
ProviderName: "status-provider",
ThinkingLevel: agentcore.ThinkingLevel("low"),
})
if err != nil {
t.Fatalf("newRunSessionWithStore: %v", err)
}
// The /status environment section keys off the launch directory; keep it
// deterministic rather than depending on the test's cwd.
s.cwd = "/tmp/tui-status"
m := NewModel(Options{}).withSession(s, nil)
m = typeCommand(t, m, "/status")
if m.running {
t.Error("/status is an action command; model should stay idle")
}
if m.input.Value() != "" {
t.Errorf("after executing /status, input = %q, want cleared", m.input.Value())
}
joined := strings.Join(blockTexts(m.transcript), "\n")
for _, want := range []string{
"runtime config:",
"model: status-model",
"provider: status-provider",
"context:",
"project & environment:",
"cwd: /tmp/tui-status",
"credentials & connectivity:",
"telemetry:",
"no telemetry yet",
} {
if !strings.Contains(joined, want) {
t.Errorf("/status output missing %q; transcript:\n%s", want, joined)
}
}
}
// TestStatusWithoutSessionNotice verifies a session-less model reports the
// unavailable notice rather than panicking on nil collaborators, and still
// clears the input.
func TestStatusWithoutSessionNotice(t *testing.T) {
m := NewModel(Options{})
m = typeCommand(t, m, "/status")
if m.running {
t.Error("/status must not start a run on a session-less model")
}
if m.input.Value() != "" {
t.Errorf("after executing /status, input = %q, want cleared", m.input.Value())
}
joined := strings.Join(blockTexts(m.transcript), "\n")
if !strings.Contains(joined, "status unavailable: no active session") {
t.Errorf("expected an unavailable notice in transcript, got:\n%s", joined)
}
}
// TestSessionCommandRendersSummary drives /session on a session-bound model and
// asserts the summary lines (session id, message count, tokens, model/provider,
// compactions) match the REPL's /session format.
func TestSessionCommandRendersSummary(t *testing.T) {
store := newTestStore(t)
s, _, err := newRunSessionWithStore(store, Options{
Model: "session-model",
ProviderName: "session-provider",
})
if err != nil {
t.Fatalf("newRunSessionWithStore: %v", err)
}
// Simulate two user/assistant turns in the live context (unsaved messages are
// counted too, mirroring the REPL's in-memory source of truth).
s.agentCtx.Messages = agentcore.MessageList{
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("q1")}},
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("a1")}},
agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("q2")}},
agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewTextContent("a2")}},
}
m := NewModel(Options{}).withSession(s, nil)
m = typeCommand(t, m, "/session")
if m.running {
t.Error("/session is an action command; model should stay idle")
}
joined := strings.Join(blockTexts(m.transcript), "\n")
for _, want := range []string{
"session: " + s.header.ID,
"messages: 4",
"tokens (est):",
"model: session-model (provider: session-provider)",
"compactions: 0",
} {
if !strings.Contains(joined, want) {
t.Errorf("/session output missing %q; transcript:\n%s", want, joined)
}
}
}
// TestSessionWithoutSessionNotice verifies a session-less model reports the
// unavailable notice for /session.
func TestSessionWithoutSessionNotice(t *testing.T) {
m := NewModel(Options{})
m = typeCommand(t, m, "/session")
joined := strings.Join(blockTexts(m.transcript), "\n")
if !strings.Contains(joined, "session unavailable: no active session") {
t.Errorf("expected an unavailable notice in transcript, got:\n%s", joined)
}
}
// TestTelemetryFoldingFeedsStatus verifies a telemetryMsg from the bridge is
// folded into the session's telemetry holder, so /status renders the cumulative
// and last-run telemetry blocks instead of "no telemetry yet".
func TestTelemetryFoldingFeedsStatus(t *testing.T) {
store := newTestStore(t)
s, _, err := newRunSessionWithStore(store, Options{Model: "m", ProviderName: "p"})
if err != nil {
t.Fatalf("newRunSessionWithStore: %v", err)
}
if s.telemetry == nil {
t.Fatal("session telemetry holder should be initialized")
}
m := NewModel(Options{}).withSession(s, nil)
// A telemetryMsg is bridged while a run is pumping; set up that state so
// Update keeps the pump running (pumpNext) exactly as during a real run.
m.running = true
m.runCh = make(chan tea.Msg)
next, cmd := m.Update(telemetryMsg{ev: agentcore.TelemetryEvent{
Turns: 3,
TruncationCount: 1,
CompactionCount: 1,
ContextTokens: 1000,
ContextWindow: 200000,
ContextUtilization: 0.005,
}})
if cmd == nil {
t.Fatal("telemetryMsg should return a pump cmd while a run is in flight")
}
m = next.(Model)
// The event must be retained on the session's holder (not just the status
// bar), otherwise /status could not render the telemetry section.
if !s.telemetry.HasTelemetry() {
t.Fatal("telemetry event should be folded into the session holder")
}
if s.telemetry.CumulativeTurns() != 3 {
t.Errorf("CumulativeTurns = %d, want 3", s.telemetry.CumulativeTurns())
}
// The run has since ended, returning the model to idle so the user can
// issue /status (key presses are dropped while a run is in flight).
m.running = false
m.runCh = nil
m = typeCommand(t, m, "/status")
joined := strings.Join(blockTexts(m.transcript), "\n")
for _, want := range []string{
"telemetry:",
"since session start:",
"turns: 3",
"truncations: 1",
"last run:",
} {
if !strings.Contains(joined, want) {
t.Errorf("/status output missing %q after telemetry fold; transcript:\n%s", want, joined)
}
}
if strings.Contains(joined, "no telemetry yet") {
t.Error("/status should render real telemetry after a fold, not 'no telemetry yet'")
}
}
// TestStatusNotInterceptedForStatusFoo verifies "/statusfoo" is NOT intercepted
// as /status (mirroring the REPL's guard), so it resolves as an unknown command
// rather than rendering the status report.
func TestStatusNotInterceptedForStatusFoo(t *testing.T) {
m := NewModel(Options{})
m = typeCommand(t, m, "/statusfoo")
joined := strings.Join(blockTexts(m.transcript), "\n")
if strings.Contains(joined, "runtime config:") {
t.Errorf("/statusfoo must not run the status command; transcript:\n%s", joined)
}
}
+397
View File
@@ -0,0 +1,397 @@
package tui
import (
"fmt"
"os"
"strings"
"charm.land/lipgloss/v2"
"github.com/smallnest/pigo/internal/cli/ui"
)
// statusBar renders the persistent bottom line described in the SPEC (US-003,
// Section 5.1): model name, thinking level, cwd (with $HOME abbreviated to ~),
// git branch + dirty/ahead markers, context-usage %, and the current task text.
// It holds no styling of its own beyond the Theme's StatusBar style; all width
// fitting is done against ui.Width so CJK/emoji count as two columns.
//
// The component is a plain value: Update-side code copies it into the Model,
// mutates the exported-to-package snapshot fields via the setters, and calls
// Render(width) from View. It never performs I/O — the git probe lives in
// gitinfo.go and feeds it via SetGit.
type statusBar struct {
theme Theme
// Static-ish config sourced from Options.
model string
thinking string
// cwd is the launch directory with $HOME already abbreviated to "~".
cwd string
// git is the latest probe result; rendered only when git.ok is true.
git gitInfoMsg
// contextPct is the latest context-window utilization in percent [0,100],
// derived from telemetryMsg (ContextUtilization * 100). -1 means unknown, so
// the segment is hidden until the first telemetry arrives.
contextPct int
// tokens is the most recently observed context-token count (ContextTokens),
// shown alongside the percentage. 0 means unknown/not yet reported.
tokens int
// task is the current activity text (e.g. the running tool or turn state).
task string
}
// newStatusBar builds a status bar from the theme, resolved Options, and the
// launch directory. contextPct starts at -1 (unknown) so the token segment stays
// hidden until telemetry arrives.
func newStatusBar(theme Theme, opts Options, cwd string) statusBar {
return statusBar{
theme: theme,
model: opts.Model,
thinking: string(opts.ThinkingLevel),
cwd: abbreviateHome(cwd),
contextPct: -1,
}
}
// SetGit stores the latest git probe result.
func (s *statusBar) SetGit(g gitInfoMsg) { s.git = g }
// SetModel updates the displayed model name after a /model switch.
func (s *statusBar) SetModel(model string) { s.model = model }
// SetThinking updates the displayed reasoning-effort level after a /think switch.
func (s *statusBar) SetThinking(level string) { s.thinking = level }
// SetTelemetry updates the context-usage percentage from a telemetry event.
// A zero/unknown window (ContextWindow == 0) leaves the segment hidden.
func (s *statusBar) SetTelemetry(ev telemetryEventView) {
if ev.window <= 0 {
s.contextPct = -1
s.tokens = 0
return
}
pct := int(ev.util*100 + 0.5)
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
s.contextPct = pct
if ev.tokens > 0 {
s.tokens = ev.tokens
}
}
// SetTask records the current activity text shown at the far right / high
// priority slot of the bar.
func (s *statusBar) SetTask(task string) { s.task = task }
// telemetryEventView is the minimal projection of agentcore.TelemetryEvent the
// status bar needs, so the caller (model.go) adapts the event rather than this
// file depending on agentcore directly for a two-field read.
type telemetryEventView struct {
util float64
window int
tokens int
}
// appName is the badge shown at the far left of the bar.
const appName = "pigo"
// Glyphs prefixing each segment plus the powerline separator, matching the
// decorated Claude-Code-plugin look. The segment icons are common Unicode; the
// separator (sepArrow) is a powerline glyph in the private-use area that Nerd
// Fonts and most modern terminal fonts render. Each measures one display column
// and ui.Width accounts for it during truncation.
const (
glyphGit = "⎇" // git branch
glyphDirty = "●" // uncommitted changes
glyphAhead = "⇡" // commits ahead of upstream
glyphModel = "✱" // model name
glyphThink = "✽" // thinking level
glyphCwd = "▸" // working directory
glyphCtx = "◔" // context-window usage
glyphTask = "⏵" // current activity
sepArrow = "" // filled right arrow — used at a background transition
)
// Powerline palette (ANSI 256-color cube, so it renders without true-color).
// Every segment is its own colored block. The arrow between two segments is
// drawn in the LEFT block's background color so it reads as that item's color
// spilling into the next; a closing arrow caps the final block back to the bar.
const (
sbBarBg = "236" // bar background behind the trailing pad
sbAppFg = "233" // app badge text (dark, on light gray)
sbAppBg = "252" // app badge block (light gray)
sbGitFg = "231" // git text
sbGitBg = "65" // git block (muted green)
sbModelFg = "231" // model text
sbModelBg = "97" // model block (muted purple)
sbThinkFg = "231" // thinking text
sbThinkBg = "60" // thinking block (slate)
sbCwdFg = "231" // cwd text
sbCwdBg = "67" // cwd block (steel blue)
sbCtxFg = "236" // context text (dark, on amber)
sbCtxBg = "179" // context block (amber/gold)
sbTaskFg = "231" // task text
sbTaskBg = "131" // task block (muted terracotta)
)
// segment is one labelled field of the bar together with its colors and
// truncation priority. bg == "" means the segment sits on the bar background;
// a non-empty bg gives it a filled powerline block. Higher priority survives
// longer when the terminal is too narrow.
type segment struct {
text string
fg string
bg string // "" => bar background
priority int // larger = kept longer under truncation
}
// Priority order (SPEC: task > model/app > token > git > cwd). Higher is more
// important and dropped/truncated last.
const (
prioCwd = 0
prioGit = 1
prioToken = 2
prioModel = 3
prioApp = 3 // the app badge rides at the model tier
prioTask = 4
)
// Render lays the bar out to exactly the configured width as a colored powerline
// ribbon. Each segment is a filled block joined to the next by an arrow drawn in
// the left block's background color, and the tail is padded with the bar
// background so the whole row is filled. When the ribbon would exceed the width
// it drops whole segments from lowest to highest priority; if even the single
// highest-priority segment still overflows it hard-truncates that segment's
// text. The rendered row's display width (ui.Width, which ignores ANSI) is
// always exactly width for width > 0; a non-positive width yields the empty
// string.
func (s statusBar) Render(width int) string {
if width <= 0 {
return ""
}
segs := s.segments()
for len(segs) > 0 {
ribbon, w := renderRibbon(segs)
if w <= width {
return ribbon + barPad(width-w)
}
if len(segs) == 1 {
break
}
segs = dropLowest(segs)
}
// Even a single highest-priority segment overflows: hard-truncate its text
// onto the bar background (no separators, so width stays bounded).
txt := TruncateToWidth(s.highestText(), width)
base := lipgloss.NewStyle().Foreground(lipgloss.Color(sbAppFg)).Background(lipgloss.Color(sbBarBg))
return base.Render(txt) + barPad(width-ui.Width(txt))
}
// renderRibbon builds the styled powerline string for segs and returns it with
// its visible width (excluding ANSI). The left edge starts at the first
// segment's background; a closing arrow caps any trailing block back to the bar.
func renderRibbon(segs []segment) (string, int) {
resolve := func(bg string) string {
if bg == "" {
return sbBarBg
}
return bg
}
var b strings.Builder
vis := 0
for i, seg := range segs {
curBg := resolve(seg.bg)
if i > 0 {
// The separator arrow is filled with the LEFT block's background, so
// it matches the item it flows out of, sitting on the next block's bg.
leftBg := resolve(segs[i-1].bg)
b.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(leftBg)).
Background(lipgloss.Color(curBg)).
Render(sepArrow))
vis += ui.Width(sepArrow)
}
content := " " + seg.text + " "
b.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(seg.fg)).
Background(lipgloss.Color(curBg)).
Render(content))
vis += ui.Width(content)
}
// Cap a trailing colored block with an arrow back to the bar background.
if lastBg := resolve(segs[len(segs)-1].bg); lastBg != sbBarBg {
b.WriteString(lipgloss.NewStyle().
Foreground(lipgloss.Color(lastBg)).
Background(lipgloss.Color(sbBarBg)).
Render(sepArrow))
vis += ui.Width(sepArrow)
}
return b.String(), vis
}
// barPad returns n spaces painted with the bar background so the row fills the
// full terminal width. n <= 0 yields the empty string.
func barPad(n int) string {
if n <= 0 {
return ""
}
return lipgloss.NewStyle().
Background(lipgloss.Color(sbBarBg)).
Render(strings.Repeat(" ", n))
}
// segments builds the ordered list of visible segments. Order in the slice is
// the left-to-right display order; priority governs truncation, not position.
func (s statusBar) segments() []segment {
var segs []segment
// App badge leads the bar as a filled block.
segs = append(segs, segment{text: appName, fg: sbAppFg, bg: sbAppBg, priority: prioApp})
if s.git.ok {
segs = append(segs, segment{text: s.gitText(), fg: sbGitFg, bg: sbGitBg, priority: prioGit})
}
if s.model != "" {
segs = append(segs, segment{text: glyphModel + " " + s.model, fg: sbModelFg, bg: sbModelBg, priority: prioModel})
}
if s.thinking != "" {
// Thinking rides with the model priority — it is cheap and contextual.
segs = append(segs, segment{text: glyphThink + " " + s.thinking, fg: sbThinkFg, bg: sbThinkBg, priority: prioModel})
}
if s.cwd != "" {
segs = append(segs, segment{text: glyphCwd + " " + s.cwd, fg: sbCwdFg, bg: sbCwdBg, priority: prioCwd})
}
if s.contextPct >= 0 {
// The context readout is the highlighted amber block on the right.
segs = append(segs, segment{text: s.ctxText(), fg: sbCtxFg, bg: sbCtxBg, priority: prioToken})
}
if s.task != "" {
segs = append(segs, segment{text: glyphTask + " " + s.task, fg: sbTaskFg, bg: sbTaskBg, priority: prioTask})
}
return segs
}
// gitText formats the git segment, e.g. "⎇ master ●3 ⇡4": branch, then "●N" for
// N dirty entries and "⇡N" for N commits ahead, each shown only when non-zero.
func (s statusBar) gitText() string {
var b strings.Builder
b.WriteString(glyphGit + " " + s.git.branch)
if s.git.dirty > 0 {
fmt.Fprintf(&b, " %s%d", glyphDirty, s.git.dirty)
}
if s.git.ahead > 0 {
fmt.Fprintf(&b, " %s%d", glyphAhead, s.git.ahead)
}
return b.String()
}
// ctxText formats the context segment, e.g. "◔ 90,866 (46%)" when the token
// count is known, or "◔ 46%" before the first token count arrives.
func (s statusBar) ctxText() string {
if s.tokens > 0 {
return fmt.Sprintf("%s %s (%d%%)", glyphCtx, humanizeInt(s.tokens), s.contextPct)
}
return fmt.Sprintf("%s %d%%", glyphCtx, s.contextPct)
}
// humanizeInt renders n with thousands separators, e.g. 90866 → "90,866".
func humanizeInt(n int) string {
s := fmt.Sprintf("%d", n)
neg := strings.HasPrefix(s, "-")
if neg {
s = s[1:]
}
var b strings.Builder
for i, r := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(r)
}
if neg {
return "-" + b.String()
}
return b.String()
}
// highestText returns the text of the highest-priority segment, used as the last
// thing standing when the terminal cannot even fit one full segment.
func (s statusBar) highestText() string {
segs := s.segments()
if len(segs) == 0 {
return ""
}
best := segs[0]
for _, seg := range segs[1:] {
if seg.priority > best.priority {
best = seg
}
}
return best.text
}
// dropLowest removes one occurrence of the lowest-priority segment, preserving
// display order among the rest. It returns the shortened slice.
func dropLowest(segs []segment) []segment {
if len(segs) == 0 {
return segs
}
lowIdx := 0
for i, seg := range segs {
if seg.priority < segs[lowIdx].priority {
lowIdx = i
}
}
out := make([]segment, 0, len(segs)-1)
out = append(out, segs[:lowIdx]...)
out = append(out, segs[lowIdx+1:]...)
return out
}
// abbreviateHome replaces a leading $HOME in path with "~" so the status bar
// stays compact. It leaves paths outside $HOME untouched and never fails.
func abbreviateHome(path string) string {
home := homeDir()
if home == "" || path == "" {
return path
}
if path == home {
return "~"
}
if strings.HasPrefix(path, home+"/") {
return "~" + strings.TrimPrefix(path, home)
}
return path
}
// homeDir returns the user's home directory, or "" when it cannot be
// determined. Kept as a tiny wrapper so abbreviateHome stays testable.
func homeDir() string {
h, err := os.UserHomeDir()
if err != nil {
return ""
}
return h
}
+164
View File
@@ -0,0 +1,164 @@
package tui
import (
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli/ui"
)
// newTestStatusBar builds a status bar with a known cwd (already ~-abbreviated
// by the caller's intent) so tests do not depend on the real $HOME.
func newTestStatusBar() statusBar {
opts := Options{Model: "claude-opus", ThinkingLevel: agentcore.ThinkingHigh}
s := newStatusBar(DefaultTheme(), opts, "/tmp/project")
s.cwd = "~/project"
return s
}
func TestStatusBarRendersAllFields(t *testing.T) {
s := newTestStatusBar()
s.SetGit(gitInfoMsg{branch: "master", dirty: 3, ahead: 4, ok: true})
s.SetTelemetry(telemetryEventView{util: 0.42, window: 200000})
s.SetTask("running: Read")
const width = 200
out := s.Render(width)
for _, want := range []string{
"pigo", // app badge
"claude-opus", // model
"high", // thinking level
"~/project", // cwd
"master", // git branch
glyphDirty + "3", // dirty marker
glyphAhead + "4", // ahead marker
"42%", // context usage
"running: Read", // task
} {
if !strings.Contains(out, want) {
t.Errorf("render missing %q; got %q", want, out)
}
}
if w := ui.Width(out); w > width {
t.Errorf("render width %d exceeds terminal width %d", w, width)
}
}
func TestStatusBarHidesGitWhenNotRepo(t *testing.T) {
s := newTestStatusBar()
s.SetGit(gitInfoMsg{ok: false})
out := s.Render(120)
if strings.Contains(out, "master") || strings.Contains(out, "*") || strings.Contains(out, "+") {
t.Errorf("git segment should be hidden when ok=false; got %q", out)
}
}
func TestStatusBarHidesContextWhenUnknown(t *testing.T) {
s := newTestStatusBar()
// No telemetry set (window 0) → context segment hidden.
s.SetTelemetry(telemetryEventView{util: 0.5, window: 0})
out := s.Render(120)
if strings.Contains(out, glyphCtx) {
t.Errorf("context segment should be hidden when window unknown; got %q", out)
}
}
func TestStatusBarTruncationKeepsPriorityFields(t *testing.T) {
s := newTestStatusBar()
s.SetGit(gitInfoMsg{branch: "master", dirty: 3, ahead: 4, ok: true})
s.SetTelemetry(telemetryEventView{util: 0.42, window: 200000})
s.SetTask("TASK")
// Narrow width: only the highest-priority fields (task > model > token)
// should survive; cwd and git should drop first.
const width = 24
out := s.Render(width)
if w := ui.Width(out); w > width {
t.Fatalf("truncated render width %d exceeds %d: %q", w, width, out)
}
if !strings.Contains(out, "TASK") {
t.Errorf("highest-priority task field dropped under truncation: %q", out)
}
// cwd (lowest priority) must be gone before task.
if strings.Contains(out, "~/project") {
t.Errorf("lowest-priority cwd should drop first under truncation: %q", out)
}
}
func TestStatusBarVeryNarrowNeverOverflows(t *testing.T) {
s := newTestStatusBar()
s.SetTask("a-fairly-long-task-description-that-cannot-fit")
for _, width := range []int{1, 2, 3, 5, 8} {
out := s.Render(width)
if w := ui.Width(out); w > width {
t.Errorf("width %d: render width %d overflows: %q", width, w, out)
}
}
}
func TestStatusBarZeroWidthEmpty(t *testing.T) {
s := newTestStatusBar()
if out := s.Render(0); out != "" {
t.Errorf("zero width should render empty, got %q", out)
}
}
// TestStatusBarContextTokenCount checks the context segment shows a
// comma-grouped token count with the percentage once telemetry reports tokens.
func TestStatusBarContextTokenCount(t *testing.T) {
s := newTestStatusBar()
s.SetTelemetry(telemetryEventView{util: 0.46, window: 200000, tokens: 90866})
out := s.Render(200)
for _, want := range []string{glyphCtx, "90,866", "46%"} {
if !strings.Contains(out, want) {
t.Errorf("render missing %q; got %q", want, out)
}
}
}
func TestHumanizeInt(t *testing.T) {
cases := map[int]string{0: "0", 90866: "90,866", 1000: "1,000", 999: "999", 1234567: "1,234,567"}
for in, want := range cases {
if got := humanizeInt(in); got != want {
t.Errorf("humanizeInt(%d) = %q, want %q", in, got, want)
}
}
}
func TestAbbreviateHome(t *testing.T) {
home := homeDir()
if home == "" {
t.Skip("no home dir available")
}
if got := abbreviateHome(home); got != "~" {
t.Errorf("abbreviateHome(home) = %q, want ~", got)
}
if got := abbreviateHome(home + "/foo/bar"); got != "~/foo/bar" {
t.Errorf("abbreviateHome(home/foo/bar) = %q, want ~/foo/bar", got)
}
if got := abbreviateHome("/etc/passwd"); got != "/etc/passwd" {
t.Errorf("abbreviateHome(/etc/passwd) = %q, want unchanged", got)
}
}
// TestStatusBarGitTextFormatting checks the "*N +N" markers appear only when
// non-zero.
func TestStatusBarGitTextFormatting(t *testing.T) {
s := newTestStatusBar()
s.SetGit(gitInfoMsg{branch: "main", ok: true})
out := s.Render(120)
if strings.Contains(out, "*") || strings.Contains(out, "+") {
t.Errorf("clean tree should show no *N/+N markers: %q", out)
}
if !strings.Contains(out, "main") {
t.Errorf("branch name missing: %q", out)
}
}
+317
View File
@@ -0,0 +1,317 @@
package tui
import (
"fmt"
"strings"
"time"
)
// This file renders the multi-line sub-agent status panel (SPEC 4.4, US-006): a
// block shown just above the working spinner while one or more sub-agents
// dispatched by the `task` tool are running. Each active sub-agent contributes
// exactly one status line of the form:
//
// ⏺ {desc} · {activity} ({elapsed} · ↓{tokens})
//
// The panel is also interactive: while the input box is empty, ↑/↓ move a
// selection cursor over the rows and Enter expands the selected row to show that
// sub-agent's accumulated text output inline (below its status line), Esc
// collapses. The panel is a pure function of the model's ordered active-subagent
// set plus its selection state; it is re-rendered every spinner tick so the
// elapsed clock stays live without a dedicated timer. When there are no active
// sub-agents it renders nothing (zero lines, zero height), leaving the existing
// single-run layout untouched.
// maxExpandedLines caps how many wrapped output lines an expanded row shows. The
// output can grow without bound, so only the most recent lines are kept visible;
// older content scrolls off the top of the inline pane.
const maxExpandedLines = 12
// subagentRow is one live sub-agent's status, keyed by the parent task tool-call
// id. start is recorded when the row is added so elapsed can be computed at
// render time; activity/tokens are refreshed by subagentProgressMsg; output
// accumulates the sub-agent's forwarded text (toolUpdate deltas + final result)
// for the inline expanded view.
type subagentRow struct {
id string
desc string
activity string
tokens int
start time.Time
output string
}
// subagentPanel is the ordered set of live sub-agents. order preserves insertion
// order (so rows render stably, oldest first) while byID gives O(1) lookup for
// progress updates and removal. selecting reports whether a row is cursored;
// selected is that row's index into order (meaningful only while selecting is
// true); expanded reports whether the selected row shows its output inline. The
// zero value is a valid empty, unselected panel — selecting defaults false so the
// selected int's zero value never spuriously marks row 0.
type subagentPanel struct {
order []string
byID map[string]*subagentRow
selecting bool
selected int
expanded bool
}
// add records a newly dispatched sub-agent (a toolStartMsg with name=="task").
// It is idempotent on the id: a duplicate start refreshes the description and
// resets the start clock rather than adding a second row.
func (p *subagentPanel) add(id, desc string, now time.Time) {
if p.byID == nil {
p.byID = make(map[string]*subagentRow)
}
if row, ok := p.byID[id]; ok {
row.desc = desc
row.start = now
return
}
p.byID[id] = &subagentRow{id: id, desc: desc, start: now}
p.order = append(p.order, id)
}
// update folds a progress event into the row for id, refreshing its activity and
// token estimate. A progress for an unknown id (late/out-of-order, arriving
// before or without a start) adds the row so no update is lost; now seeds its
// start clock in that case.
func (p *subagentPanel) update(id, desc, activity string, tokens int, now time.Time) {
if p.byID == nil {
p.byID = make(map[string]*subagentRow)
}
row, ok := p.byID[id]
if !ok {
row = &subagentRow{id: id, desc: desc, start: now}
p.byID[id] = row
p.order = append(p.order, id)
}
if activity != "" {
row.activity = activity
}
if desc != "" {
row.desc = desc
}
row.tokens = tokens
}
// appendOutput accumulates a forwarded text delta into the row for id, so the
// expanded view can show the sub-agent's running output. Deltas for an unknown id
// are ignored (the row's start/end brackets its output; nothing to attach to).
func (p *subagentPanel) appendOutput(id, delta string) {
if delta == "" {
return
}
if row, ok := p.byID[id]; ok {
row.output += delta
}
}
// remove drops the row for id (the task's toolEndMsg). It is a no-op when id is
// absent, so an end without a matching start — or a duplicate end — is safe.
// The selection is clamped to the shrunken order so the cursor never dangles past
// the end; removing the last row clears the selection entirely.
func (p *subagentPanel) remove(id string) {
if _, ok := p.byID[id]; !ok {
return
}
delete(p.byID, id)
for i, v := range p.order {
if v == id {
p.order = append(p.order[:i], p.order[i+1:]...)
break
}
}
if len(p.order) == 0 {
p.clearSelection()
return
}
if p.selected >= len(p.order) {
p.selected = len(p.order) - 1
}
}
// active reports the number of live sub-agents (status rows the panel would
// render), ignoring any extra rows an expanded row contributes.
func (p *subagentPanel) active() int { return len(p.order) }
// hasSelection reports whether a row is currently cursored.
func (p *subagentPanel) hasSelection() bool {
return p.selecting && p.selected >= 0 && p.selected < len(p.order)
}
// clearSelection drops the cursor and collapses any expansion.
func (p *subagentPanel) clearSelection() {
p.selecting = false
p.selected = 0
p.expanded = false
}
// selectUp moves the cursor to the previous row. With no current selection the
// first press lands on the last (bottom-most) row; moving up collapses any open
// expansion so it re-anchors to the newly selected row.
func (p *subagentPanel) selectUp() {
if len(p.order) == 0 {
return
}
if !p.selecting {
p.selecting = true
p.selected = len(p.order) - 1
} else if p.selected > 0 {
p.selected--
}
p.expanded = false
}
// selectDown moves the cursor to the next row. With no current selection the
// first press lands on the first (top-most) row; moving down collapses any open
// expansion so it re-anchors to the newly selected row.
func (p *subagentPanel) selectDown() {
if len(p.order) == 0 {
return
}
if !p.selecting {
p.selecting = true
p.selected = 0
} else if p.selected < len(p.order)-1 {
p.selected++
}
p.expanded = false
}
// toggleExpand flips the expanded state of the selected row. It is a no-op when
// nothing is selected.
func (p *subagentPanel) toggleExpand() {
if p.hasSelection() {
p.expanded = !p.expanded
}
}
// expandedID returns the id of the currently expanded row, or "" when no row is
// expanded. It lets the model relayout only when a streamed delta lands on the
// row whose inline output pane is on screen.
func (p *subagentPanel) expandedID() string {
if p.expanded && p.hasSelection() {
return p.order[p.selected]
}
return ""
}
// lineCount reports how many terminal rows the panel occupies at the given width:
// one status line per active sub-agent, plus the wrapped output lines when the
// selected row is expanded. relayout uses this to reserve exactly the right
// height so the transcript never overlaps the panel.
func (p subagentPanel) lineCount(width int) int {
if len(p.order) == 0 || width <= 0 {
return 0
}
n := len(p.order)
if p.expanded && p.hasSelection() {
if row := p.byID[p.order[p.selected]]; row != nil {
n += len(p.expandedLines(row, width))
}
}
return n
}
// view renders the panel to a string, one status line per active sub-agent in
// insertion order, each truncated to width display columns. The selected row is
// marked with a leading cursor and, when expanded, its accumulated output is
// rendered on the following (indented, wrapped) lines. It returns "" when there
// are no active sub-agents or width is non-positive, so an empty panel
// contributes zero rows and zero height. now is the reference time elapsed is
// measured from (the spinner tick's time) so the clock advances each frame.
func (p subagentPanel) view(theme Theme, width int, now time.Time) string {
if len(p.order) == 0 || width <= 0 {
return ""
}
lines := make([]string, 0, len(p.order)+1)
for i, id := range p.order {
row := p.byID[id]
if row == nil {
continue
}
cursored := p.selecting && i == p.selected
lines = append(lines, TruncateToWidth(row.render(theme, now, cursored), width))
if cursored && p.expanded {
for _, out := range p.expandedLines(row, width) {
lines = append(lines, theme.System.Render(out))
}
}
}
return strings.Join(lines, "\n")
}
// expandedLines builds the wrapped, indented, tail-capped output lines shown
// under an expanded row. An empty output yields a single placeholder line so the
// pane is never blank. Lines are already truncated to width (as plain text); the
// caller styles them.
func (p subagentPanel) expandedLines(row *subagentRow, width int) []string {
const indent = " "
out := strings.TrimRight(row.output, "\n")
if out == "" {
return []string{indent + "(no output yet)"}
}
var wrapped []string
for _, para := range strings.Split(out, "\n") {
for _, seg := range wrapToWidth(para, width-len(indent)) {
wrapped = append(wrapped, indent+seg)
}
}
if len(wrapped) > maxExpandedLines {
wrapped = wrapped[len(wrapped)-maxExpandedLines:]
}
return wrapped
}
// wrapToWidth breaks s into segments no wider than width display columns, cutting
// on the column boundary (there is no word-aware wrapping here — sub-agent output
// is arbitrary text/code). An empty line yields one empty segment so blank lines
// in the output are preserved.
func wrapToWidth(s string, width int) []string {
if width <= 0 {
return []string{s}
}
if s == "" {
return []string{""}
}
var segs []string
for s != "" {
seg := TruncateToWidth(s, width)
if seg == "" { // guard against no forward progress on odd-width runes
segs = append(segs, s)
break
}
segs = append(segs, seg)
s = s[len(seg):]
}
return segs
}
// render builds one status line for a row: "{cursor}⏺ {desc} · {activity}
// ({elapsed} · ↓{tokens})". A blank description is omitted (the line leads with
// the glyph and activity); a zero token estimate drops the "↓" stat. When
// selected, the line leads with a " " cursor and the head takes the accent color
// to stand out; otherwise the glyph + head take the spinner color and the
// parenthetical stats are dim, mirroring the spinner line.
func (r subagentRow) render(theme Theme, now time.Time, selected bool) string {
var head strings.Builder
head.WriteString("⏺")
if r.desc != "" {
fmt.Fprintf(&head, " %s ·", r.desc)
}
fmt.Fprintf(&head, " %s", r.activity)
stats := formatElapsed(now.Sub(r.start))
if r.tokens > 0 {
stats += " · ↓" + humanizeInt(r.tokens)
}
headStyle := theme.Spinner
cursor := " "
if selected {
headStyle = theme.Accent
cursor = theme.Accent.Render(" ")
}
return cursor + headStyle.Render(head.String()) + " " + theme.System.Render("("+stats+")")
}
+292
View File
@@ -0,0 +1,292 @@
package tui
import (
"strings"
"testing"
"time"
"github.com/smallnest/pigo/internal/cli/ui"
)
// TestSubagentPanelLifecycle exercises the ordered add/update/remove set: rows
// keep insertion order, a progress refreshes activity/tokens, a progress for an
// unknown id adds a row (late/out-of-order safe), and remove drops exactly one
// row (a no-op for an absent id).
func TestSubagentPanelLifecycle(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
p.add("b", "task B", now)
if got := p.active(); got != 2 {
t.Fatalf("active after two adds = %d, want 2", got)
}
if p.order[0] != "a" || p.order[1] != "b" {
t.Errorf("order = %v, want [a b]", p.order)
}
p.update("a", "task A", "Editing", 120, now)
if row := p.byID["a"]; row.activity != "Editing" || row.tokens != 120 {
t.Errorf("row a after update = %+v, want activity=Editing tokens=120", row)
}
// A progress for an id that never started adds the row (SPEC 5.4).
p.update("c", "task C", "Reading", 0, now)
if got := p.active(); got != 3 {
t.Fatalf("active after late progress = %d, want 3", got)
}
if p.order[2] != "c" {
t.Errorf("order = %v, want c appended last", p.order)
}
// Removing a middle row preserves the order of the rest.
p.remove("a")
if got := p.active(); got != 2 {
t.Fatalf("active after remove = %d, want 2", got)
}
if p.order[0] != "b" || p.order[1] != "c" {
t.Errorf("order after remove(a) = %v, want [b c]", p.order)
}
// Removing an absent id is a no-op.
p.remove("zzz")
if got := p.active(); got != 2 {
t.Errorf("active after remove(absent) = %d, want 2", got)
}
}
// TestSubagentPanelEmptyView verifies an empty panel renders nothing — zero
// lines, zero height — so the single-run layout is untouched.
func TestSubagentPanelEmptyView(t *testing.T) {
var p subagentPanel
if got := p.view(DefaultTheme(), 80, time.Now()); got != "" {
t.Errorf("empty panel view = %q, want empty", got)
}
// A non-empty panel with a non-positive width also renders nothing.
p.add("a", "task", time.Now())
if got := p.view(DefaultTheme(), 0, time.Now()); got != "" {
t.Errorf("zero-width view = %q, want empty", got)
}
}
// TestSubagentPanelViewLines verifies one line per active sub-agent, each
// carrying the description, activity, and token stat.
func TestSubagentPanelViewLines(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "build parser", now)
p.update("a", "build parser", "Editing", 1200, now)
p.add("b", "run tests", now)
p.update("b", "run tests", "Running bash", 0, now)
view := p.view(DefaultTheme(), 200, now.Add(65*time.Second))
lines := strings.Split(view, "\n")
if len(lines) != 2 {
t.Fatalf("view has %d lines, want 2: %q", len(lines), view)
}
if !strings.Contains(lines[0], "build parser") || !strings.Contains(lines[0], "Editing") {
t.Errorf("line[0] = %q, want desc + activity", lines[0])
}
if !strings.Contains(lines[0], "1m 5s") {
t.Errorf("line[0] = %q, want elapsed 1m 5s", lines[0])
}
if !strings.Contains(lines[0], "1,200") {
t.Errorf("line[0] = %q, want token stat 1,200", lines[0])
}
if !strings.Contains(lines[1], "run tests") || !strings.Contains(lines[1], "Running bash") {
t.Errorf("line[1] = %q, want desc + activity", lines[1])
}
// A zero token estimate omits the ↓ stat.
if strings.Contains(lines[1], "↓") {
t.Errorf("line[1] = %q, should omit ↓ for zero tokens", lines[1])
}
}
// TestSubagentPanelViewTruncation verifies each rendered line is clipped to the
// given terminal width (display columns), never exceeding it.
func TestSubagentPanelViewTruncation(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", strings.Repeat("very long description ", 10), now)
p.update("a", "", "Searching", 42, now)
const width = 30
view := p.view(DefaultTheme(), width, now)
for _, line := range strings.Split(view, "\n") {
if w := ui.Width(line); w > width {
t.Errorf("line width = %d, want <= %d: %q", w, width, line)
}
}
}
// TestSubagentPanelViewBlankDescription verifies a row with no description still
// renders (leading with the activity) rather than producing a dangling "·".
func TestSubagentPanelViewBlankDescription(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "", now)
p.update("a", "", "Thinking", 0, now)
line := p.view(DefaultTheme(), 100, now)
if !strings.Contains(line, "Thinking") {
t.Errorf("view = %q, want activity", line)
}
if strings.Contains(line, " · Thinking") {
t.Errorf("view = %q, blank desc should not leave a leading ' · '", line)
}
}
// TestSubagentPanelSelection verifies the cursor navigation: a fresh panel has no
// selection, the first ↓ lands on the top row and the first ↑ on the bottom row,
// movement clamps at both ends, and clearSelection resets to no-cursor state.
func TestSubagentPanelSelection(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
p.add("b", "task B", now)
p.add("c", "task C", now)
if p.hasSelection() {
t.Fatal("fresh panel should have no selection")
}
// First ↓ selects the top row; further ↓ advance and clamp at the bottom.
p.selectDown()
if !p.hasSelection() || p.selected != 0 {
t.Fatalf("after first down: hasSelection=%v selected=%d, want true/0", p.hasSelection(), p.selected)
}
p.selectDown()
p.selectDown()
p.selectDown() // clamp
if p.selected != 2 {
t.Errorf("selected after clamp down = %d, want 2", p.selected)
}
// ↑ retreats and clamps at the top.
p.selectUp()
if p.selected != 1 {
t.Errorf("selected after up = %d, want 1", p.selected)
}
p.selectUp()
p.selectUp() // clamp
if p.selected != 0 {
t.Errorf("selected after clamp up = %d, want 0", p.selected)
}
p.clearSelection()
if p.hasSelection() {
t.Error("clearSelection should drop the cursor")
}
// From no selection, the first ↑ lands on the bottom row.
p.selectUp()
if !p.hasSelection() || p.selected != 2 {
t.Errorf("first up from none: selected=%d, want 2", p.selected)
}
}
// TestSubagentPanelExpandView verifies that expanding a selected row appends its
// accumulated output below the status line, that the cursor marker is present,
// that lineCount matches the rendered height, and that collapsing removes the
// extra lines.
func TestSubagentPanelExpandView(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
p.add("b", "task B", now)
p.appendOutput("b", "hello from B\nsecond line")
p.selectDown() // selects "a"
p.selectDown() // selects "b"
if id := p.expandedID(); id != "" {
t.Errorf("expandedID before toggle = %q, want empty", id)
}
p.toggleExpand()
if id := p.expandedID(); id != "b" {
t.Errorf("expandedID after toggle = %q, want b", id)
}
const width = 80
view := p.view(DefaultTheme(), width, now)
lines := strings.Split(view, "\n")
if got := p.lineCount(width); got != len(lines) {
t.Errorf("lineCount = %d, rendered %d lines", got, len(lines))
}
// Two status rows + two output lines.
if len(lines) != 4 {
t.Fatalf("expanded view has %d lines, want 4: %q", len(lines), view)
}
if !strings.Contains(view, "") {
t.Errorf("expanded view missing selection cursor: %q", view)
}
if !strings.Contains(view, "hello from B") || !strings.Contains(view, "second line") {
t.Errorf("expanded view missing output: %q", view)
}
// Collapsing reclaims the output lines.
p.toggleExpand()
if got := p.lineCount(width); got != 2 {
t.Errorf("lineCount after collapse = %d, want 2", got)
}
}
// TestSubagentPanelExpandTruncates verifies the inline output is wrapped and
// tail-capped: every rendered line fits the width and no more than
// maxExpandedLines output lines are shown.
func TestSubagentPanelExpandTruncates(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
// Many short lines exceed the cap; one very long line must wrap.
p.appendOutput("a", strings.Repeat("x\n", 40))
p.appendOutput("a", strings.Repeat("y", 500))
p.selectDown()
p.toggleExpand()
const width = 40
view := p.view(DefaultTheme(), width, now)
lines := strings.Split(view, "\n")
for _, line := range lines {
if w := ui.Width(line); w > width {
t.Errorf("line width = %d, want <= %d: %q", w, width, line)
}
}
// 1 status row + at most maxExpandedLines output lines.
if len(lines) > 1+maxExpandedLines {
t.Errorf("expanded view has %d lines, want <= %d", len(lines), 1+maxExpandedLines)
}
}
// TestSubagentPanelRemoveClampsSelection verifies that removing rows keeps the
// selection valid: removing the selected bottom row clamps the cursor to the new
// last row, and removing the final row clears the selection entirely.
func TestSubagentPanelRemoveClampsSelection(t *testing.T) {
now := time.Now()
var p subagentPanel
p.add("a", "task A", now)
p.add("b", "task B", now)
p.selectDown()
p.selectDown() // selects "b" (index 1)
p.remove("b")
if !p.hasSelection() || p.selected != 0 {
t.Errorf("after remove(b): hasSelection=%v selected=%d, want true/0", p.hasSelection(), p.selected)
}
p.remove("a")
if p.hasSelection() {
t.Error("removing the last row should clear the selection")
}
if got := p.lineCount(80); got != 0 {
t.Errorf("empty panel lineCount = %d, want 0", got)
}
}
// TestSubagentPanelAppendOutputUnknown verifies deltas for an unknown id are
// dropped (no phantom row, no panic).
func TestSubagentPanelAppendOutputUnknown(t *testing.T) {
var p subagentPanel
p.appendOutput("ghost", "data") // must not panic or add a row
if p.active() != 0 {
t.Errorf("active after appendOutput to unknown id = %d, want 0", p.active())
}
}
+195
View File
@@ -0,0 +1,195 @@
package tui
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/smallnest/pigo/internal/cli/ui"
)
// Theme bundles the lipgloss styles for every visual element the TUI paints so
// the transcript, tool cards and status bar share one palette instead of each
// call site hand-rolling colors (see tasks/spec-tui-agent.md Sections 2.2, 5.1).
// The reference palette is: success green, error/warn red & yellow, file/accent
// blue, and gray for secondary chrome. Styles are plain value types, so a Theme
// is cheap to copy and safe to pass by value.
type Theme struct {
// User styles the human's turns in the transcript.
User lipgloss.Style
// Assistant styles the model's turns in the transcript.
Assistant lipgloss.Style
// System styles system / meta notices (secondary gray).
System lipgloss.Style
// ToolHeader styles the title line of a tool invocation card.
ToolHeader lipgloss.Style
// ToolBody styles the body/output region of a tool card.
ToolBody lipgloss.Style
// StatusBar styles the persistent bottom status bar.
StatusBar lipgloss.Style
// Accent styles file names and other highlighted tokens (blue).
Accent lipgloss.Style
// Error styles failure messages (red).
Error lipgloss.Style
// Warn styles warnings (yellow).
Warn lipgloss.Style
// Success styles successful outcomes (green).
Success lipgloss.Style
// ScrollThumb styles the transcript scrollbar thumb (medium gray block).
ScrollThumb lipgloss.Style
// ScrollTrack styles the transcript scrollbar track (dim shaded column).
ScrollTrack lipgloss.Style
// Spinner styles the animated "working" indicator glyph + verb (warm coral).
Spinner lipgloss.Style
}
// Palette color numbers use the ANSI 256-color cube so the theme renders
// consistently across terminals without depending on true-color support.
const (
colorSuccess = "42" // green
colorError = "196" // red
colorWarn = "214" // yellow/amber
colorAccent = "39" // blue (file names, highlights)
colorGray = "245" // secondary / muted text
colorScroll = "250" // scrollbar thumb (bright gray pill, clearly visible)
colorTrack = "240" // scrollbar groove (dim gray, visible but recessive)
colorUser = "15" // bright white
colorAssist = "252" // near-white
colorStatus = "62" // status bar background (violet)
colorSpinner = "173" // spinner glyph/verb (warm coral, matches Claude Code)
)
// DefaultTheme returns the built-in palette described in the SPEC: success
// green, error/warn red & yellow, file/accent blue, and gray for secondary
// chrome. It performs no I/O and never panics, so callers can construct it
// eagerly at startup.
func DefaultTheme() Theme {
return Theme{
User: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorUser)).
Bold(true),
Assistant: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorAssist)),
System: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorGray)).
Italic(true),
ToolHeader: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorAccent)).
Bold(true),
ToolBody: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorGray)),
StatusBar: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorUser)).
Background(lipgloss.Color(colorStatus)),
Accent: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorAccent)),
Error: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorError)).
Bold(true),
Warn: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorWarn)),
Success: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorSuccess)),
ScrollThumb: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorScroll)),
ScrollTrack: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorTrack)),
Spinner: lipgloss.NewStyle().
Foreground(lipgloss.Color(colorSpinner)).
Bold(true),
}
}
// ellipsis is the single rune appended to truncated strings. It is itself a
// single display column, so it is cheap to reserve room for.
const ellipsis = "…"
// WrapToWidth wraps s to at most width display columns per line, measuring width
// by terminal cells (CJK and emoji count as two) via ui.Width rather than byte
// length. It never splits inside a multi-byte rune or a double-width character:
// a rune that would overflow the current line starts a new line instead. Any
// existing newlines in s are preserved as hard breaks. A non-positive width is
// treated as "no wrapping" and s is returned unchanged.
func WrapToWidth(s string, width int) string {
if width <= 0 {
return s
}
var out strings.Builder
lines := strings.Split(s, "\n")
for li, line := range lines {
if li > 0 {
out.WriteByte('\n')
}
wrapLine(&out, line, width)
}
return out.String()
}
// wrapLine wraps a single newline-free line into out, breaking on rune
// boundaries so no double-width rune is ever cut in half.
func wrapLine(out *strings.Builder, line string, width int) {
cur := 0 // display width accumulated on the current output line
first := true
for _, r := range line {
rw := ui.Width(string(r))
if !first && cur+rw > width {
out.WriteByte('\n')
cur = 0
}
out.WriteRune(r)
cur += rw
first = false
}
}
// TruncateToWidth returns s clipped to at most width display columns, appending
// an ellipsis "…" when it removes content. Width is measured in terminal cells
// (CJK and emoji count as two) via ui.Width, and truncation happens on rune
// boundaries so a double-width character is never sliced. The returned string's
// display width is guaranteed to be <= width. A non-positive width yields the
// empty string.
func TruncateToWidth(s string, width int) string {
if width <= 0 {
return ""
}
if ui.Width(s) <= width {
return s
}
// Reserve room for the ellipsis. If width is too small to even hold the
// ellipsis plus one column, fall back to fitting bare runes into width.
budget := width - ui.Width(ellipsis)
if budget <= 0 {
return fitRunes(s, width)
}
var b strings.Builder
used := 0
for _, r := range s {
rw := ui.Width(string(r))
if used+rw > budget {
break
}
b.WriteRune(r)
used += rw
}
b.WriteString(ellipsis)
return b.String()
}
// fitRunes packs as many leading runes of s as fit within width columns without
// any ellipsis, breaking on rune boundaries.
func fitRunes(s string, width int) string {
var b strings.Builder
used := 0
for _, r := range s {
rw := ui.Width(string(r))
if used+rw > width {
break
}
b.WriteRune(r)
used += rw
}
return b.String()
}
+116
View File
@@ -0,0 +1,116 @@
package tui
import (
"strings"
"testing"
"github.com/smallnest/pigo/internal/cli/ui"
)
func TestDefaultThemeRenders(t *testing.T) {
th := DefaultTheme()
cases := map[string]string{
"user": th.User.Render("hi"),
"assistant": th.Assistant.Render("ok"),
"system": th.System.Render("note"),
"toolHeader": th.ToolHeader.Render("Bash"),
"toolBody": th.ToolBody.Render("output"),
"statusBar": th.StatusBar.Render("status"),
"accent": th.Accent.Render("file.go"),
"error": th.Error.Render("boom"),
"warn": th.Warn.Render("careful"),
"success": th.Success.Render("done"),
}
for name, got := range cases {
if got == "" {
t.Errorf("style %s rendered empty output", name)
}
}
}
func TestWrapToWidthDisplayWidth(t *testing.T) {
// Mix double-width CJK, an emoji, and ASCII.
const input = "你好world世界🚀测试abc"
const width = 6
wrapped := WrapToWidth(input, width)
// Reassembling the wrapped lines (minus the inserted newlines) must equal
// the original: nothing is dropped or split inside a rune.
if got := strings.ReplaceAll(wrapped, "\n", ""); got != input {
t.Fatalf("wrap altered content: got %q want %q", got, input)
}
for _, line := range strings.Split(wrapped, "\n") {
if w := ui.Width(line); w > width {
t.Errorf("line %q has display width %d > %d", line, w, width)
}
// Guard against a mid-rune cut producing invalid UTF-8.
if !isValidBoundary(line) {
t.Errorf("line %q was cut inside a multibyte rune", line)
}
}
}
func TestWrapToWidthPreservesNewlines(t *testing.T) {
out := WrapToWidth("ab\ncd", 10)
if out != "ab\ncd" {
t.Fatalf("wrap collapsed existing newlines: got %q", out)
}
}
func TestWrapToWidthNonPositive(t *testing.T) {
const s = "你好world"
if got := WrapToWidth(s, 0); got != s {
t.Errorf("width<=0 should return input unchanged, got %q", got)
}
}
func TestTruncateToWidth(t *testing.T) {
tests := []struct {
name string
in string
width int
}{
{"cjk", "你好世界测试内容很长", 6},
{"emoji", "🚀🚀🚀🚀🚀🚀", 5},
{"mixed", "abc你好def世界🚀tail", 8},
{"ascii", "helloworld", 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := TruncateToWidth(tt.in, tt.width)
if w := ui.Width(got); w > tt.width {
t.Errorf("truncated %q to display width %d > %d (result %q)", tt.in, w, tt.width, got)
}
if !isValidBoundary(got) {
t.Errorf("truncation cut inside a multibyte rune: %q", got)
}
// It must actually be a truncation: contain the ellipsis when the
// input was wider than the budget.
if ui.Width(tt.in) > tt.width && !strings.Contains(got, ellipsis) {
t.Errorf("expected ellipsis in truncated result, got %q", got)
}
})
}
}
func TestTruncateToWidthNoTruncationNeeded(t *testing.T) {
const s = "你好"
if got := TruncateToWidth(s, 10); got != s {
t.Errorf("short string should be returned unchanged, got %q", got)
}
}
func TestTruncateToWidthNonPositive(t *testing.T) {
if got := TruncateToWidth("你好", 0); got != "" {
t.Errorf("width<=0 should return empty string, got %q", got)
}
}
// isValidBoundary reports whether s contains no invalid UTF-8, which would be
// the tell-tale of a cut inside a multibyte rune.
func isValidBoundary(s string) bool {
return strings.ToValidUTF8(s, "") == s
}
+195
View File
@@ -0,0 +1,195 @@
package tui
import (
"fmt"
"sort"
"strings"
"charm.land/lipgloss/v2"
"github.com/smallnest/pigo/internal/cli/ui"
)
// This file implements the rich tool-call card component (US-006, SPEC 3.2,
// FR-6/7/8). A toolCard is a bordered inline block in the transcript that shows
// a single tool invocation: a header with the tool name and a status icon
// (running / success / warn), the decoded call arguments, and the tool's
// response rendered as an indented tree. Cards are created on toolStartMsg,
// completed on toolEndMsg, and toggled between a capped and a full response view
// with Ctrl+O (see model.go). All width math goes through ui.Width /
// WrapToWidth / TruncateToWidth so CJK and emoji (two columns) never split.
// cardState is the lifecycle of a tool card: running while the tool executes,
// then success or warn once it finishes (warn covers a reported tool error).
type cardState int
const (
cardRunning cardState = iota
cardSuccess
cardWarn
)
// respNode is one line of a tool's response, with depth giving the tree indent
// level (each level is rendered as two leading spaces).
type respNode struct {
text string
depth int
}
// toolCard is a single tool invocation rendered as a bordered card. input holds
// the decoded call arguments (nil when the args were not a JSON object);
// response is the parsed result tree, populated on completion. expanded flips
// the response between a capped preview and the full tree.
type toolCard struct {
id string
name string
input map[string]any
response []respNode
state cardState
expanded bool
}
// collapsedResponseLines is how many response lines a card shows before it is
// expanded; past this the preview is truncated and a Ctrl+O hint is appended.
const collapsedResponseLines = 5
// statusIcon returns the header status glyph for the card's state. Running is a
// spinner-like ellipsis, success a check, warn a bang.
func (c toolCard) statusIcon() string {
switch c.state {
case cardSuccess:
return "✓"
case cardWarn:
return "!"
default:
return "…"
}
}
// styledIcon renders the status glyph with the state's theme color: gray while
// running, green on success, yellow/red on warn.
func (c toolCard) styledIcon(theme Theme) string {
icon := c.statusIcon()
switch c.state {
case cardSuccess:
return theme.Success.Render(icon)
case cardWarn:
return theme.Warn.Render(icon)
default:
return theme.System.Render(icon)
}
}
// render draws the card at the given content width: a rounded border wrapping a
// header (status icon + tool name), an "Input arguments" section listing the input map,
// and a "Response" section with the tree lines. When not expanded the response
// is capped to collapsedResponseLines with a "(Ctrl+O for more)" hint; when
// expanded every line is shown.
func (c toolCard) render(theme Theme, width int) string {
if width < 4 {
width = 4
}
// The rounded border consumes one column on each side; wrap everything to the
// inner width so nothing overflows the frame.
inner := width - 2
var lines []string
icon := c.styledIcon(theme)
nameBudget := inner - ui.Width(icon) - 1
if nameBudget < 1 {
nameBudget = 1
}
header := c.name
if arg := c.primaryArg(); arg != "" {
header = c.name + "(" + arg + ")"
}
header = TruncateToWidth(header, nameBudget)
lines = append(lines, icon+" "+theme.ToolHeader.Render(header))
if len(c.input) > 0 {
lines = append(lines, theme.ToolBody.Render("Input arguments"))
for _, k := range sortedKeys(c.input) {
kv := " " + k + ": " + fmt.Sprintf("%v", c.input[k])
lines = append(lines, theme.ToolBody.Render(WrapToWidth(kv, inner)))
}
}
if len(c.response) > 0 {
lines = append(lines, theme.ToolBody.Render("Response"))
resp := c.response
truncated := false
if !c.expanded && len(resp) > collapsedResponseLines {
resp = resp[:collapsedResponseLines]
truncated = true
}
for _, n := range resp {
indent := strings.Repeat(" ", n.depth)
lines = append(lines, theme.ToolBody.Render(WrapToWidth(indent+n.text, inner)))
}
if truncated {
lines = append(lines, theme.System.Render("(Ctrl+O for more)"))
}
}
border := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(colorGray)).
Width(inner)
return border.Render(strings.Join(lines, "\n"))
}
// primaryArg returns the most salient call argument to inline in the card header
// so the user can see what the tool is operating on at a glance (FR-6), e.g.
// Bash(cd /x && git add -A). It picks the command for bash and the file path for
// the file tools, otherwise the first argument in sorted-key order. Returns ""
// when the call carried no arguments.
func (c toolCard) primaryArg() string {
if len(c.input) == 0 {
return ""
}
var keyPrefs []string
switch strings.ToLower(c.name) {
case "bash":
keyPrefs = []string{"command"}
case "read", "write", "edit", "multiedit":
// The file tools emit "path"; accept "file_path" as a fallback for
// callers that use the Claude-style key.
keyPrefs = []string{"path", "file_path"}
}
for _, key := range keyPrefs {
if v, ok := c.input[key]; ok {
return fmt.Sprintf("%v", v)
}
}
keys := sortedKeys(c.input)
return fmt.Sprintf("%v", c.input[keys[0]])
}
// sortedKeys returns the map keys in a stable (sorted) order so the input
// section renders deterministically instead of in Go's random map order.
func sortedKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// parseToolResult splits a tool's textual result into response tree nodes,
// inferring depth from leading whitespace (every two leading spaces is one
// level). Trailing empty lines are trimmed so the card does not render blank
// tail rows.
func parseToolResult(result string) []respNode {
lines := strings.Split(result, "\n")
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
nodes := make([]respNode, 0, len(lines))
for _, ln := range lines {
leading := len(ln) - len(strings.TrimLeft(ln, " "))
nodes = append(nodes, respNode{text: ln[leading:], depth: leading / 2})
}
return nodes
}
+164
View File
@@ -0,0 +1,164 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
)
// ctrlKey builds a Ctrl+<letter> key press matching String()=="ctrl+<letter>".
func ctrlKey(r rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl}
}
// TestParseToolResult verifies depth inference from leading spaces and trailing
// blank-line trimming.
func TestParseToolResult(t *testing.T) {
nodes := parseToolResult("root\n child\n grandchild\n\n")
if len(nodes) != 3 {
t.Fatalf("node count = %d, want 3 (trailing blank trimmed)", len(nodes))
}
want := []respNode{
{text: "root", depth: 0},
{text: "child", depth: 1},
{text: "grandchild", depth: 2},
}
for i, w := range want {
if nodes[i] != w {
t.Errorf("node[%d] = %+v, want %+v", i, nodes[i], w)
}
}
}
// TestToolCardRender checks the header (name + status icon), the input section,
// and the response tree lines appear in the rendered card, and that the status
// icon reflects the state.
func TestToolCardRender(t *testing.T) {
theme := DefaultTheme()
cases := []struct {
name string
state cardState
icon string
}{
{"running", cardRunning, "…"},
{"success", cardSuccess, "✓"},
{"warn", cardWarn, "!"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
card := toolCard{
id: "1",
name: "read_file",
input: map[string]any{"path": "/tmp/x"},
response: parseToolResult("line one\n nested"),
state: tc.state,
}
out := card.render(theme, 60)
for _, want := range []string{"read_file", tc.icon, "Input arguments", "path: /tmp/x", "Response", "line one", "nested"} {
if !strings.Contains(out, want) {
t.Errorf("render missing %q\n%s", want, out)
}
}
})
}
}
// TestToolCardExpandTruncation verifies the collapsed card caps the response and
// shows the Ctrl+O hint, while the expanded card reveals every line.
func TestToolCardExpandTruncation(t *testing.T) {
theme := DefaultTheme()
var b strings.Builder
for i := 0; i < collapsedResponseLines+3; i++ {
b.WriteString("resp-line-")
b.WriteByte(byte('a' + i))
b.WriteByte('\n')
}
card := toolCard{name: "grep", response: parseToolResult(b.String()), state: cardSuccess}
collapsed := card.render(theme, 60)
if !strings.Contains(collapsed, "(Ctrl+O for more)") {
t.Errorf("collapsed card should show Ctrl+O hint\n%s", collapsed)
}
lastLine := "resp-line-" + string(byte('a'+collapsedResponseLines+2))
if strings.Contains(collapsed, lastLine) {
t.Errorf("collapsed card should not show %q\n%s", lastLine, collapsed)
}
card.expanded = true
expanded := card.render(theme, 60)
if strings.Contains(expanded, "(Ctrl+O for more)") {
t.Errorf("expanded card should not show Ctrl+O hint\n%s", expanded)
}
if !strings.Contains(expanded, lastLine) {
t.Errorf("expanded card should show %q\n%s", lastLine, expanded)
}
}
// TestModelToolCardFlow drives the model through a tool start/end and asserts the
// card is created, transitions running→success, and that a failed tool yields
// warn.
func TestModelToolCardFlow(t *testing.T) {
m := NewModel(Options{})
next, _ := m.Update(toolStartMsg{id: "t1", name: "read_file", input: map[string]any{"path": "a.go"}})
mm := next.(Model)
card, ok := mm.toolCards["t1"]
if !ok {
t.Fatalf("toolStartMsg should create a card")
}
if card.state != cardRunning {
t.Errorf("new card state = %v, want cardRunning", card.state)
}
next, _ = mm.Update(toolEndMsg{id: "t1", ok: true, result: "done\n detail"})
mm = next.(Model)
if mm.toolCards["t1"].state != cardSuccess {
t.Errorf("state after ok end = %v, want cardSuccess", mm.toolCards["t1"].state)
}
if len(mm.toolCards["t1"].response) != 2 {
t.Errorf("response nodes = %d, want 2", len(mm.toolCards["t1"].response))
}
// A failed tool flips the same card to warn.
next, _ = m.Update(toolStartMsg{id: "t2", name: "bash"})
mm = next.(Model)
next, _ = mm.Update(toolEndMsg{id: "t2", ok: false, result: "boom"})
mm = next.(Model)
if mm.toolCards["t2"].state != cardWarn {
t.Errorf("state after failed end = %v, want cardWarn", mm.toolCards["t2"].state)
}
}
// TestModelCtrlOTogglesExpanded verifies Ctrl+O flips the most-recent card's
// expanded flag so more response lines become visible.
func TestModelCtrlOTogglesExpanded(t *testing.T) {
m := NewModel(Options{})
next, _ := m.Update(tea.WindowSizeMsg{Width: 60, Height: 24})
mm := next.(Model)
var b strings.Builder
for i := 0; i < collapsedResponseLines+3; i++ {
b.WriteString("row")
b.WriteByte(byte('0' + i))
b.WriteByte('\n')
}
next, _ = mm.Update(toolStartMsg{id: "t1", name: "grep"})
mm = next.(Model)
next, _ = mm.Update(toolEndMsg{id: "t1", ok: true, result: b.String()})
mm = next.(Model)
if mm.lastToolCard.expanded {
t.Fatalf("card should start collapsed")
}
next, _ = mm.Update(ctrlKey('o'))
mm = next.(Model)
if !mm.lastToolCard.expanded {
t.Errorf("Ctrl+O should expand the most-recent card")
}
// Toggling again collapses it.
next, _ = mm.Update(ctrlKey('o'))
mm = next.(Model)
if mm.lastToolCard.expanded {
t.Errorf("second Ctrl+O should collapse the card")
}
}
+404
View File
@@ -0,0 +1,404 @@
package tui
import (
"strings"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/smallnest/pigo/internal/agentcore"
)
// This file implements the scrolling transcript region of the full-screen TUI
// (US-005, SPEC 5.1 transcript, FR-5/FR-10). The transcript owns a
// viewport.Model and an ordered list of rendered blocks (user / assistant /
// system turns). Streaming assistant text arrives as textDeltaMsg values that
// append to the current assistant block; turnEndMsg finalizes it. Content is
// re-flowed through the viewport with theme.WrapToWidth at the live width so CJK
// and emoji never split mid-rune. Tool cards are a later node (#389); this file
// leaves a clean seam (system lines) without building cards.
// blockRole distinguishes the three transcript block kinds so each renders with
// its own theme style.
type blockRole int
const (
roleUser blockRole = iota
roleAssistant
roleSystem
roleTool
// roleBanner is the startup logo + config splash. Its text is pre-rendered
// (already colored, already laid out) and emitted verbatim, so reflow neither
// wraps it nor overrides its colors with a role style.
roleBanner
)
// transcriptBlock is one rendered turn in the transcript. text is the raw
// (unstyled, unwrapped) message body; the role selects the theme style and any
// prefix applied at render time. For roleTool blocks text is unused and card
// points at the live tool card (#389); the pointer lets a later toolEndMsg /
// Ctrl+O mutate the card in place and have it re-render on the next reflow.
type transcriptBlock struct {
role blockRole
text string
card *toolCard
}
// transcript is the scrolling message log. It wraps a viewport.Model and keeps
// the source blocks so it can re-flow on width changes. activeAssistant indexes
// the assistant block currently receiving streaming deltas, or -1 when no turn
// is streaming.
type transcript struct {
vp viewport.Model
theme Theme
// totalWidth is the full width the transcript may occupy (terminal columns
// minus any chrome the model reserves). width (below) is the content width
// the blocks actually wrap to: it equals totalWidth when the content fits, or
// totalWidth-1 when it overflows and a scrollbar column must be held back.
// reflow recomputes width from totalWidth on every content change, so the bar
// column appears/disappears correctly even as a run streams in new lines.
totalWidth int
// width is the content width (terminal columns) the blocks wrap to. It is
// separate from the viewport's own width so reflow measurements stay stable
// even before the first size message.
width int
blocks []transcriptBlock
activeAssistant int
// follow is the stick-to-bottom intent: while true, every reflow snaps the
// viewport to the newest line so streamed output stays visible. It is set
// when the user submits a turn and cleared when they scroll up to read
// history (re-armed when they scroll back to the bottom). Tracking intent
// explicitly — rather than sampling viewport.AtBottom() inside reflow — keeps
// auto-scroll correct across height changes (setSize resizes the viewport
// before reflow runs, which would make an AtBottom() sample read false).
follow bool
}
// newTranscript builds an empty transcript with the given theme. The viewport
// starts zero-sized; the model drives setSize from the first tea.WindowSizeMsg.
func newTranscript(theme Theme) transcript {
vp := viewport.New()
return transcript{
vp: vp,
theme: theme,
activeAssistant: -1,
}
}
// setSize resizes the transcript's viewport and re-flows the blocks to the new
// width. A non-positive dimension is clamped to zero so the viewport never sees
// a negative extent. width is the total space available; reflow decides whether
// to spend one column on the scrollbar based on whether the content overflows.
func (t *transcript) setSize(width, height int) {
if width < 0 {
width = 0
}
if height < 0 {
height = 0
}
t.totalWidth = width
t.vp.SetHeight(height)
t.reflow()
}
// addUser appends a user turn and closes any streaming assistant block, then
// re-flows. Submitting a prompt is an explicit action where the user always
// wants to see their new turn and the response that follows, so it re-arms
// follow: the viewport snaps to the bottom even if the user had scrolled up
// (e.g. reading the startup banner) — otherwise the streamed reply would
// accumulate off-screen and look like nothing happened. Subsequent streaming
// deltas keep the bottom via follow, which the user can pause by scrolling up.
func (t *transcript) addUser(text string) {
t.blocks = append(t.blocks, transcriptBlock{role: roleUser, text: text})
t.activeAssistant = -1
t.follow = true
t.reflow()
}
// addSystem appends a system / meta notice (used for run lifecycle and other
// inline notes).
func (t *transcript) addSystem(text string) {
t.blocks = append(t.blocks, transcriptBlock{role: roleSystem, text: text})
t.reflow()
}
// addBanner appends a pre-rendered splash block (startup logo + config). It is
// emitted verbatim by renderBlock, so its colors and horizontal layout survive
// reflow untouched.
func (t *transcript) addBanner(text string) {
t.blocks = append(t.blocks, transcriptBlock{role: roleBanner, text: text})
t.reflow()
}
// addToolCard appends a rich tool-call card (#389) as an ordered block so it
// renders inline in the transcript. The card is held by pointer, so a later
// state change (toolEndMsg) or expand toggle (Ctrl+O) followed by reflow
// re-renders it in place.
func (t *transcript) addToolCard(c *toolCard) {
t.blocks = append(t.blocks, transcriptBlock{role: roleTool, card: c})
t.reflow()
}
// appendDelta grows the current assistant block by delta, creating the block on
// the first delta of a turn. The re-flow auto-sticks to the bottom when the user
// has not scrolled up.
func (t *transcript) appendDelta(delta string) {
if t.activeAssistant < 0 {
t.blocks = append(t.blocks, transcriptBlock{role: roleAssistant})
t.activeAssistant = len(t.blocks) - 1
}
t.blocks[t.activeAssistant].text += delta
t.reflow()
}
// finalizeTurn closes the streaming assistant block. When the final message
// carries text it becomes the block's authoritative body (covering turns that
// arrive without incremental deltas); otherwise the accumulated deltas stand.
func (t *transcript) finalizeTurn(msg agentcore.AssistantMessage) {
text := agentcore.ContentToText(msg.Content)
if t.activeAssistant >= 0 {
if text != "" {
t.blocks[t.activeAssistant].text = text
}
} else if text != "" {
t.blocks = append(t.blocks, transcriptBlock{role: roleAssistant, text: text})
}
t.activeAssistant = -1
t.reflow()
}
// update forwards a message (typically a key press or scroll) to the viewport so
// PgUp/PgDn/arrow scrolling works, then re-syncs the follow intent: scrolling up
// off the bottom pauses auto-scroll, and scrolling back to the bottom re-arms it.
func (t *transcript) update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
t.vp, cmd = t.vp.Update(msg)
t.follow = t.vp.AtBottom()
return cmd
}
// scrollToRow positions the viewport so the scrollbar thumb aligns with the
// given viewport row y (0-based). It is the inverse of the thumb-position math
// in scrollbar(): pressing or dragging on row y maps that row to the matching
// scroll offset, so clicking the gutter jumps there and dragging the thumb
// tracks the cursor. It is a no-op when the content fits (nothing to scroll).
func (t *transcript) scrollToRow(y int) {
h := t.vp.Height()
if h <= 0 {
return
}
total := t.vp.TotalLineCount()
if total <= h {
return
}
thumb := h * h / total
if thumb < 2 {
thumb = 2
}
if thumb > h {
thumb = h
}
span := h - thumb // rows the thumb top can occupy
if span <= 0 {
return
}
// Center the grab on the thumb: aim its top at y minus half its body so the
// cursor sits roughly mid-thumb, then clamp into the track.
top := y - thumb/2
if top < 0 {
top = 0
}
if top > span {
top = span
}
maxOff := total - h
t.vp.SetYOffset(top * maxOff / span)
t.follow = t.vp.AtBottom()
}
// viewportHeight reports the number of visible transcript rows, so the model can
// tell whether a mouse Y falls within the scrollable region.
func (t transcript) viewportHeight() int { return t.vp.Height() }
// overflowing reports whether the transcript has more content than fits in the
// viewport, i.e. there is history to scroll. relayout uses this to reserve the
// scrollbar column only when scrolling is possible, and view uses it to decide
// whether to attach the thumb at all.
func (t transcript) overflowing() bool {
return t.vp.Height() > 0 && t.vp.TotalLineCount() > t.vp.Height()
}
// view renders the current visible slice of the transcript. When the content
// overflows the viewport a one-column vertical scrollbar is drawn down the right
// edge (FR-10): each viewport row is normalized to exactly the content width
// before the scrollbar cell is appended, so the bar sits flush against the
// terminal's right edge and a dangling SGR from Markdown rendering can never
// bleed into (and hide) the bar column. When everything fits there is nothing to
// scroll, so no bar is drawn and the viewport uses the full width (relayout
// releases the reserved column in that case).
func (t transcript) view() string {
if !t.overflowing() {
return t.vp.View()
}
bar := strings.Split(t.scrollbar(), "\n")
body := strings.Split(t.vp.View(), "\n")
// Fit every body line to exactly t.width columns (ANSI-aware pad/truncate),
// terminating any open style so the bar cell renders on a clean slate.
fit := lipgloss.NewStyle().Width(t.width).MaxWidth(t.width)
var b strings.Builder
for i := 0; i < len(bar); i++ {
if i > 0 {
b.WriteByte('\n')
}
line := ""
if i < len(body) {
line = body[i]
}
if t.width > 0 {
b.WriteString(fit.Render(line))
}
b.WriteString(bar[i])
}
return b.String()
}
// scrollbar renders the one-column vertical scrollbar the height of the
// viewport. A proportional thumb marks the visible window and its position marks
// the scroll offset, so scrolling up through history moves the thumb; the
// remaining rows draw a thin groove (│). The thumb is drawn as a capsule like
// the macOS system scrollbar: a lower-half block ▄ caps the top and an upper-half
// block ▀ caps the bottom (their filled halves sit on the inner edges so the
// outer ends taper to rounded), with the full block █ filling the body rows
// between the caps. The thumb is never shorter than three rows, so the capsule
// always shows a body between its two rounded caps rather than collapsing to a
// flat blob. When the content fits (no overflow) the capsule fills the full
// height.
func (t transcript) scrollbar() string {
h := t.vp.Height()
if h <= 0 {
return ""
}
total := t.vp.TotalLineCount()
thumb := h
pos := 0
if total > h {
thumb = h * h / total
// Keep the capsule shape (rounded cap + body + rounded cap) by never
// letting the thumb shrink below three rows; clamp down to the viewport
// height when it is shorter than that.
if thumb < 3 {
thumb = 3
}
if thumb > h {
thumb = h
}
maxOff := total - h
off := t.vp.YOffset()
if off > maxOff {
off = maxOff
}
if maxOff > 0 {
pos = off * (h - thumb) / maxOff
}
}
var b strings.Builder
for i := 0; i < h; i++ {
if i > 0 {
b.WriteByte('\n')
}
switch {
case i < pos || i >= pos+thumb:
b.WriteString(t.theme.ScrollTrack.Render("│"))
case thumb >= 2 && i == pos:
b.WriteString(t.theme.ScrollThumb.Render("▄"))
case thumb >= 2 && i == pos+thumb-1:
b.WriteString(t.theme.ScrollThumb.Render("▀"))
default:
b.WriteString(t.theme.ScrollThumb.Render("█"))
}
}
return b.String()
}
// reflow re-renders every block to the current width and pushes the joined
// content into the viewport. When the follow intent is set it snaps to the
// bottom so new content auto-scrolls; otherwise the offset is preserved so
// reading history is not interrupted. follow is tracked in update/scrollToRow
// (user scroll) and addUser (new turn) rather than sampled here, because setSize
// resizes the viewport before reflow runs and an AtBottom() sample would misread.
//
// Width is decided here rather than in setSize so it stays correct as a run
// streams in new lines (which reach reflow via appendDelta/finalizeTurn, not
// setSize): the blocks are first laid out at the full width, and only if that
// overflows the viewport is one column handed back to the scrollbar and the
// blocks re-laid at totalWidth-1. When the content fits, the transcript keeps
// the full width and view() draws no bar.
func (t *transcript) reflow() {
t.width = t.totalWidth
t.vp.SetWidth(t.width)
t.vp.SetContent(t.renderAll())
// A narrower width never reduces the line count, so if the full-width layout
// already overflows it still overflows at totalWidth-1: reserve the scrollbar
// column and re-lay the blocks so the body never sits under the bar.
if t.totalWidth > 0 && t.vp.TotalLineCount() > t.vp.Height() {
t.width = t.totalWidth - 1
t.vp.SetWidth(t.width)
t.vp.SetContent(t.renderAll())
}
if t.follow {
t.vp.GotoBottom()
}
}
// renderAll joins every block, rendered to the current content width, into the
// transcript body string. Consecutive turns are separated by a blank line before
// a new user turn so requests read as visually distinct.
func (t *transcript) renderAll() string {
var b strings.Builder
for i, blk := range t.blocks {
if i > 0 {
b.WriteByte('\n')
if blk.role == roleUser {
b.WriteByte('\n')
}
}
b.WriteString(t.renderBlock(blk, i == t.activeAssistant))
}
return b.String()
}
// renderBlock wraps a block's text to the content width and applies the role's
// theme style. Wrapping happens on the raw text (measured in display columns via
// WrapToWidth) before styling so ANSI escapes never confuse the width math and
// no double-width rune is split. A finalized assistant block is rendered as
// Markdown (fix #3, mirroring the REPL's turn-end render); the still-streaming
// block (streaming==true) stays plain text because Markdown can only be laid out
// once the whole block is known.
func (t transcript) renderBlock(blk transcriptBlock, streaming bool) string {
if blk.role == roleTool && blk.card != nil {
return blk.card.render(t.theme, t.width)
}
switch blk.role {
case roleBanner:
return blk.text
case roleUser:
return t.theme.User.Render(WrapToWidth(blk.text, t.width))
case roleSystem:
return t.theme.System.Render(WrapToWidth(blk.text, t.width))
default:
if streaming {
return t.theme.Assistant.Render(WrapToWidth(blk.text, t.width))
}
return renderMarkdown(blk.text, t.width)
}
}
+324
View File
@@ -0,0 +1,324 @@
package tui
import (
"regexp"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli/ui"
)
// ansiRE strips SGR escape sequences so tests can inspect the raw text the
// transcript stored, independent of the theme's coloring.
var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m")
func stripANSI(s string) string { return ansiRE.ReplaceAllString(s, "") }
// apply runs one Update tick and returns the concrete Model, failing on an
// unexpected model type. It keeps the streaming tests terse.
func apply(t *testing.T, m tea.Model, msg tea.Msg) Model {
t.Helper()
next, _ := m.Update(msg)
got, ok := next.(Model)
if !ok {
t.Fatalf("Update returned %T, want tui.Model", next)
}
return got
}
// TestTranscriptStreamingConcat feeds a run of text deltas then a turn end and
// asserts the assistant block accumulates the deltas in order and the joined
// text is rendered in the View.
func TestTranscriptStreamingConcat(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, textDeltaMsg{delta: "Hello "})
m = apply(t, m, textDeltaMsg{delta: "world"})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
Content: agentcore.ContentList{agentcore.NewTextContent("Hello world")},
}})
if n := len(m.transcript.blocks); n != 1 {
t.Fatalf("block count = %d, want 1 assistant block", n)
}
if got := m.transcript.blocks[0]; got.role != roleAssistant || got.text != "Hello world" {
t.Errorf("assistant block = %+v, want role assistant text %q", got, "Hello world")
}
if content := stripANSI(m.View().Content); !strings.Contains(content, "Hello world") {
t.Errorf("rendered View missing streamed text; got:\n%s", content)
}
// The turn was finalized, so a fresh delta starts a NEW assistant block.
if m.transcript.activeAssistant != -1 {
t.Errorf("activeAssistant = %d after turn end, want -1", m.transcript.activeAssistant)
}
}
// TestTranscriptSurfacesTurnError verifies a turn that ends with stopReason
// error surfaces the provider's error message as a system block rather than
// finalizing an empty turn and returning silently to the prompt. The loop
// delivers request failures (e.g. a 4xx) this way — as a terminal assistant
// message via TurnEndEvent, not as the run's result error — so without the
// StopReason check in the turnEndMsg handler the TUI would show nothing at all.
func TestTranscriptSurfacesTurnError(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
StopReason: agentcore.StopReasonError,
ErrorMessage: "upstream 401: 无效的令牌",
}})
var sys string
for _, b := range m.transcript.blocks {
if b.role == roleSystem {
sys = b.text
}
}
if !strings.Contains(sys, "error:") || !strings.Contains(sys, "upstream 401: 无效的令牌") {
t.Errorf("turn error not surfaced; system block = %q", sys)
}
if content := stripANSI(m.View().Content); !strings.Contains(content, "upstream 401") {
t.Errorf("rendered View missing the surfaced error; got:\n%s", content)
}
}
// TestTranscriptSurfacesAbortedTurn verifies a turn that ends with stopReason
// aborted is flagged rather than returning silently.
func TestTranscriptSurfacesAbortedTurn(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
StopReason: agentcore.StopReasonAborted,
}})
var sys string
for _, b := range m.transcript.blocks {
if b.role == roleSystem {
sys = b.text
}
}
if !strings.Contains(sys, "aborted") {
t.Errorf("aborted turn not surfaced; system block = %q", sys)
}
}
// TestTranscriptNotesEmptyResponse verifies a clean end_turn that produced no
// content and no tool results is flagged with a note (with a provider-mismatch
// hint) instead of returning silently to the prompt — the shape produced when an
// endpoint accepts the request with a 200 but returns nothing decodable.
func TestTranscriptNotesEmptyResponse(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
StopReason: agentcore.StopReasonEndTurn,
}})
var sys string
for _, b := range m.transcript.blocks {
if b.role == roleSystem {
sys = b.text
}
}
if !strings.Contains(sys, "empty response from the model") {
t.Errorf("empty response not flagged; system block = %q", sys)
}
}
// TestTranscriptCleanTurnNoNote verifies a normal turn with content does NOT add
// a spurious error/empty system note.
func TestTranscriptCleanTurnNoNote(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12})
m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{
StopReason: agentcore.StopReasonEndTurn,
Content: agentcore.ContentList{agentcore.NewTextContent("the answer")},
}})
for _, b := range m.transcript.blocks {
if b.role == roleSystem {
t.Errorf("clean turn should add no system note, got %q", b.text)
}
}
}
// TestTranscriptAutoStick verifies the stick-to-bottom rule: while the viewport
// is at the bottom, new content keeps it pinned there; once the user scrolls up,
// streamed content no longer forces a jump to the bottom — but submitting a new
// turn (addUser) re-arms follow and snaps back to the newest output.
func TestTranscriptAutoStick(t *testing.T) {
tr := newTranscript(DefaultTheme())
tr.setSize(20, 3) // 3 visible rows
for i := 0; i < 6; i++ {
tr.addUser("line")
}
if !tr.vp.AtBottom() {
t.Fatal("transcript should stick to the bottom while at the bottom")
}
// More content while pinned keeps it pinned.
tr.addUser("more")
if !tr.vp.AtBottom() {
t.Fatal("new content should keep a bottom-pinned transcript at the bottom")
}
// Simulate a user scroll-up through the viewport's key handling.
tr.update(tea.KeyPressMsg{Code: tea.KeyUp})
if tr.vp.AtBottom() {
t.Fatal("scrolling up should move the viewport off the bottom")
}
// Streamed content arriving while scrolled up must NOT yank the view back to
// the bottom — the user is reading history.
tr.appendDelta("streamed while reading history\nsecond line\nthird line")
if tr.vp.AtBottom() {
t.Error("auto-stick should stay paused after the user scrolls up")
}
// Submitting a new turn is an explicit action: it re-arms follow and snaps
// back to the newest output so the reply is never left off-screen.
tr.addUser("a brand new prompt")
if !tr.vp.AtBottom() {
t.Error("submitting a new turn should re-arm auto-scroll to the bottom")
}
}
// TestTranscriptCJKWrap feeds a long CJK line into a narrow transcript and
// asserts every wrapped line fits the width in display columns (not bytes) and
// that no rune was dropped or split.
func TestTranscriptCJKWrap(t *testing.T) {
const width = 10
tr := newTranscript(DefaultTheme())
tr.setSize(width, 20)
line := strings.Repeat("你好世界", 5) // 20 CJK runes = 40 display columns
tr.addUser(line)
content := tr.vp.GetContent()
lines := strings.Split(content, "\n")
if len(lines) < 2 {
t.Fatalf("expected the CJK line to wrap onto multiple rows, got %d line(s)", len(lines))
}
for i, ln := range lines {
if w := ui.Width(ln); w > width {
t.Errorf("wrapped line %d width = %d columns, want <= %d: %q", i, w, width, stripANSI(ln))
}
}
// No rune was cut or dropped: every source rune survives the wrap.
if got := strings.Count(stripANSI(content), "你"); got != 5 {
t.Errorf("counted %d 你 runes after wrap, want 5", got)
}
}
// TestTranscriptScrollbar verifies the scrollbar policy: the gutter is hidden
// while the content fits (nothing to scroll) and appears only once the content
// overflows. When overflowing, a rounded pill thumb (body "█" with half-block
// caps "▄"/"▀") sits alongside the thin groove "│".
func TestTranscriptScrollbar(t *testing.T) {
tr := newTranscript(DefaultTheme())
tr.setSize(20, 4) // 4 visible rows
// Two short lines fit in 4 rows: no scrollbar at all — no thumb, no groove.
tr.addUser("one")
tr.addUser("two")
if tr.overflowing() {
t.Fatal("transcript should not overflow while content fits")
}
fit := stripANSI(tr.view())
if strings.ContainsAny(fit, "█▄▀│") {
t.Errorf("expected no scrollbar glyphs while content fits; got:\n%q", fit)
}
// Enough lines to exceed 4 rows: now it overflows, thumb shrinks and the
// groove appears.
for i := 0; i < 10; i++ {
tr.addUser("line")
}
if !tr.overflowing() {
t.Fatal("transcript should overflow once content exceeds the viewport")
}
view := stripANSI(tr.view())
if !strings.Contains(view, "▄") || !strings.Contains(view, "▀") {
t.Errorf("expected a rounded pill thumb (▄ top, ▀ bottom) while overflowing; got:\n%q", view)
}
if !strings.Contains(view, "│") {
t.Errorf("expected a groove │ while overflowing; got:\n%q", view)
}
if strings.Contains(view, "░") {
t.Errorf("scrollbar no longer uses the shaded track ░; got:\n%q", view)
}
}
// TestTranscriptScrollToRow checks the click/drag mapping: pressing the top of
// the gutter scrolls to the top, the bottom scrolls to the bottom, and it is a
// no-op when the content fits.
func TestTranscriptScrollToRow(t *testing.T) {
tr := newTranscript(DefaultTheme())
tr.setSize(20, 4)
// Content fits: dragging must not move a non-scrollable viewport.
tr.addUser("only line")
tr.scrollToRow(3)
if tr.vp.YOffset() != 0 {
t.Errorf("scrollToRow on non-overflowing viewport moved offset to %d, want 0", tr.vp.YOffset())
}
for i := 0; i < 20; i++ {
tr.addUser("line")
}
if !tr.overflowing() {
t.Fatal("expected overflow after filling the transcript")
}
tr.scrollToRow(0)
if !tr.vp.AtTop() {
t.Errorf("dragging to row 0 should scroll to the top; YOffset=%d", tr.vp.YOffset())
}
tr.scrollToRow(tr.viewportHeight() - 1)
if !tr.vp.AtBottom() {
t.Errorf("dragging to the last row should scroll to the bottom; YOffset=%d", tr.vp.YOffset())
}
}
// TestModelScrollbarDrag drives the model with mouse press/motion/release on the
// scrollbar column and asserts the drag state toggles and the viewport scrolls.
func TestModelScrollbarDrag(t *testing.T) {
m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 30, Height: 8})
for i := 0; i < 40; i++ {
m.transcript.addUser("line")
}
if !m.transcript.overflowing() {
t.Fatal("expected the transcript to overflow")
}
col := m.width - 1
// Press at the top of the gutter: drag begins and the view jumps to the top.
m = apply(t, m, tea.MouseClickMsg{X: col, Y: 0, Button: tea.MouseLeft})
if !m.draggingScrollbar {
t.Fatal("left press on the scrollbar column should start dragging")
}
if !m.transcript.vp.AtTop() {
t.Errorf("press at row 0 should scroll to top; YOffset=%d", m.transcript.vp.YOffset())
}
// Motion to the bottom row while held drags the thumb down.
m = apply(t, m, tea.MouseMotionMsg{X: col, Y: m.transcript.viewportHeight() - 1, Button: tea.MouseLeft})
if !m.transcript.vp.AtBottom() {
t.Errorf("motion to the last row while dragging should scroll to bottom; YOffset=%d", m.transcript.vp.YOffset())
}
// Release ends the drag.
m = apply(t, m, tea.MouseReleaseMsg{X: col, Y: 3, Button: tea.MouseLeft})
if m.draggingScrollbar {
t.Error("release should end the scrollbar drag")
}
// A press away from the gutter column must not start a drag.
m = apply(t, m, tea.MouseClickMsg{X: 0, Y: 0, Button: tea.MouseLeft})
if m.draggingScrollbar {
t.Error("press off the scrollbar column should not start dragging")
}
}