first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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, "你")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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+")")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user