diff --git a/pkg/leantui/autocomplete_test.go b/pkg/leantui/autocomplete_test.go deleted file mode 100644 index 9fa25037b1..0000000000 --- a/pkg/leantui/autocomplete_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package leantui - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testCommands() []command { - return []command{ - {name: "new", desc: "Start a new session", kind: cmdBuiltin}, - {name: "help", desc: "Show help", kind: cmdBuiltin}, - {name: "compact", desc: "Compact", kind: cmdBuiltin}, - {name: "plan", desc: "Switch to planner", kind: cmdAgent}, - } -} - -func TestAutocompleteActivation(t *testing.T) { - t.Parallel() - a := newAutocomplete() - a.setCommands(testCommands()) - - assert.True(t, a.sync("/ne")) - cur, ok := a.current() - require.True(t, ok) - assert.Equal(t, "new", cur.name) - - assert.False(t, a.sync("hello")) // no leading slash - assert.False(t, a.sync("/new x")) // contains a space - assert.False(t, a.sync("/zzzzz")) // no matches -} - -func TestAutocompleteNavigation(t *testing.T) { - t.Parallel() - a := newAutocomplete() - a.setCommands(testCommands()) - require.True(t, a.sync("/")) // all commands match - - first, _ := a.current() - a.moveDown() - second, _ := a.current() - assert.NotEqual(t, first.name, second.name) - - a.moveUp() - back, _ := a.current() - assert.Equal(t, first.name, back.name) -} - -func TestAutocompleteRenderWidth(t *testing.T) { - t.Parallel() - a := newAutocomplete() - a.setCommands(testCommands()) - require.True(t, a.sync("/")) - rows := a.render(60) - assert.NotEmpty(t, rows) - for _, r := range rows { - assert.LessOrEqual(t, displayWidth(r), 60) - } -} - -func TestAutocompleteBuiltinsBeforeAgent(t *testing.T) { - t.Parallel() - matches := filterCommands(testCommands(), "") - // The agent command "plan" must sort after every built-in. - last := matches[len(matches)-1] - assert.Equal(t, "plan", last.name) - assert.Equal(t, cmdAgent, last.kind) -} diff --git a/pkg/leantui/banner_test.go b/pkg/leantui/banner_test.go index 39a7d4e20a..0f839b3d98 100644 --- a/pkg/leantui/banner_test.go +++ b/pkg/leantui/banner_test.go @@ -7,15 +7,17 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/leantui/ui" ) func TestCommitWelcomePadsBanner(t *testing.T) { t.Parallel() - m := &model{transcript: newTranscript()} + m := &model{screen: ui.NewScreen("", "", "")} m.commitWelcome() - require.Len(t, m.transcript.blocks, 1) - lines := m.transcript.blocks[0].lines(80) + require.Equal(t, 1, m.screen.Transcript.BlockCount()) + lines := m.screen.Transcript.BlockLines(0, 80) require.GreaterOrEqual(t, len(lines), bannerTopPadding+len(bannerLines)) for i := range bannerTopPadding { diff --git a/pkg/leantui/commands.go b/pkg/leantui/commands.go index dec77cc48e..d23ba44804 100644 --- a/pkg/leantui/commands.go +++ b/pkg/leantui/commands.go @@ -1,53 +1,16 @@ package leantui -import ( - "sort" - "strings" -) - -// commandKind distinguishes built-in lean-TUI commands (handled locally) from -// agent-provided commands and skills (resolved and sent to the agent). -type commandKind int - -const ( - cmdBuiltin commandKind = iota - cmdAgent -) - -type command struct { - name string - desc string - kind commandKind -} +import "github.com/docker/docker-agent/pkg/leantui/ui" // builtinCommands are the slash commands the lean TUI handles itself. -func builtinCommands() []command { - return []command{ - {name: "new", desc: "Start a new session", kind: cmdBuiltin}, - {name: "compact", desc: "Summarize and compact the conversation", kind: cmdBuiltin}, - {name: "effort", desc: "Set the model's reasoning effort (usage: /effort )", kind: cmdBuiltin}, - {name: "clear", desc: "Clear the screen", kind: cmdBuiltin}, - {name: "help", desc: "Show keyboard shortcuts and commands", kind: cmdBuiltin}, - {name: "exit", desc: "Exit", kind: cmdBuiltin}, - {name: "quit", desc: "Exit", kind: cmdBuiltin}, - } -} - -// filterCommands returns the commands whose name has the given prefix, built-in -// commands first, then agent commands, each group alphabetically sorted. -func filterCommands(all []command, prefix string) []command { - prefix = strings.ToLower(prefix) - var out []command - for _, c := range all { - if strings.HasPrefix(strings.ToLower(c.name), prefix) { - out = append(out, c) - } +func builtinCommands() []ui.Command { + return []ui.Command{ + {Name: "new", Desc: "Start a new session", Kind: ui.CmdBuiltin}, + {Name: "compact", Desc: "Summarize and compact the conversation", Kind: ui.CmdBuiltin}, + {Name: "effort", Desc: "Set the model's reasoning effort (usage: /effort )", Kind: ui.CmdBuiltin}, + {Name: "clear", Desc: "Clear the screen", Kind: ui.CmdBuiltin}, + {Name: "help", Desc: "Show keyboard shortcuts and commands", Kind: ui.CmdBuiltin}, + {Name: "exit", Desc: "Exit", Kind: ui.CmdBuiltin}, + {Name: "quit", Desc: "Exit", Kind: ui.CmdBuiltin}, } - sort.SliceStable(out, func(i, j int) bool { - if out[i].kind != out[j].kind { - return out[i].kind < out[j].kind - } - return out[i].name < out[j].name - }) - return out } diff --git a/pkg/leantui/editor_test.go b/pkg/leantui/editor_test.go deleted file mode 100644 index 8d935bf23d..0000000000 --- a/pkg/leantui/editor_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package leantui - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEditorInsertAndText(t *testing.T) { - t.Parallel() - e := newEditor("") - e.insert([]rune("hello")) - assert.Equal(t, "hello", e.text()) - assert.Equal(t, 5, e.cursor) - - e.moveLeft() - e.insert([]rune("X")) - assert.Equal(t, "hellXo", e.text()) -} - -func TestEditorInsertStripsCarriageReturns(t *testing.T) { - t.Parallel() - e := newEditor("") - e.insert([]rune("a\r\nb")) - assert.Equal(t, "a\nb", e.text()) -} - -func TestEditorBackspaceAndDelete(t *testing.T) { - t.Parallel() - e := newEditor("") - e.setText("abc") - e.backspace() - assert.Equal(t, "ab", e.text()) - - e.moveLineStart() - e.deleteForward() - assert.Equal(t, "b", e.text()) -} - -func TestEditorWordOps(t *testing.T) { - t.Parallel() - e := newEditor("") - e.setText("foo bar baz") - e.moveWordLeft() - assert.Equal(t, 8, e.cursor) - e.moveWordLeft() - assert.Equal(t, 4, e.cursor) - - e.moveLineStart() - e.moveWordRight() - assert.Equal(t, 3, e.cursor) - - e.moveLineEnd() - e.deleteWordBack() - assert.Equal(t, "foo bar ", e.text()) -} - -func TestEditorLayoutSingleLine(t *testing.T) { - t.Parallel() - e := newEditor("") - e.setText("hello") - lines, row, col := e.layout(20) - require.Len(t, lines, 1) - assert.Equal(t, 0, row) - assert.Equal(t, promptWidth+5, col) - assert.LessOrEqual(t, displayWidth(lines[0]), 20) -} - -func TestEditorLayoutWrapping(t *testing.T) { - t.Parallel() - e := newEditor("") - e.setText(strings.Repeat("a", 25)) - lines, row, col := e.layout(12) // content width 10 - require.Len(t, lines, 3) - assert.Equal(t, 2, row) - assert.Equal(t, promptWidth+5, col) - for _, l := range lines { - assert.LessOrEqual(t, displayWidth(l), 12) - } -} - -func TestEditorLayoutPlaceholder(t *testing.T) { - t.Parallel() - e := newEditor("type here") - lines, row, col := e.layout(40) - require.Len(t, lines, 1) - assert.Equal(t, 0, row) - assert.Equal(t, promptWidth, col) - assert.Contains(t, lines[0], "type here") -} - -func TestEditorVerticalMovement(t *testing.T) { - t.Parallel() - e := newEditor("") - e.setText("line1\nline2\nline3") - // cursor at end (line3) - require.True(t, e.up(40)) - _, _, col := e.layout(40) - assert.Equal(t, promptWidth+5, col) // preserved column on "line2" - - require.True(t, e.up(40)) - // now on the first row; up should fail and let history take over - assert.False(t, e.up(40)) -} - -func TestEditorHistory(t *testing.T) { - t.Parallel() - e := newEditor("") - e.rememberHistory("first") - e.rememberHistory("second") - - e.setText("draft") - e.historyPrev() - assert.Equal(t, "second", e.text()) - e.historyPrev() - assert.Equal(t, "first", e.text()) - e.historyPrev() - assert.Equal(t, "first", e.text()) // clamped - - e.historyNext() - assert.Equal(t, "second", e.text()) - e.historyNext() - assert.Equal(t, "draft", e.text()) // restored draft -} - -func TestEditorHistoryDeduplicates(t *testing.T) { - t.Parallel() - e := newEditor("") - e.rememberHistory("same") - e.rememberHistory("same") - assert.Len(t, e.history, 1) -} diff --git a/pkg/leantui/events.go b/pkg/leantui/events.go index 95cc698da3..431d580de1 100644 --- a/pkg/leantui/events.go +++ b/pkg/leantui/events.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/tools" msgtypes "github.com/docker/docker-agent/pkg/tui/messages" @@ -29,72 +30,72 @@ func (m *model) handleEvent(ctx context.Context, ev any) { m.trackStreamStopped() m.handleStreamStopped(ctx) case *runtime.AgentChoiceReasoningEvent: - m.transcript.appendPending(blockReasoning, e.Content) + m.screen.Transcript.AppendReasoning(e.Content) case *runtime.AgentChoiceEvent: - m.transcript.appendPending(blockAssistant, e.Content) + m.screen.Transcript.AppendAssistant(e.Content) case *runtime.PartialToolCallEvent: - m.transcript.flushPending() + m.screen.Transcript.FlushPending() toolDef := tools.Tool{Name: e.ToolCall.Function.Name} if e.ToolDefinition != nil { toolDef = *e.ToolDefinition } - m.transcript.upsertTool(e.GetAgentName(), e.ToolCall, toolDef, tuitypes.ToolStatusPending) + m.screen.Transcript.UpsertTool(e.GetAgentName(), e.ToolCall, toolDef, tuitypes.ToolStatusPending) case *runtime.ToolCallEvent: - m.transcript.flushPending() - m.transcript.upsertTool(e.GetAgentName(), e.ToolCall, e.ToolDefinition, tuitypes.ToolStatusRunning) + m.screen.Transcript.FlushPending() + m.screen.Transcript.UpsertTool(e.GetAgentName(), e.ToolCall, e.ToolDefinition, tuitypes.ToolStatusRunning) case *runtime.ToolCallOutputEvent: - if tv := m.transcript.tool(e.ToolCallID); tv != nil && tv.message != nil { - tv.message.AppendToolOutput(e.Output) - if tv.message.ToolStatus == tuitypes.ToolStatusPending { - tv.message.ToolStatus = tuitypes.ToolStatusRunning - if tv.message.StartedAt == nil { + if tv := m.screen.Transcript.Tool(e.ToolCallID); tv != nil && tv.Message() != nil { + tv.Message().AppendToolOutput(e.Output) + if tv.Message().ToolStatus == tuitypes.ToolStatusPending { + tv.Message().ToolStatus = tuitypes.ToolStatusRunning + if tv.Message().StartedAt == nil { now := time.Now() - tv.message.StartedAt = &now + tv.Message().StartedAt = &now } } } case *runtime.ToolCallResponseEvent: - m.transcript.finishTool(e, m.sessionState) + m.screen.Transcript.FinishTool(e.ToolCallID, ui.ToolResult{Response: e.Response, Result: e.Result, AgentName: e.GetAgentName(), ToolDefinition: e.ToolDefinition, Images: inlineImagesFromToolResult(e.Result)}, m.sessionState) case *runtime.ToolCallConfirmationEvent: - m.transcript.removeTool(toolViewID(e.ToolCall)) - toolDef := ensureToolDefinition(e.ToolCall, e.ToolDefinition) - m.confirm = &confirmState{ - tool: toolDef.Name, - toolView: *newToolView(e.GetAgentName(), e.ToolCall, toolDef, tuitypes.ToolStatusConfirmation), + m.screen.Transcript.RemoveTool(ui.ToolViewID(e.ToolCall)) + toolDef := ui.EnsureToolDefinition(e.ToolCall, e.ToolDefinition) + m.screen.Confirm = &ui.ConfirmModel{ + Tool: toolDef.Name, + View: *ui.NewToolView(e.GetAgentName(), e.ToolCall, toolDef, tuitypes.ToolStatusConfirmation), } case *runtime.TokenUsageEvent: m.setTokenUsage(e.SessionID, e.Usage) case *runtime.AgentInfoEvent: - m.status.agent = e.AgentName + m.status.Agent = e.AgentName if m.sessionState != nil { m.sessionState.SetCurrentAgentName(e.AgentName) } if e.Model != "" { - m.status.model = e.Model + m.status.Model = e.Model } if e.ContextLimit > 0 { - m.status.contextLimit = e.ContextLimit + m.status.ContextLimit = e.ContextLimit } case *runtime.TeamInfoEvent: m.applyTeamInfo(ctx, e) case *runtime.SessionCompactionEvent: m.handleSessionCompaction(ctx, e) case *runtime.ErrorEvent: - m.transcript.flushPending() - m.addNotice("✗ ", e.Error, stError()) + m.screen.Transcript.FlushPending() + m.addNotice("✗ ", e.Error, ui.StError()) case *runtime.WarningEvent: - m.addNotice("⚠ ", e.Message, stWarning()) + m.addNotice("⚠ ", e.Message, ui.StWarning()) case *runtime.ShellOutputEvent: output := e.Output - m.transcript.addBlock(func(w int) []string { return renderToolOutput(output, w) }) + m.screen.Transcript.AddBlock(func(w int) []string { return ui.RenderToolOutput(output, w) }) case *runtime.AgentSwitchingEvent: if e.Switching && e.ToAgent != "" { - m.addNotice("→ ", "Switching to "+e.ToAgent, stMuted()) + m.addNotice("→ ", "Switching to "+e.ToAgent, ui.StMuted()) } case *runtime.MaxIterationsReachedEvent: - m.addNotice("⚠ ", "Maximum iterations reached.", stWarning()) + m.addNotice("⚠ ", "Maximum iterations reached.", ui.StWarning()) case *runtime.ModelFallbackEvent: - m.addNotice("⚠ ", "Model "+e.FailedModel+" failed, falling back to "+e.FallbackModel+".", stWarning()) + m.addNotice("⚠ ", "Model "+e.FailedModel+" failed, falling back to "+e.FallbackModel+".", ui.StWarning()) } } @@ -102,12 +103,12 @@ func (m *model) handleUserMessageEvent(e *runtime.UserMessageEvent) { if m.consumeIgnoredUserEcho(e.Message) { return } - if pending, ok := m.consumePendingUser(pendingUserSteer, e.Message); ok { - m.transcript.flushPending() - m.addUserEcho(pending.display) + if pending, ok := m.consumePendingUser(ui.PendingUserSteer, e.Message); ok { + m.screen.Transcript.FlushPending() + m.addUserEcho(pending.Display) return } - m.transcript.flushPending() + m.screen.Transcript.FlushPending() m.addUserEcho(e.Message) } @@ -133,20 +134,20 @@ func (m *model) handleSessionCompaction(ctx context.Context, e *runtime.SessionC // finishBusy clears the busy state at the end of a run and starts the next // queued message, if any. It reports whether a queued run was started. func (m *model) finishBusy(ctx context.Context) bool { - m.transcript.flushPending() + m.screen.Transcript.FlushPending() m.busy = false m.runCancel = nil if len(m.queue) > 0 { next := m.queue[0] - m.queue[0] = pendingUserMessage{} + m.queue[0] = ui.PendingUserMessage{} m.queue = m.queue[1:] - if pending, ok := m.consumePendingUser(pendingUserFollowUp, next.content); ok { - next.display = pending.display + if pending, ok := m.consumePendingUser(ui.PendingUserFollowUp, next.Content); ok { + next.Display = pending.Display } - m.addUserEcho(next.display) - m.ignoreUserEcho(next.content) - m.startRun(ctx, next.content, nil) + m.addUserEcho(next.Display) + m.ignoreUserEcho(next.Content) + m.startRun(ctx, next.Content, nil) return true } return false @@ -161,14 +162,14 @@ func (m *model) applyTeamInfo(ctx context.Context, e *runtime.TeamInfoEvent) { if a.Name != e.CurrentAgent { continue } - m.status.agent = a.Name + m.status.Agent = a.Name switch { case a.Provider != "" && a.Model != "": - m.status.model = a.Provider + "/" + a.Model + m.status.Model = a.Provider + "/" + a.Model case a.Model != "": - m.status.model = a.Model + m.status.Model = a.Model } - m.status.thinking = a.Thinking + m.status.Thinking = a.Thinking } m.refreshCommands(ctx) } diff --git a/pkg/leantui/image.go b/pkg/leantui/image.go index 9fc2bda9d4..9f2fa95cf8 100644 --- a/pkg/leantui/image.go +++ b/pkg/leantui/image.go @@ -10,29 +10,16 @@ import ( "strings" "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/tools" ) -const ( - kittyMaxChunkSize = 4096 - kittyMaxImageCols = 80 - kittyMaxImageRows = 30 -) - -type inlineImage struct { - name string - mime string - pngData []byte - width int - height int -} - -func inlineImagesFromToolResult(result *tools.ToolCallResult) []inlineImage { +func inlineImagesFromToolResult(result *tools.ToolCallResult) []ui.InlineImage { if result == nil { return nil } - images := make([]inlineImage, 0, len(result.Images)+len(result.Documents)) + images := make([]ui.InlineImage, 0, len(result.Images)+len(result.Documents)) for i, img := range result.Images { name := fmt.Sprintf("image-%d", i+1) if inline, ok := inlineImageFromBase64(name, img.MimeType, img.Data); ok { @@ -54,20 +41,20 @@ func inlineImagesFromToolResult(result *tools.ToolCallResult) []inlineImage { return images } -func inlineImageFromBase64(name, mimeType, b64 string) (inlineImage, bool) { +func inlineImageFromBase64(name, mimeType, b64 string) (ui.InlineImage, bool) { if strings.TrimSpace(b64) == "" { - return inlineImage{}, false + return ui.InlineImage{}, false } data, err := base64.StdEncoding.DecodeString(b64) if err != nil { - return inlineImage{}, false + return ui.InlineImage{}, false } if mimeType == "" { mimeType = chat.DetectMimeTypeByContent(data) } if !chat.IsImageMimeType(mimeType) { - return inlineImage{}, false + return ui.InlineImage{}, false } if resized, err := chat.ResizeImage(data, mimeType); err == nil { data = resized.Data @@ -76,63 +63,20 @@ func inlineImageFromBase64(name, mimeType, b64 string) (inlineImage, bool) { img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { - return inlineImage{}, false + return ui.InlineImage{}, false } bounds := img.Bounds() var pngBuf bytes.Buffer if err := png.Encode(&pngBuf, img); err != nil { - return inlineImage{}, false + return ui.InlineImage{}, false } - return inlineImage{ - name: name, - mime: mimeType, - pngData: pngBuf.Bytes(), - width: bounds.Dx(), - height: bounds.Dy(), + return ui.InlineImage{ + Name: name, + MIME: mimeType, + PNGData: pngBuf.Bytes(), + Width: bounds.Dx(), + Height: bounds.Dy(), }, true } - -func renderInlineImage(img inlineImage, width int) []string { - if len(img.pngData) == 0 || img.width <= 0 || img.height <= 0 { - return nil - } - - cols := min(max(width-4, 1), kittyMaxImageCols) - rows := max(1, (img.height*cols+img.width-1)/img.width/2) - rows = min(rows, kittyMaxImageRows) - - label := "image" - if img.name != "" { - label = img.name - } - if img.mime != "" { - label += " (" + img.mime + ")" - } - - out := []string{" " + stMuted().Render("🖼 "+label)} - out = append(out, " "+kittyImageSequence(img.pngData, cols, rows)) - for range rows - 1 { - out = append(out, "") - } - return out -} - -func kittyImageSequence(pngData []byte, cols, rows int) string { - encoded := base64.StdEncoding.EncodeToString(pngData) - var b strings.Builder - for offset := 0; offset < len(encoded); offset += kittyMaxChunkSize { - end := min(offset+kittyMaxChunkSize, len(encoded)) - more := 0 - if end < len(encoded) { - more = 1 - } - if offset == 0 { - fmt.Fprintf(&b, "\x1b_Ga=T,t=d,f=100,q=2,C=1,c=%d,r=%d,m=%d;%s\x1b\\", cols, rows, more, encoded[offset:end]) - } else { - fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, encoded[offset:end]) - } - } - return b.String() -} diff --git a/pkg/leantui/image_test.go b/pkg/leantui/image_test.go index 4bfc680fa5..f49e0eea5e 100644 --- a/pkg/leantui/image_test.go +++ b/pkg/leantui/image_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/tools" tuitypes "github.com/docker/docker-agent/pkg/tui/types" ) @@ -30,13 +31,13 @@ func TestInlineImagesFromToolResultIncludesImagesAndImageDocuments(t *testing.T) images := inlineImagesFromToolResult(result) require.Len(t, images, 2) - assert.Equal(t, "image-1", images[0].name) - assert.Equal(t, "screenshot.png", images[1].name) - assert.Equal(t, "image/png", images[0].mime) - assert.NotEmpty(t, images[0].pngData) + assert.Equal(t, "image-1", images[0].Name) + assert.Equal(t, "screenshot.png", images[1].Name) + assert.Equal(t, "image/png", images[0].MIME) + assert.NotEmpty(t, images[0].PNGData) } -func TestRenderToolIncludesKittyImageSequence(t *testing.T) { +func TestRenderToolIncludesInlineImage(t *testing.T) { t.Parallel() b64 := testPNGBase64(t) images := inlineImagesFromToolResult(&tools.ToolCallResult{ @@ -44,23 +45,21 @@ func TestRenderToolIncludesKittyImageSequence(t *testing.T) { }) require.Len(t, images, 1) - tv := newToolView("root", tools.ToolCall{ + tv := ui.NewToolView("root", tools.ToolCall{ ID: "call-1", Function: tools.FunctionCall{ Name: "image_tool", Arguments: `{"file":"sample.png"}`, }, }, tools.Tool{Name: "image_tool"}, tuitypes.ToolStatusCompleted) - tv.message.Content = "Read image file sample.png" - tv.images = images + tv.Message().Content = "Read image file sample.png" + tv.SetImages(images) - lines := renderTool(*tv, 80) + lines := ui.RenderTool(*tv, 80) joined := strings.Join(lines, "\n") assert.Contains(t, joined, "Read image file sample.png") assert.Contains(t, joined, "\x1b_G") - assert.Contains(t, joined, "a=T") - assert.Contains(t, joined, "f=100") assert.Contains(t, joined, "🖼") } diff --git a/pkg/leantui/keys_test.go b/pkg/leantui/keys_test.go deleted file mode 100644 index a0e0c10de0..0000000000 --- a/pkg/leantui/keys_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package leantui - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func singleKey(t *testing.T, b string) key { - t.Helper() - p := &inputParser{} - keys := p.feed([]byte(b)) - require.Len(t, keys, 1) - return keys[0] -} - -func TestParseSimpleKeys(t *testing.T) { - t.Parallel() - assert.Equal(t, keyEnter, singleKey(t, "\r").typ) - assert.Equal(t, keyEnter, singleKey(t, "\n").typ) - assert.Equal(t, keyTab, singleKey(t, "\t").typ) - assert.Equal(t, keyBackspace, singleKey(t, "\x7f").typ) - assert.Equal(t, keyBackspace, singleKey(t, "\x08").typ) - assert.Equal(t, keyCtrlC, singleKey(t, "\x03").typ) - assert.Equal(t, keyCtrlD, singleKey(t, "\x04").typ) - assert.Equal(t, keyHome, singleKey(t, "\x01").typ) - assert.Equal(t, keyEnd, singleKey(t, "\x05").typ) - assert.Equal(t, keyCtrlW, singleKey(t, "\x17").typ) -} - -func TestParseRunes(t *testing.T) { - t.Parallel() - k := singleKey(t, "a") - assert.Equal(t, keyRune, k.typ) - assert.Equal(t, []rune{'a'}, k.runes) - - k = singleKey(t, "é") - assert.Equal(t, keyRune, k.typ) - assert.Equal(t, []rune{'é'}, k.runes) -} - -func TestParseEscapeSequences(t *testing.T) { - t.Parallel() - assert.Equal(t, keyUp, singleKey(t, "\x1b[A").typ) - assert.Equal(t, keyDown, singleKey(t, "\x1b[B").typ) - assert.Equal(t, keyRight, singleKey(t, "\x1b[C").typ) - assert.Equal(t, keyLeft, singleKey(t, "\x1b[D").typ) - assert.Equal(t, keyUp, singleKey(t, "\x1bOA").typ) - assert.Equal(t, keyWordRight, singleKey(t, "\x1b[1;5C").typ) - assert.Equal(t, keyWordLeft, singleKey(t, "\x1b[1;5D").typ) - assert.Equal(t, keyDelete, singleKey(t, "\x1b[3~").typ) - assert.Equal(t, keyHome, singleKey(t, "\x1b[H").typ) - assert.Equal(t, keyEnd, singleKey(t, "\x1b[F").typ) - assert.Equal(t, keyShiftTab, singleKey(t, "\x1b[Z").typ) - assert.Equal(t, keyWordLeft, singleKey(t, "\x1bb").typ) - assert.Equal(t, keyWordRight, singleKey(t, "\x1bf").typ) - assert.Equal(t, keyAltEnter, singleKey(t, "\x1b\r").typ) -} - -func TestParseLoneEscape(t *testing.T) { - t.Parallel() - assert.Equal(t, keyEsc, singleKey(t, "\x1b").typ) -} - -func TestParseBracketedPaste(t *testing.T) { - t.Parallel() - k := singleKey(t, "\x1b[200~hello world\x1b[201~") - assert.Equal(t, keyPaste, k.typ) - assert.Equal(t, "hello world", string(k.runes)) -} - -func TestParseBracketedPasteAcrossReads(t *testing.T) { - t.Parallel() - p := &inputParser{} - assert.Empty(t, p.feed([]byte("\x1b[200~hel"))) - assert.Empty(t, p.feed([]byte("lo"))) - keys := p.feed([]byte(" there\x1b[201~")) - require.Len(t, keys, 1) - assert.Equal(t, keyPaste, keys[0].typ) - assert.Equal(t, "hello there", string(keys[0].runes)) -} - -func TestParseMixedRun(t *testing.T) { - t.Parallel() - p := &inputParser{} - keys := p.feed([]byte("hi\r")) - require.Len(t, keys, 3) - assert.Equal(t, keyRune, keys[0].typ) - assert.Equal(t, keyRune, keys[1].typ) - assert.Equal(t, keyEnter, keys[2].typ) -} diff --git a/pkg/leantui/leantui.go b/pkg/leantui/leantui.go index db2f612e3c..3654635bae 100644 --- a/pkg/leantui/leantui.go +++ b/pkg/leantui/leantui.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/gitbranch" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/tui/service" ) @@ -31,11 +32,11 @@ type Config struct { // Run drives the lean TUI until the user exits. It owns the terminal (raw // mode, no alternate screen) for its lifetime and restores it on return. func Run(ctx context.Context, cfg Config) error { - term, err := newTerminal(os.Stdin, os.Stdout) + term, err := ui.NewTerminal(os.Stdin, os.Stdout) if err != nil { return err } - defer term.restore() + defer term.Restore() loopCtx, loopCancel := context.WithCancel(ctx) defer loopCancel() @@ -44,13 +45,13 @@ func Run(ctx context.Context, cfg Config) error { m.commitWelcome() m.refreshCommands(loopCtx) - keys := make(chan key, 64) + keys := make(chan ui.Key, 64) events := make(chan any, 256) resizes := make(chan [2]int, 4) done := make(chan struct{}) defer close(done) - go readKeys(term.reader, keys, done) + go readKeys(term.Reader(), keys, done) go func() { m.app.SubscribeWith(loopCtx, func(msg tea.Msg) { select { @@ -61,7 +62,7 @@ func Run(ctx context.Context, cfg Config) error { }() go func() { for { - w, h, ok := term.resized() + w, h, ok := term.Resized() if !ok { return } @@ -100,7 +101,7 @@ func Run(ctx context.Context, cfg Config) error { m.render() case sz := <-resizes: m.width, m.height = sz[0], sz[1] - m.r.setSize(sz[0], sz[1]) + m.r.SetSize(sz[0], sz[1]) m.render() case <-ticker.C: if m.busy { @@ -117,12 +118,12 @@ func Run(ctx context.Context, cfg Config) error { return nil } -func readKeys(r io.Reader, keys chan<- key, done <-chan struct{}) { - p := &inputParser{} +func readKeys(r io.Reader, keys chan<- ui.Key, done <-chan struct{}) { + p := &ui.InputParser{} buf := make([]byte, 8192) for { n, err := r.Read(buf) - for _, k := range p.feed(buf[:n]) { + for _, k := range p.Feed(buf[:n]) { select { case keys <- k: case <-done: @@ -137,36 +138,32 @@ func readKeys(r io.Reader, keys chan<- key, done <-chan struct{}) { type model struct { app *app.App - term *terminal - r *renderer + term *ui.Terminal + r *ui.Renderer width int height int - editor *editor - ac *autocomplete + screen *ui.Screen - status statusData + status ui.StatusModel sessionState *service.SessionState - usage *usageTracker + usage *ui.UsageTracker - transcript *transcript busy bool spinnerFrame int runCancel context.CancelFunc - queue []pendingUserMessage - pendingUsers []pendingUserMessage + queue []ui.PendingUserMessage + pendingUsers []ui.PendingUserMessage ignoredUsers []string - confirm *confirmState - quitting bool appName string disabledCommands map[string]bool } -func newModel(term *terminal, cfg Config) *model { - w, h := term.size() +func newModel(term *ui.Terminal, cfg Config) *model { + w, h := term.Size() appName := cfg.AppName if appName == "" { appName = "docker agent" @@ -184,15 +181,13 @@ func newModel(term *terminal, cfg Config) *model { return &model{ app: cfg.App, term: term, - r: newRenderer(term.writer, w, h), + r: ui.NewRenderer(term.Writer(), w, h), width: w, height: h, - editor: newEditor("Type a message, / for commands"), - ac: newAutocomplete(), - transcript: newTranscript(), - status: statusData{workingDir: cfg.WorkingDir, branch: gitbranch.Current(cfg.WorkingDir)}, + screen: ui.NewScreen(cfg.WorkingDir, gitbranch.Current(cfg.WorkingDir), "Type a message, / for commands"), + status: ui.StatusModel{WorkingDir: cfg.WorkingDir, Branch: gitbranch.Current(cfg.WorkingDir)}, sessionState: sessionState, - usage: newUsageTracker(), + usage: ui.NewUsageTracker(), appName: appName, disabledCommands: disabled, } @@ -201,19 +196,19 @@ func newModel(term *terminal, cfg Config) *model { // render assembles the full frame and reconciles it with the terminal. func (m *model) render() { lines, cursorLine, cursorCol := m.buildLines() - m.r.frame(lines, cursorLine, cursorCol) + m.r.Frame(lines, cursorLine, cursorCol) } // renderFinal repaints the current state, then erases the input box and footer // so only the conversation remains once the program exits. func (m *model) renderFinal() { - m.transcript.flushPending() + m.screen.Transcript.FlushPending() m.render() - m.r.eraseBelow(len(m.transcript.lines(m.width, m.spinnerFrame, m.busy, m.sessionState, m.pendingUsers))) + m.r.EraseBelow(len(m.screen.Transcript.Lines(m.width, m.spinnerFrame, m.busy, m.sessionState, m.pendingUsers))) } func (m *model) commitWelcome() { - m.transcript.addBlock(func(int) []string { + m.screen.Transcript.AddBlock(func(int) []string { lines := make([]string, 0, bannerTopPadding+len(bannerLines)+2) for range bannerTopPadding { lines = append(lines, "") @@ -221,11 +216,11 @@ func (m *model) commitWelcome() { leftPad := strings.Repeat(" ", bannerLeftPadding) for _, l := range bannerLines { - lines = append(lines, stAccent().Render(leftPad+l)) + lines = append(lines, ui.StAccent().Render(leftPad+l)) } lines = append(lines, "", - stMuted().Render(leftPad+"Type a message, press / for commands, Ctrl+C to quit."), + ui.StMuted().Render(leftPad+"Type a message, press / for commands, Ctrl+C to quit."), ) return lines }) diff --git a/pkg/leantui/status.go b/pkg/leantui/status.go deleted file mode 100644 index 291742ab49..0000000000 --- a/pkg/leantui/status.go +++ /dev/null @@ -1,129 +0,0 @@ -package leantui - -import ( - "fmt" - "strconv" - "strings" - - pathx "github.com/docker/docker-agent/pkg/path" - "github.com/docker/docker-agent/pkg/tui/components/toolcommon" -) - -// statusData is the snapshot of run state shown in the footer. -type statusData struct { - workingDir string - branch string - - agent string - model string - thinking string - - contextLength int64 - contextLimit int64 - tokens int64 // input + output tokens used so far - cost float64 - costKnown bool -} - -// renderStatus builds the two-line footer: -// -// -// · · · -func renderStatus(d statusData, width int) []string { - dir := stSecondary().Render(truncate(pathx.ShortenHome(d.workingDir), max(10, width/2))) - left1 := dir - if d.branch != "" { - left1 += stMuted().Render(" ⎇ " + d.branch) - } - - right1 := "" - if d.agent != "" { - right1 = stAccent().Render(d.agent) - } - - left2 := renderContext(d) - - var rightParts []string - if d.model != "" { - rightParts = append(rightParts, d.model) - } - if d.thinking != "" { - rightParts = append(rightParts, d.thinking) - } - right2 := stMuted().Render(strings.Join(rightParts, " · ")) - - return []string{ - composeLine(left1, right1, width), - composeLine(left2, right2, width), - } -} - -func renderContext(d statusData) string { - cost := renderCostSuffix(d) - if d.contextLimit <= 0 { - if d.tokens > 0 { - return stMuted().Render(formatTokens(d.tokens)+" tokens") + cost - } - return renderBar(0) + stMuted().Render(" 0% · 0/0") + cost - } - - pct := float64(d.contextLength) / float64(d.contextLimit) - if pct > 1 { - pct = 1 - } - bar := renderBar(pct) - label := fmt.Sprintf(" %d%% · %s/%s", - int(pct*100+0.5), - formatTokens(d.contextLength), - formatTokens(d.contextLimit), - ) - return bar + stMuted().Render(label) + cost -} - -func renderCostSuffix(d statusData) string { - if !d.costKnown { - return "" - } - return stMuted().Render(" · ") + stAccent().Render(toolcommon.FormatCostUSD(d.cost)) -} - -// contextBarWidth is the cell width of the context-usage gauge. -const contextBarWidth = 10 - -func renderBar(pct float64) string { - filled := min(int(pct*float64(contextBarWidth)+0.5), contextBarWidth) - style := stSuccess() - switch { - case pct >= 0.85: - style = stError() - case pct >= 0.6: - style = stWarning() - } - return style.Render(strings.Repeat("█", filled)) + stMuted().Render(strings.Repeat("░", contextBarWidth-filled)) -} - -// composeLine right-aligns right within width, truncating left if necessary. -func composeLine(left, right string, width int) string { - lw := displayWidth(left) - rw := displayWidth(right) - if rw > width { - return truncate(right, width) - } - if lw+rw+1 > width { - left = truncate(left, max(0, width-rw-1)) - lw = displayWidth(left) - } - gap := max(1, width-lw-rw) - return left + strings.Repeat(" ", gap) + right -} - -func formatTokens(n int64) string { - switch { - case n >= 1_000_000: - return fmt.Sprintf("%.1fM", float64(n)/1_000_000) - case n >= 1_000: - return fmt.Sprintf("%.1fk", float64(n)/1_000) - default: - return strconv.FormatInt(n, 10) - } -} diff --git a/pkg/leantui/status_test.go b/pkg/leantui/status_test.go index aacd5ad3d6..e8d0a23bc7 100644 --- a/pkg/leantui/status_test.go +++ b/pkg/leantui/status_test.go @@ -6,80 +6,18 @@ import ( "github.com/stretchr/testify/assert" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/runtime" ) -func TestFormatTokens(t *testing.T) { - t.Parallel() - assert.Equal(t, "500", formatTokens(500)) - assert.Equal(t, "999", formatTokens(999)) - assert.Equal(t, "1.0k", formatTokens(1000)) - assert.Equal(t, "1.2k", formatTokens(1234)) - assert.Equal(t, "1.0M", formatTokens(1_000_000)) - assert.Equal(t, "2.5M", formatTokens(2_500_000)) -} - -func TestComposeLineRightAligns(t *testing.T) { - t.Parallel() - out := composeLine("left", "right", 20) - assert.Equal(t, 20, displayWidth(out)) - assert.GreaterOrEqual(t, len(out), len("left")+len("right")) - assert.Contains(t, out, "left") - assert.Contains(t, out, "right") -} - -func TestComposeLineTruncatesLeft(t *testing.T) { - t.Parallel() - out := composeLine("a very long left side that does not fit", "right", 15) - assert.LessOrEqual(t, displayWidth(out), 15) - assert.Contains(t, out, "right") -} - -func TestRenderBarWidth(t *testing.T) { - t.Parallel() - assert.Equal(t, contextBarWidth, displayWidth(renderBar(0.5))) - assert.Equal(t, contextBarWidth, displayWidth(renderBar(0))) - assert.Equal(t, contextBarWidth, displayWidth(renderBar(1))) - assert.Equal(t, contextBarWidth, displayWidth(renderBar(1.5))) // clamped -} - -func TestRenderContextShowsZerosBeforeUsage(t *testing.T) { - t.Parallel() - out := renderContext(statusData{}) - assert.NotContains(t, out, "context") - assert.Contains(t, out, "0% · 0/0") -} - func TestAgentInfoContextLimitShownBeforeUsage(t *testing.T) { t.Parallel() m := bareModel(24) m.handleEvent(t.Context(), runtime.AgentInfo("root", "test/model", "", "", 200_000)) - assert.Equal(t, int64(200_000), m.status.contextLimit) - assert.Contains(t, renderContext(m.status), "0% · 0/200.0k") -} - -func TestRenderStatusFitsWidth(t *testing.T) { - t.Parallel() - d := statusData{ - workingDir: "/home/user/project", - branch: "main", - agent: "coder", - model: "openai/gpt-5", - thinking: "high", - contextLength: 24_000, - contextLimit: 200_000, - tokens: 24_000, - cost: 0.05, - costKnown: true, - } - lines := renderStatus(d, 80) - assert.Len(t, lines, 2) - assert.Contains(t, strings.Join(lines, "\n"), "$0.05") - for _, l := range lines { - assert.LessOrEqual(t, displayWidth(l), 80) - } + assert.Equal(t, int64(200_000), m.status.ContextLimit) + assert.Contains(t, ui.RenderContext(m.status), "0% · 0/200.0k") } func TestTokenUsageEventAggregatesSessionCost(t *testing.T) { @@ -103,15 +41,15 @@ func TestTokenUsageEventAggregatesSessionCost(t *testing.T) { Cost: 0.05, })) - assert.Equal(t, int64(1_000), m.status.tokens) - assert.InDelta(t, 0.15, m.status.cost, 0.0001) - assert.True(t, m.status.costKnown) - assert.Contains(t, strings.Join(renderStatus(m.status, 80), "\n"), "$0.15") + assert.Equal(t, int64(1_000), m.status.Tokens) + assert.InDelta(t, 0.15, m.status.Cost, 0.0001) + assert.True(t, m.status.CostKnown) + assert.Contains(t, strings.Join(ui.RenderStatus(m.status, 80), "\n"), "$0.15") m.handleEvent(t.Context(), runtime.StreamStopped("child-session", "developer", "normal")) - assert.Equal(t, int64(3_000), m.status.tokens) - assert.InDelta(t, 0.15, m.status.cost, 0.0001) + assert.Equal(t, int64(3_000), m.status.Tokens) + assert.InDelta(t, 0.15, m.status.Cost, 0.0001) } func TestTokenUsageBeforeStreamUsesFirstSessionAsRoot(t *testing.T) { @@ -133,9 +71,9 @@ func TestTokenUsageBeforeStreamUsesFirstSessionAsRoot(t *testing.T) { Cost: 0.05, })) - assert.Equal(t, "root-session", m.usage.rootSessionID) - assert.Equal(t, int64(3_000), m.status.tokens) - assert.InDelta(t, 0.15, m.status.cost, 0.0001) + assert.Equal(t, "root-session", m.usage.RootSessionID()) + assert.Equal(t, int64(3_000), m.status.Tokens) + assert.InDelta(t, 0.15, m.status.Cost, 0.0001) } func TestEmptySessionUsageDoesNotOverrideSessionScopedUsage(t *testing.T) { @@ -155,6 +93,6 @@ func TestEmptySessionUsageDoesNotOverrideSessionScopedUsage(t *testing.T) { Cost: 0.99, })) - assert.Equal(t, int64(3_000), m.status.tokens) - assert.InDelta(t, 0.10, m.status.cost, 0.0001) + assert.Equal(t, int64(3_000), m.status.Tokens) + assert.InDelta(t, 0.10, m.status.Cost, 0.0001) } diff --git a/pkg/leantui/stream_test.go b/pkg/leantui/stream_test.go index da8962dbc8..29e4231e6d 100644 --- a/pkg/leantui/stream_test.go +++ b/pkg/leantui/stream_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/tui/service" tuitypes "github.com/docker/docker-agent/pkg/tui/types" @@ -25,13 +26,11 @@ func bareModel(height int) *model { return &model{ width: width, height: height, - r: newRenderer(w, width, height), - editor: newEditor("type here"), - ac: newAutocomplete(), - transcript: newTranscript(), - status: statusData{workingDir: "/tmp/project"}, + r: ui.NewRenderer(w, width, height), + screen: ui.NewScreen("/tmp/project", "", "type here"), + status: ui.StatusModel{WorkingDir: "/tmp/project"}, sessionState: service.NewSessionState(nil), - usage: newUsageTracker(), + usage: ui.NewUsageTracker(), } } @@ -41,43 +40,44 @@ func TestStreamingGrowthScrollsAndRendersMarkdown(t *testing.T) { m.busy = true m.render() // initial frame - m.transcript.pending = &pendingBlock{kind: blockAssistant} + m.screen.Transcript.AppendAssistant("") + m.screen.Transcript.AppendAssistant("init") for i := range 40 { - m.transcript.pending.text.WriteString("Paragraph " + strconv.Itoa(i) + " with some streamed text.\n\n") + m.screen.Transcript.AppendAssistant("Paragraph " + strconv.Itoa(i) + " with some streamed text.\n\n") lines, cl, cc := m.buildLines() - require.NotPanics(t, func() { m.r.frame(lines, cl, cc) }) + require.NotPanics(t, func() { m.r.Frame(lines, cl, cc) }) } // Content far exceeds the 10-row viewport, so it must have scrolled. - assert.Positive(t, m.r.viewportTop) + assert.Positive(t, m.r.ViewportTop()) // Finalizing the stream turns it into a cached block; the visible output is // unchanged because it was already rendered as markdown live. - m.transcript.flushPending() - assert.Len(t, m.transcript.blocks, 1) + m.screen.Transcript.FlushPending() + assert.Equal(t, 1, m.screen.Transcript.BlockCount()) require.NotPanics(t, func() { lines, cl, cc := m.buildLines() - m.r.frame(lines, cl, cc) + m.r.Frame(lines, cl, cc) }) } func TestBuildLinesPlacesCursorOnInput(t *testing.T) { t.Parallel() m := bareModel(24) - m.editor.setText("hello") + m.screen.Editor.SetText("hello") lines, cursorLine, cursorCol := m.buildLines() require.NotEmpty(t, lines) // The cursor line must point at the input row and the column past the prompt. assert.Contains(t, lines[cursorLine], "hello") - assert.Equal(t, promptWidth+5, cursorCol) + assert.Equal(t, ui.PromptWidth+5, cursorCol) } func TestConversationLinesShowsSpinnerWhenBusy(t *testing.T) { t.Parallel() m := bareModel(24) m.busy = true - lines := m.transcript.lines(80, m.spinnerFrame, m.busy, m.sessionState, nil) + lines := m.screen.Transcript.Lines(80, m.spinnerFrame, m.busy, m.sessionState, nil) assert.Contains(t, strings.Join(lines, ""), "Working") } @@ -85,23 +85,23 @@ func TestToolConfirmationReplacesRunningTool(t *testing.T) { t.Parallel() m := bareModel(24) tv := shellToolView(tuitypes.ToolStatusRunning) - m.transcript.upsertTool("root", tv.message.ToolCall, tv.message.ToolDefinition, tuitypes.ToolStatusRunning) - require.Len(t, m.transcript.toolz.order, 1) + m.screen.Transcript.UpsertTool("root", tv.Message().ToolCall, tv.Message().ToolDefinition, tuitypes.ToolStatusRunning) + require.Equal(t, 1, m.screen.Transcript.ToolCount()) - event := runtime.ToolCallConfirmation(tv.message.ToolCall, tv.message.ToolDefinition, "root", nil) + event := runtime.ToolCallConfirmation(tv.Message().ToolCall, tv.Message().ToolDefinition, "root", nil) m.handleEvent(t.Context(), event) - assert.Empty(t, m.transcript.toolz.order) - assert.Empty(t, m.transcript.toolz.byID) - require.NotNil(t, m.confirm) + assert.Zero(t, m.screen.Transcript.ToolCount()) + assert.Zero(t, m.screen.Transcript.ToolByIDCount()) + require.NotNil(t, m.screen.Confirm) } func TestBuildLinesConfirmCursorSitsOnOptions(t *testing.T) { t.Parallel() m := bareModel(24) - m.confirm = &confirmState{ - tool: "shell", - toolView: *shellToolView(tuitypes.ToolStatusConfirmation), + m.screen.Confirm = &ui.ConfirmModel{ + Tool: "shell", + View: *shellToolView(tuitypes.ToolStatusConfirmation), } lines, cursorLine, cursorCol := m.buildLines() diff --git a/pkg/leantui/tool_test.go b/pkg/leantui/tool_test.go index 53ddf3093d..902086bb82 100644 --- a/pkg/leantui/tool_test.go +++ b/pkg/leantui/tool_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/tools" "github.com/docker/docker-agent/pkg/tools/builtin/filesystem" builtinshell "github.com/docker/docker-agent/pkg/tools/builtin/shell" @@ -18,18 +19,18 @@ import ( func TestRenderToolOutputTruncatesOutput(t *testing.T) { t.Parallel() output := strings.Repeat("line\n", 50) - lines := renderToolOutput(output, 80) + lines := ui.RenderToolOutput(output, 80) - assert.LessOrEqual(t, len(lines), maxToolOutputLines+1) + assert.LessOrEqual(t, len(lines), ui.MaxToolOutputLines+1) assert.Contains(t, strings.Join(lines, "\n"), "earlier lines") } func TestRenderToolUsesFullTUIRenderer(t *testing.T) { t.Parallel() tv := shellToolView(tuitypes.ToolStatusCompleted) - tv.message.Content = "hi\n" + tv.Message().Content = "hi\n" - joined := strings.Join(renderTool(*tv, 80), "\n") + joined := strings.Join(ui.RenderTool(*tv, 80), "\n") assert.Contains(t, joined, builtinshell.ToolNameShell) assert.Contains(t, joined, "echo hi") assert.Contains(t, joined, "hi") @@ -39,26 +40,26 @@ func TestRenderToolUsesFullTUIRenderer(t *testing.T) { func TestRenderToolWrapsCallInBox(t *testing.T) { t.Parallel() width := 40 - lines := renderTool(*shellToolView(tuitypes.ToolStatusCompleted), width) + lines := ui.RenderTool(*shellToolView(tuitypes.ToolStatusCompleted), width) require.GreaterOrEqual(t, len(lines), 3) for _, line := range lines { - assert.LessOrEqual(t, displayWidth(line), width) + assert.LessOrEqual(t, ui.DisplayWidth(line), width) } assert.Empty(t, strings.TrimSpace(ansi.Strip(lines[0]))) - assert.Equal(t, width, displayWidth(lines[0])) + assert.Equal(t, width, ui.DisplayWidth(lines[0])) assert.True(t, strings.HasPrefix(ansi.Strip(lines[1]), " ")) assert.Contains(t, ansi.Strip(strings.Join(lines, "\n")), builtinshell.ToolNameShell) } func TestRenderToolDoesNotLeakAnimationSubscription(t *testing.T) { assert.False(t, animation.HasActive()) - renderToolWithState(shellToolView(tuitypes.ToolStatusRunning), 80, 3, nil) + ui.RenderToolWithState(shellToolView(tuitypes.ToolStatusRunning), 80, 3, nil) assert.False(t, animation.HasActive()) } func TestRenderToolKeepsLastLinesWhenArgumentsTemporarilyInvalid(t *testing.T) { - tv := newToolView("root", tools.ToolCall{ + tv := ui.NewToolView("root", tools.ToolCall{ ID: "call-1", Function: tools.FunctionCall{ Name: "Write", @@ -66,16 +67,16 @@ func TestRenderToolKeepsLastLinesWhenArgumentsTemporarilyInvalid(t *testing.T) { }, }, tools.Tool{Name: "Write"}, tuitypes.ToolStatusPending) - first := renderToolWithState(tv, 80, 0, nil) + first := ui.RenderToolWithState(tv, 80, 0, nil) require.Contains(t, strings.Join(first, "\n"), "hello") - tv.message.ToolCall.Function.Arguments += "," - second := renderToolWithState(tv, 80, 1, nil) + tv.Message().ToolCall.Function.Arguments += "," + second := ui.RenderToolWithState(tv, 80, 1, nil) assert.Contains(t, strings.Join(second, "\n"), "hello") } func TestRenderWriteFileKeepsPathWhenArgumentsTemporarilyInvalid(t *testing.T) { - tv := newToolView("root", tools.ToolCall{ + tv := ui.NewToolView("root", tools.ToolCall{ ID: "call-1", Function: tools.FunctionCall{ Name: filesystem.ToolNameWriteFile, @@ -83,16 +84,16 @@ func TestRenderWriteFileKeepsPathWhenArgumentsTemporarilyInvalid(t *testing.T) { }, }, tools.Tool{Name: filesystem.ToolNameWriteFile}, tuitypes.ToolStatusPending) - first := renderToolWithState(tv, 80, 0, nil) + first := ui.RenderToolWithState(tv, 80, 0, nil) require.Contains(t, strings.Join(first, "\n"), "/tmp/file") - tv.message.ToolCall.Function.Arguments += "," - second := renderToolWithState(tv, 80, 1, nil) + tv.Message().ToolCall.Function.Arguments += "," + second := ui.RenderToolWithState(tv, 80, 1, nil) assert.Contains(t, strings.Join(second, "\n"), "/tmp/file") } -func shellToolView(status tuitypes.ToolStatus) *toolView { - return newToolView("root", tools.ToolCall{ +func shellToolView(status tuitypes.ToolStatus) *ui.ToolView { + return ui.NewToolView("root", tools.ToolCall{ ID: "call-1", Function: tools.FunctionCall{ Name: builtinshell.ToolNameShell, diff --git a/pkg/leantui/toolstate.go b/pkg/leantui/toolstate.go deleted file mode 100644 index 1c624d0914..0000000000 --- a/pkg/leantui/toolstate.go +++ /dev/null @@ -1,133 +0,0 @@ -package leantui - -import ( - "slices" - "strings" - "time" - - "github.com/docker/docker-agent/pkg/runtime" - "github.com/docker/docker-agent/pkg/tools" - tuitypes "github.com/docker/docker-agent/pkg/tui/types" -) - -// toolTracker holds the render state of in-flight tool calls, keyed by id and -// kept in call order so the conversation shows them as they arrive. -type toolTracker struct { - byID map[string]*toolView - order []string -} - -func newToolTracker() *toolTracker { - return &toolTracker{byID: map[string]*toolView{}} -} - -func (t *toolTracker) reset() { - t.byID = map[string]*toolView{} - t.order = nil -} - -func (t *toolTracker) empty() bool { return len(t.order) == 0 } - -func (t *toolTracker) get(id string) *toolView { return t.byID[id] } - -// forEach visits the tracked tools in call order, skipping nil entries. -func (t *toolTracker) forEach(fn func(*toolView)) { - for _, id := range t.order { - if tv := t.byID[id]; tv != nil { - fn(tv) - } - } -} - -func (t *toolTracker) remove(id string) { - if id == "" { - return - } - delete(t.byID, id) - t.order = slices.DeleteFunc(t.order, func(s string) bool { return s == id }) -} - -// upsert creates or updates the tracked view for a tool call. Argument -// fragments streamed while the call is still pending are concatenated. -func (t *toolTracker) upsert(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status tuitypes.ToolStatus) { - id := toolViewID(toolCall) - tv := t.byID[id] - if tv == nil { - tv = newToolView(agentName, toolCall, toolDef, status) - t.byID[id] = tv - t.order = append(t.order, id) - return - } - - msg := tv.message - if msg == nil { - msg = newToolView(agentName, toolCall, toolDef, status).message - tv.message = msg - return - } - - if agentName != "" { - msg.Sender = agentName - } - if toolDef.Name != "" || toolCall.Function.Name != "" { - msg.ToolDefinition = ensureToolDefinition(toolCall, toolDef) - } - msg.ToolStatus = status - if status == tuitypes.ToolStatusRunning && msg.StartedAt == nil { - now := time.Now() - msg.StartedAt = &now - } - if toolCall.ID != "" { - msg.ToolCall.ID = toolCall.ID - } - if toolCall.Type != "" { - msg.ToolCall.Type = toolCall.Type - } - if toolCall.Function.Name != "" { - msg.ToolCall.Function.Name = toolCall.Function.Name - } - if toolCall.Function.Arguments != "" { - if status == tuitypes.ToolStatusPending { - msg.ToolCall.Function.Arguments += toolCall.Function.Arguments - } else { - msg.ToolCall.Function.Arguments = toolCall.Function.Arguments - } - } -} - -// finish marks a tool call complete, drops it from the tracker, and returns an -// immutable snapshot to commit into the conversation. It returns nil when there -// is nothing to render. -func (t *toolTracker) finish(e *runtime.ToolCallResponseEvent) *toolView { - id := e.ToolCallID - tv := t.byID[id] - if tv == nil { - toolCall := tools.ToolCall{ID: id, Function: tools.FunctionCall{Name: e.ToolDefinition.Name}} - tv = newToolView(e.GetAgentName(), toolCall, e.ToolDefinition, tuitypes.ToolStatusCompleted) - } - if tv.message == nil { - return nil - } - - status := tuitypes.ToolStatusCompleted - if e.Result != nil && e.Result.IsError { - status = tuitypes.ToolStatusError - } - tv.message.ToolStatus = status - tv.message.ToolDefinition = ensureToolDefinition(tv.message.ToolCall, e.ToolDefinition) - tv.message.Content = strings.ReplaceAll(e.Response, "\t", " ") - tv.message.ToolResult = e.Result.WithoutPayload() - tv.images = inlineImagesFromToolResult(e.Result) - - msg := *tv.message - snapshot := &toolView{message: &msg, images: tv.images} - t.remove(id) - return snapshot -} - -func toolViewID(toolCall tools.ToolCall) string { - if toolCall.ID != "" { - return toolCall.ID - } - return toolCall.Function.Name -} diff --git a/pkg/leantui/transcript.go b/pkg/leantui/transcript.go deleted file mode 100644 index 47244fd444..0000000000 --- a/pkg/leantui/transcript.go +++ /dev/null @@ -1,177 +0,0 @@ -package leantui - -import ( - "strings" - - "github.com/docker/docker-agent/pkg/runtime" - "github.com/docker/docker-agent/pkg/tools" - "github.com/docker/docker-agent/pkg/tui/service" - tuitypes "github.com/docker/docker-agent/pkg/tui/types" -) - -var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - -type blockKind int - -type pendingUserKind int - -const ( - blockReasoning blockKind = iota - blockAssistant -) - -const ( - pendingUserSteer pendingUserKind = iota - pendingUserFollowUp -) - -type pendingUserMessage struct { - display string - content string - kind pendingUserKind -} - -// pendingBlock accumulates the text of the block currently being streamed. -type pendingBlock struct { - kind blockKind - text strings.Builder -} - -// block is a finalized piece of the conversation. Its lines are rendered lazily -// and cached per width, so finalized content is not re-rendered every frame and -// only reflows when the terminal is resized. -type block struct { - render func(width int) []string - cacheW int - cache []string - cached bool -} - -func (b *block) lines(width int) []string { - if !b.cached || b.cacheW != width { - b.cache = b.render(width) - b.cacheW = width - b.cached = true - } - return b.cache -} - -// transcript owns everything that scrolls: the finalized conversation blocks, -// the in-progress streamed block, and the in-flight tool calls. Committed -// blocks are immutable scrollback; the pending block and tool calls are the -// live region that changes each frame until they finalize into blocks. -type transcript struct { - blocks []*block - pending *pendingBlock - toolz *toolTracker -} - -func newTranscript() *transcript { - return &transcript{toolz: newToolTracker()} -} - -// clearActive drops the live region (the streamed block and any in-flight tool -// calls) while keeping the committed scrollback intact. Used when starting a -// new session. -func (t *transcript) clearActive() { - t.pending = nil - t.toolz.reset() -} - -// addBlock appends a finalized, lazily-rendered block to the conversation. -func (t *transcript) addBlock(render func(width int) []string) { - t.blocks = append(t.blocks, &block{render: render}) -} - -func (t *transcript) appendPending(kind blockKind, content string) { - if content == "" { - return - } - if t.pending == nil || t.pending.kind != kind { - t.flushPending() - t.pending = &pendingBlock{kind: kind} - } - t.pending.text.WriteString(content) -} - -// flushPending finalizes the in-progress streamed block into the conversation. -func (t *transcript) flushPending() { - if t.pending == nil { - return - } - text := t.pending.text.String() - kind := t.pending.kind - t.pending = nil - - switch kind { - case blockReasoning: - t.addBlock(func(w int) []string { return renderReasoningLines(text, w) }) - case blockAssistant: - t.addBlock(func(w int) []string { return renderAssistantLines(text, w) }) - } -} - -func (t *transcript) upsertTool(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status tuitypes.ToolStatus) { - t.toolz.upsert(agentName, toolCall, toolDef, status) -} - -func (t *transcript) tool(id string) *toolView { return t.toolz.get(id) } - -func (t *transcript) removeTool(id string) { t.toolz.remove(id) } - -// finishTool commits a completed tool call as an immutable block. -func (t *transcript) finishTool(e *runtime.ToolCallResponseEvent, sessionState service.SessionStateReader) { - view := t.toolz.finish(e) - if view == nil { - return - } - t.addBlock(func(w int) []string { return renderToolWithState(view, w, 0, sessionState) }) -} - -// lines renders everything that scrolls: finalized blocks, the in-progress -// streamed block, running tool calls, and user messages waiting to be accepted -// by the runtime. A blank line separates each entry. The spinner is shown only -// while busy with nothing yet streaming. -func (t *transcript) lines(width, spinnerFrame int, busy bool, sessionState service.SessionStateReader, pendingUsers []pendingUserMessage) []string { - var lines []string - for _, b := range t.blocks { - lines = append(lines, b.lines(width)...) - lines = append(lines, "") - } - if t.pending != nil { - lines = append(lines, t.pendingLines(width)...) - lines = append(lines, "") - } - t.toolz.forEach(func(tv *toolView) { - lines = append(lines, renderToolWithState(tv, width, spinnerFrame, sessionState)...) - lines = append(lines, "") - }) - if busy && t.pending == nil && t.toolz.empty() { - lines = append(lines, spinnerLine(spinnerFrame), "") - } - for _, msg := range pendingUsers { - lines = append(lines, renderPendingUserLines(msg.display, width)...) - lines = append(lines, "") - } - return lines -} - -// pendingLines renders the message currently being streamed. Assistant text is -// rendered as markdown live (the same renderer used once it is finalized), so -// formatting appears as it streams. -func (t *transcript) pendingLines(width int) []string { - text := t.pending.text.String() - switch t.pending.kind { - case blockReasoning: - return renderReasoningLines(text, width) - case blockAssistant: - return renderAssistantLines(text, width) - default: - return nil - } -} - -func spinnerLine(frame int) string { - f := spinnerFrames[frame%len(spinnerFrames)] - return stAccent().Render(f) + " " + stMuted().Render("Working…") -} diff --git a/pkg/leantui/autocomplete.go b/pkg/leantui/ui/autocomplete.go similarity index 51% rename from pkg/leantui/autocomplete.go rename to pkg/leantui/ui/autocomplete.go index 35e2198870..f5c489d185 100644 --- a/pkg/leantui/autocomplete.go +++ b/pkg/leantui/ui/autocomplete.go @@ -1,48 +1,48 @@ -package leantui +package ui import "strings" const autocompleteMaxRows = 8 -// autocomplete drives the slash-command popup. It is active whenever the input +// Autocomplete drives the slash-command popup. It is active whenever the input // is a partial command: a single token starting with "/" and no spaces yet. -type autocomplete struct { - all []command - matches []command +type Autocomplete struct { + all []Command + matches []Command selected int - active bool + Active bool } -func newAutocomplete() *autocomplete { - return &autocomplete{} +func NewAutocomplete() *Autocomplete { + return &Autocomplete{} } -func (a *autocomplete) setCommands(cmds []command) { +func (a *Autocomplete) SetCommands(cmds []Command) { a.all = cmds } -// sync recomputes the popup state from the current editor text. It returns true +// Sync recomputes the popup state from the current editor text. It returns true // while the popup is showing. -func (a *autocomplete) sync(input string) bool { +func (a *Autocomplete) Sync(input string) bool { if !strings.HasPrefix(input, "/") || strings.ContainsAny(input, " \n") { - a.active = false + a.Active = false a.matches = nil a.selected = 0 return false } - a.matches = filterCommands(a.all, input[1:]) - a.active = len(a.matches) > 0 + a.matches = FilterCommands(a.all, input[1:]) + a.Active = len(a.matches) > 0 if a.selected >= len(a.matches) { a.selected = len(a.matches) - 1 } if a.selected < 0 { a.selected = 0 } - return a.active + return a.Active } -func (a *autocomplete) moveUp() { - if !a.active { +func (a *Autocomplete) MoveUp() { + if !a.Active { return } if a.selected > 0 { @@ -50,8 +50,8 @@ func (a *autocomplete) moveUp() { } } -func (a *autocomplete) moveDown() { - if !a.active { +func (a *Autocomplete) MoveDown() { + if !a.Active { return } if a.selected < len(a.matches)-1 { @@ -59,22 +59,22 @@ func (a *autocomplete) moveDown() { } } -func (a *autocomplete) current() (command, bool) { - if !a.active || a.selected >= len(a.matches) { - return command{}, false +func (a *Autocomplete) Current() (Command, bool) { + if !a.Active || a.selected >= len(a.matches) { + return Command{}, false } return a.matches[a.selected], true } -func (a *autocomplete) dismiss() { - a.active = false +func (a *Autocomplete) Dismiss() { + a.Active = false a.matches = nil a.selected = 0 } -// render returns the popup rows (top to bottom) for the given width. -func (a *autocomplete) render(width int) []string { - if !a.active || len(a.matches) == 0 { +// Render returns the popup rows (top to bottom) for the given width. +func (a *Autocomplete) Render(width int) []string { + if !a.Active || len(a.matches) == 0 { return nil } @@ -86,7 +86,7 @@ func (a *autocomplete) render(width int) []string { nameWidth := 0 for _, c := range a.matches { - if w := len(c.name) + 1; w > nameWidth { + if w := len(c.Name) + 1; w > nameWidth { nameWidth = w } } @@ -95,13 +95,13 @@ func (a *autocomplete) render(width int) []string { var rows []string for i := start; i < end; i++ { c := a.matches[i] - name := padRight("/"+c.name, nameWidth) - line := " " + name + " " + c.desc - line = truncate(line, width) + name := PadRight("/"+c.Name, nameWidth) + line := " " + name + " " + c.Desc + line = Truncate(line, width) if i == a.selected { rows = append(rows, lipglossSelected(line, width)) } else { - rows = append(rows, stMuted().Render(line)) + rows = append(rows, StMuted().Render(line)) } } return rows @@ -109,6 +109,6 @@ func (a *autocomplete) render(width int) []string { // lipglossSelected highlights the selected popup row across the full width. func lipglossSelected(line string, width int) string { - padded := padRight(line, width) - return stAccent().Bold(true).Render(padded) + padded := PadRight(line, width) + return StAccent().Bold(true).Render(padded) } diff --git a/pkg/leantui/ui/autocomplete_test.go b/pkg/leantui/ui/autocomplete_test.go new file mode 100644 index 0000000000..af5f7a118d --- /dev/null +++ b/pkg/leantui/ui/autocomplete_test.go @@ -0,0 +1,69 @@ +package ui + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testCommands() []Command { + return []Command{ + {Name: "new", Desc: "Start a new session", Kind: CmdBuiltin}, + {Name: "help", Desc: "Show help", Kind: CmdBuiltin}, + {Name: "compact", Desc: "Compact", Kind: CmdBuiltin}, + {Name: "plan", Desc: "Switch to planner", Kind: CmdAgent}, + } +} + +func TestAutocompleteActivation(t *testing.T) { + t.Parallel() + a := NewAutocomplete() + a.SetCommands(testCommands()) + + assert.True(t, a.Sync("/ne")) + cur, ok := a.Current() + require.True(t, ok) + assert.Equal(t, "new", cur.Name) + + assert.False(t, a.Sync("hello")) // no leading slash + assert.False(t, a.Sync("/new x")) // contains a space + assert.False(t, a.Sync("/zzzzz")) // no matches +} + +func TestAutocompleteNavigation(t *testing.T) { + t.Parallel() + a := NewAutocomplete() + a.SetCommands(testCommands()) + require.True(t, a.Sync("/")) // all commands match + + first, _ := a.Current() + a.MoveDown() + second, _ := a.Current() + assert.NotEqual(t, first.Name, second.Name) + + a.MoveUp() + back, _ := a.Current() + assert.Equal(t, first.Name, back.Name) +} + +func TestAutocompleteRenderWidth(t *testing.T) { + t.Parallel() + a := NewAutocomplete() + a.SetCommands(testCommands()) + require.True(t, a.Sync("/")) + rows := a.Render(60) + assert.NotEmpty(t, rows) + for _, r := range rows { + assert.LessOrEqual(t, DisplayWidth(r), 60) + } +} + +func TestAutocompleteBuiltinsBeforeAgent(t *testing.T) { + t.Parallel() + matches := FilterCommands(testCommands(), "") + // The agent command "plan" must sort after every built-in. + last := matches[len(matches)-1] + assert.Equal(t, "plan", last.Name) + assert.Equal(t, CmdAgent, last.Kind) +} diff --git a/pkg/leantui/ui/boundary_test.go b/pkg/leantui/ui/boundary_test.go new file mode 100644 index 0000000000..f9ff653164 --- /dev/null +++ b/pkg/leantui/ui/boundary_test.go @@ -0,0 +1,42 @@ +package ui_test + +import ( + "go/parser" + "go/token" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUIDoesNotImportRuntimePackages(t *testing.T) { + t.Parallel() + + forbidden := []string{ + "github.com/docker/docker-agent/pkg/runtime", + "github.com/docker/docker-agent/pkg/app", + "github.com/docker/docker-agent/pkg/effort", + "github.com/docker/docker-agent/pkg/session", + "github.com/docker/docker-agent/pkg/sessiontitle", + "github.com/docker/docker-agent/pkg/gitbranch", + "github.com/docker/docker-agent/pkg/tui/messages", + "github.com/docker/docker-agent/pkg/chat", + } + + files, err := filepath.Glob("*.go") + require.NoError(t, err) + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, parser.ImportsOnly) + require.NoError(t, err) + for _, imp := range parsed.Imports { + path := strings.Trim(imp.Path.Value, "\"") + for _, denied := range forbidden { + require.NotEqualf(t, denied, path, "%s imports forbidden runtime package %s", file, path) + } + } + } +} diff --git a/pkg/leantui/ui/commands.go b/pkg/leantui/ui/commands.go new file mode 100644 index 0000000000..da7fd4132a --- /dev/null +++ b/pkg/leantui/ui/commands.go @@ -0,0 +1,40 @@ +package ui + +import ( + "sort" + "strings" +) + +// CommandKind distinguishes built-in lean-TUI commands (handled locally) from +// agent-provided commands and skills (resolved and sent to the agent). +type CommandKind int + +const ( + CmdBuiltin CommandKind = iota + CmdAgent +) + +type Command struct { + Name string + Desc string + Kind CommandKind +} + +// FilterCommands returns the commands whose name has the given prefix, built-in +// commands first, then agent commands, each group alphabetically sorted. +func FilterCommands(all []Command, prefix string) []Command { + prefix = strings.ToLower(prefix) + var out []Command + for _, c := range all { + if strings.HasPrefix(strings.ToLower(c.Name), prefix) { + out = append(out, c) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].Name < out[j].Name + }) + return out +} diff --git a/pkg/leantui/ui/doc.go b/pkg/leantui/ui/doc.go new file mode 100644 index 0000000000..d3ce5cb2dc --- /dev/null +++ b/pkg/leantui/ui/doc.go @@ -0,0 +1,7 @@ +// Package ui contains the lean TUI presentation layer: terminal rendering, +// input widgets, reusable views, and plain view-models. +// +// This package may use the shared TUI rendering vocabulary, but it must not +// directly import docker-agent runtime or driver packages. Runtime events and +// app commands are translated by the parent leantui controller package. +package ui diff --git a/pkg/leantui/editor.go b/pkg/leantui/ui/editor.go similarity index 69% rename from pkg/leantui/editor.go rename to pkg/leantui/ui/editor.go index 64dae53bea..832e52a803 100644 --- a/pkg/leantui/editor.go +++ b/pkg/leantui/ui/editor.go @@ -1,4 +1,4 @@ -package leantui +package ui import "strings" @@ -9,11 +9,11 @@ type rowSpan struct { end int } -// editor is a multi-line text input. The buffer is a flat rune slice with the +// Editor is a multi-line text input. The buffer is a flat rune slice with the // cursor expressed as an index into it; newlines are stored literally so the // same structure handles single-line prompts and pasted multi-line text. All // visual wrapping is derived on demand from a given content width. -type editor struct { +type Editor struct { value []rune cursor int @@ -24,27 +24,27 @@ type editor struct { draft string } -func newEditor(placeholder string) *editor { - return &editor{placeholder: placeholder, histIndex: 0} +func NewEditor(placeholder string) *Editor { + return &Editor{placeholder: placeholder, histIndex: 0} } -func (e *editor) text() string { return string(e.value) } +func (e *Editor) Text() string { return string(e.value) } -func (e *editor) isEmpty() bool { return len(e.value) == 0 } +func (e *Editor) IsEmpty() bool { return len(e.value) == 0 } -func (e *editor) reset() { +func (e *Editor) Reset() { e.value = nil e.cursor = 0 e.histIndex = len(e.history) e.draft = "" } -func (e *editor) setText(s string) { +func (e *Editor) SetText(s string) { e.value = []rune(s) e.cursor = len(e.value) } -func (e *editor) insert(runes []rune) { +func (e *Editor) Insert(runes []rune) { if len(runes) == 0 { return } @@ -64,9 +64,9 @@ func (e *editor) insert(runes []rune) { e.cursor += len(cleaned) } -func (e *editor) insertNewline() { e.insert([]rune{'\n'}) } +func (e *Editor) InsertNewline() { e.Insert([]rune{'\n'}) } -func (e *editor) backspace() { +func (e *Editor) Backspace() { if e.cursor == 0 { return } @@ -74,14 +74,14 @@ func (e *editor) backspace() { e.cursor-- } -func (e *editor) deleteForward() { +func (e *Editor) DeleteForward() { if e.cursor >= len(e.value) { return } e.value = append(e.value[:e.cursor], e.value[e.cursor+1:]...) } -func (e *editor) deleteWordBack() { +func (e *Editor) DeleteWordBack() { if e.cursor == 0 { return } @@ -90,38 +90,38 @@ func (e *editor) deleteWordBack() { e.cursor = start } -func (e *editor) deleteToLineStart() { +func (e *Editor) DeleteToLineStart() { start := lineStart(e.value, e.cursor) e.value = append(e.value[:start], e.value[e.cursor:]...) e.cursor = start } -func (e *editor) deleteToLineEnd() { +func (e *Editor) DeleteToLineEnd() { end := lineEnd(e.value, e.cursor) e.value = append(e.value[:e.cursor], e.value[end:]...) } -func (e *editor) moveLeft() { +func (e *Editor) MoveLeft() { if e.cursor > 0 { e.cursor-- } } -func (e *editor) moveRight() { +func (e *Editor) MoveRight() { if e.cursor < len(e.value) { e.cursor++ } } -func (e *editor) moveWordLeft() { e.cursor = wordStart(e.value, e.cursor) } -func (e *editor) moveWordRight() { e.cursor = wordEnd(e.value, e.cursor) } -func (e *editor) moveLineStart() { e.cursor = lineStart(e.value, e.cursor) } -func (e *editor) moveLineEnd() { e.cursor = lineEnd(e.value, e.cursor) } +func (e *Editor) MoveWordLeft() { e.cursor = wordStart(e.value, e.cursor) } +func (e *Editor) MoveWordRight() { e.cursor = wordEnd(e.value, e.cursor) } +func (e *Editor) MoveLineStart() { e.cursor = lineStart(e.value, e.cursor) } +func (e *Editor) MoveLineEnd() { e.cursor = lineEnd(e.value, e.cursor) } -// up moves the cursor one visual row up, preserving the column. It reports +// Up moves the cursor one visual row up, preserving the column. It reports // false when the cursor is already on the first row, letting the caller fall // back to history navigation. -func (e *editor) up(termWidth int) bool { +func (e *Editor) Up(termWidth int) bool { width := contentWidth(termWidth) rows := e.wrapRows(width) row, col := e.cursorPos(rows) @@ -132,7 +132,7 @@ func (e *editor) up(termWidth int) bool { return true } -func (e *editor) down(termWidth int) bool { +func (e *Editor) Down(termWidth int) bool { width := contentWidth(termWidth) rows := e.wrapRows(width) row, col := e.cursorPos(rows) @@ -143,8 +143,8 @@ func (e *editor) down(termWidth int) bool { return true } -// rememberHistory records a submitted entry and resets the history cursor. -func (e *editor) rememberHistory(s string) { +// RememberHistory records a submitted entry and resets the history cursor. +func (e *Editor) RememberHistory(s string) { s = strings.TrimRight(s, "\n") if s == "" { return @@ -156,59 +156,59 @@ func (e *editor) rememberHistory(s string) { e.draft = "" } -func (e *editor) historyPrev() { +func (e *Editor) HistoryPrev() { if e.histIndex == 0 { return } if e.histIndex == len(e.history) { - e.draft = e.text() + e.draft = e.Text() } e.histIndex-- - e.setText(e.history[e.histIndex]) + e.SetText(e.history[e.histIndex]) } -func (e *editor) historyNext() { +func (e *Editor) HistoryNext() { if e.histIndex >= len(e.history) { return } e.histIndex++ if e.histIndex == len(e.history) { - e.setText(e.draft) + e.SetText(e.draft) return } - e.setText(e.history[e.histIndex]) + e.SetText(e.history[e.histIndex]) } -// layout renders the editor for the given terminal width, returning one styled +// Layout renders the editor for the given terminal width, returning one styled // string per physical row along with the hardware cursor position (row within // the returned slice, column in terminal cells). -func (e *editor) layout(termWidth int) (lines []string, curRow, curCol int) { +func (e *Editor) Layout(termWidth int) (lines []string, curRow, curCol int) { width := contentWidth(termWidth) rows := e.wrapRows(width) if len(e.value) == 0 { - line := stAccent().Render(promptText) + line := StAccent().Render(PromptText) if e.placeholder != "" { - line += stPlaceholder().Render(truncate(e.placeholder, width)) + line += StPlaceholder().Render(Truncate(e.placeholder, width)) } - return []string{line}, 0, promptWidth + return []string{line}, 0, PromptWidth } lines = make([]string, len(rows)) for i, rs := range rows { content := string(e.value[rs.start:rs.end]) if i == 0 { - lines[i] = stAccent().Render(promptText) + content + lines[i] = StAccent().Render(PromptText) + content } else { - lines[i] = continuation + content + lines[i] = Continuation + content } } row, col := e.cursorPos(rows) - return lines, row, col + promptWidth + return lines, row, col + PromptWidth } -func (e *editor) wrapRows(width int) []rowSpan { +func (e *Editor) wrapRows(width int) []rowSpan { if width < 1 { width = 1 } @@ -223,7 +223,7 @@ func (e *editor) wrapRows(width int) []rowSpan { curWidth = 0 continue } - w := runeWidth(r) + w := RuneWidth(r) if curWidth+w > width && curWidth > 0 { rows = append(rows, rowSpan{start, i}) start = i @@ -237,7 +237,7 @@ func (e *editor) wrapRows(width int) []rowSpan { return rows } -func (e *editor) cursorPos(rows []rowSpan) (row, col int) { +func (e *Editor) cursorPos(rows []rowSpan) (row, col int) { row = 0 for i, rs := range rows { if rs.start <= e.cursor { @@ -246,12 +246,12 @@ func (e *editor) cursorPos(rows []rowSpan) (row, col int) { } rs := rows[row] for i := rs.start; i < e.cursor && i < len(e.value); i++ { - col += runeWidth(e.value[i]) + col += RuneWidth(e.value[i]) } return row, col } -func (e *editor) indexAt(rows []rowSpan, row, col int) int { +func (e *Editor) indexAt(rows []rowSpan, row, col int) int { if row < 0 { row = 0 } @@ -261,7 +261,7 @@ func (e *editor) indexAt(rows []rowSpan, row, col int) int { rs := rows[row] w := 0 for i := rs.start; i < rs.end; i++ { - rw := runeWidth(e.value[i]) + rw := RuneWidth(e.value[i]) if w+rw > col { return i } @@ -271,7 +271,7 @@ func (e *editor) indexAt(rows []rowSpan, row, col int) int { } func contentWidth(termWidth int) int { - w := termWidth - promptWidth + w := termWidth - PromptWidth if w < 1 { return 1 } diff --git a/pkg/leantui/ui/editor_test.go b/pkg/leantui/ui/editor_test.go new file mode 100644 index 0000000000..4aac33c3de --- /dev/null +++ b/pkg/leantui/ui/editor_test.go @@ -0,0 +1,134 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEditorInsertAndText(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.Insert([]rune("hello")) + assert.Equal(t, "hello", e.Text()) + assert.Equal(t, 5, e.cursor) + + e.MoveLeft() + e.Insert([]rune("X")) + assert.Equal(t, "hellXo", e.Text()) +} + +func TestEditorInsertStripsCarriageReturns(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.Insert([]rune("a\r\nb")) + assert.Equal(t, "a\nb", e.Text()) +} + +func TestEditorBackspaceAndDelete(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.SetText("abc") + e.Backspace() + assert.Equal(t, "ab", e.Text()) + + e.MoveLineStart() + e.DeleteForward() + assert.Equal(t, "b", e.Text()) +} + +func TestEditorWordOps(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.SetText("foo bar baz") + e.MoveWordLeft() + assert.Equal(t, 8, e.cursor) + e.MoveWordLeft() + assert.Equal(t, 4, e.cursor) + + e.MoveLineStart() + e.MoveWordRight() + assert.Equal(t, 3, e.cursor) + + e.MoveLineEnd() + e.DeleteWordBack() + assert.Equal(t, "foo bar ", e.Text()) +} + +func TestEditorLayoutSingleLine(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.SetText("hello") + lines, row, col := e.Layout(20) + require.Len(t, lines, 1) + assert.Equal(t, 0, row) + assert.Equal(t, PromptWidth+5, col) + assert.LessOrEqual(t, DisplayWidth(lines[0]), 20) +} + +func TestEditorLayoutWrapping(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.SetText(strings.Repeat("a", 25)) + lines, row, col := e.Layout(12) // content width 10 + require.Len(t, lines, 3) + assert.Equal(t, 2, row) + assert.Equal(t, PromptWidth+5, col) + for _, l := range lines { + assert.LessOrEqual(t, DisplayWidth(l), 12) + } +} + +func TestEditorLayoutPlaceholder(t *testing.T) { + t.Parallel() + e := NewEditor("type here") + lines, row, col := e.Layout(40) + require.Len(t, lines, 1) + assert.Equal(t, 0, row) + assert.Equal(t, PromptWidth, col) + assert.Contains(t, lines[0], "type here") +} + +func TestEditorVerticalMovement(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.SetText("line1\nline2\nline3") + // cursor at end (line3) + require.True(t, e.Up(40)) + _, _, col := e.Layout(40) + assert.Equal(t, PromptWidth+5, col) // preserved column on "line2" + + require.True(t, e.Up(40)) + // now on the first row; up should fail and let history take over + assert.False(t, e.Up(40)) +} + +func TestEditorHistory(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.RememberHistory("first") + e.RememberHistory("second") + + e.SetText("draft") + e.HistoryPrev() + assert.Equal(t, "second", e.Text()) + e.HistoryPrev() + assert.Equal(t, "first", e.Text()) + e.HistoryPrev() + assert.Equal(t, "first", e.Text()) // clamped + + e.HistoryNext() + assert.Equal(t, "second", e.Text()) + e.HistoryNext() + assert.Equal(t, "draft", e.Text()) // restored draft +} + +func TestEditorHistoryDeduplicates(t *testing.T) { + t.Parallel() + e := NewEditor("") + e.RememberHistory("same") + e.RememberHistory("same") + assert.Len(t, e.history, 1) +} diff --git a/pkg/leantui/ui/image.go b/pkg/leantui/ui/image.go new file mode 100644 index 0000000000..ec56dbaf5b --- /dev/null +++ b/pkg/leantui/ui/image.go @@ -0,0 +1,67 @@ +package ui + +import ( + "encoding/base64" + "fmt" + "strings" +) + +const ( + kittyMaxChunkSize = 4096 + kittyMaxImageCols = 80 + kittyMaxImageRows = 30 +) + +// InlineImage is a PNG image prepared for inline kitty-protocol rendering. +type InlineImage struct { + Name string + MIME string + PNGData []byte + Width int + Height int +} + +// RenderInlineImage renders an inline image label and kitty image sequence. +func RenderInlineImage(img InlineImage, width int) []string { + if len(img.PNGData) == 0 || img.Width <= 0 || img.Height <= 0 { + return nil + } + + cols := min(max(width-4, 1), kittyMaxImageCols) + rows := max(1, (img.Height*cols+img.Width-1)/img.Width/2) + rows = min(rows, kittyMaxImageRows) + + label := "image" + if img.Name != "" { + label = img.Name + } + if img.MIME != "" { + label += " (" + img.MIME + ")" + } + + out := []string{" " + StMuted().Render("🖼 "+label)} + out = append(out, " "+KittyImageSequence(img.PNGData, cols, rows)) + for range rows - 1 { + out = append(out, "") + } + return out +} + +// KittyImageSequence encodes PNG data as a kitty graphics escape sequence. +func KittyImageSequence(pngData []byte, cols, rows int) string { + encoded := base64.StdEncoding.EncodeToString(pngData) + var b strings.Builder + for offset := 0; offset < len(encoded); offset += kittyMaxChunkSize { + end := min(offset+kittyMaxChunkSize, len(encoded)) + more := 0 + if end < len(encoded) { + more = 1 + } + if offset == 0 { + fmt.Fprintf(&b, "\x1b_Ga=T,t=d,f=100,q=2,C=1,c=%d,r=%d,m=%d;%s\x1b\\", cols, rows, more, encoded[offset:end]) + } else { + fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, encoded[offset:end]) + } + } + return b.String() +} diff --git a/pkg/leantui/ui/image_test.go b/pkg/leantui/ui/image_test.go new file mode 100644 index 0000000000..0c96494f80 --- /dev/null +++ b/pkg/leantui/ui/image_test.go @@ -0,0 +1,59 @@ +package ui + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "image/png" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderInlineImageIncludesKittyImageSequence(t *testing.T) { + t.Parallel() + img := InlineImage{ + Name: "sample.png", + MIME: "image/png", + PNGData: testPNGData(t), + Width: 2, + Height: 1, + } + + lines := RenderInlineImage(img, 80) + joined := strings.Join(lines, "\n") + + assert.Contains(t, joined, "sample.png") + assert.Contains(t, joined, "image/png") + assert.Contains(t, joined, "\x1b_G") + assert.Contains(t, joined, "a=T") + assert.Contains(t, joined, "f=100") + assert.Contains(t, joined, "🖼") +} + +func TestKittyImageSequenceChunksLargePayload(t *testing.T) { + t.Parallel() + data := bytes.Repeat([]byte("x"), 4096) + + seq := KittyImageSequence(data, 10, 5) + encoded := base64.StdEncoding.EncodeToString(data) + + assert.Contains(t, seq, "m=1") + assert.Contains(t, seq, "\x1b_Gm=0;") + assert.Contains(t, seq, encoded[:100]) +} + +func testPNGData(t *testing.T) []byte { + t.Helper() + + img := image.NewRGBA(image.Rect(0, 0, 2, 1)) + img.Set(0, 0, color.RGBA{R: 255, A: 255}) + img.Set(1, 0, color.RGBA{B: 255, A: 255}) + + var buf bytes.Buffer + require.NoError(t, png.Encode(&buf, img)) + return buf.Bytes() +} diff --git a/pkg/leantui/keys.go b/pkg/leantui/ui/keys.go similarity index 54% rename from pkg/leantui/keys.go rename to pkg/leantui/ui/keys.go index 32bb781779..e9c2c18765 100644 --- a/pkg/leantui/keys.go +++ b/pkg/leantui/ui/keys.go @@ -1,4 +1,4 @@ -package leantui +package ui import ( "bytes" @@ -7,40 +7,40 @@ import ( "unicode/utf8" ) -type keyType int +type KeyType int const ( - keyNone keyType = iota - keyRune - keyPaste - keyEnter - keyAltEnter // insert a literal newline (multi-line input) - keyTab - keyShiftTab - keyBackspace - keyDelete - keyUp - keyDown - keyLeft - keyRight - keyWordLeft - keyWordRight - keyHome - keyEnd - keyEsc - keyCtrlC - keyCtrlD - keyCtrlU // delete to start of line - keyCtrlK // delete to end of line - keyCtrlW // delete word backwards - keyCtrlL // redraw + KeyNone KeyType = iota + KeyRune + KeyPaste + KeyEnter + KeyAltEnter // insert a literal newline (multi-line input) + KeyTab + KeyShiftTab + KeyBackspace + KeyDelete + KeyUp + KeyDown + KeyLeft + KeyRight + KeyWordLeft + KeyWordRight + KeyHome + KeyEnd + KeyEsc + KeyCtrlC + KeyCtrlD + KeyCtrlU // delete to start of line + KeyCtrlK // delete to end of line + KeyCtrlW // delete word backwards + KeyCtrlL // redraw ) -// key is a single decoded keyboard event. For keyRune and keyPaste the decoded +// Key is a single decoded keyboard event. For KeyRune and KeyPaste the decoded // characters are carried in runes; every other key type carries no payload. -type key struct { - typ keyType - runes []rune +type Key struct { + Typ KeyType + Runes []rune } var ( @@ -48,15 +48,15 @@ var ( pasteEnd = []byte("\x1b[201~") ) -// inputParser turns raw terminal bytes into key events. It is stateful only to +// InputParser turns raw terminal bytes into key events. It is stateful only to // reassemble bracketed-paste payloads, which may span several reads. -type inputParser struct { +type InputParser struct { inPaste bool paste []rune } -func (p *inputParser) feed(b []byte) []key { - var out []key +func (p *InputParser) Feed(b []byte) []Key { + var out []Key for len(b) > 0 { if p.inPaste { idx := bytes.Index(b, pasteEnd) @@ -65,7 +65,7 @@ func (p *inputParser) feed(b []byte) []key { return out } p.paste = append(p.paste, []rune(string(b[:idx]))...) - out = append(out, key{typ: keyPaste, runes: p.paste}) + out = append(out, Key{Typ: KeyPaste, Runes: p.paste}) p.paste = nil p.inPaste = false b = b[idx+len(pasteEnd):] @@ -87,54 +87,54 @@ func (p *inputParser) feed(b []byte) []key { // parseChunk decodes a run of bytes that contains no bracketed-paste markers. // Escape sequences are assumed to arrive atomically within a single read, so a // trailing lone ESC is reported as the Escape key. -func parseChunk(b []byte) []key { - var out []key +func parseChunk(b []byte) []Key { + var out []Key for i := 0; i < len(b); { c := b[i] switch { case c == 0x1b: if i == len(b)-1 { - out = append(out, key{typ: keyEsc}) + out = append(out, Key{Typ: KeyEsc}) i++ continue } n, k := parseEscape(b[i:]) - if k.typ != keyNone { + if k.Typ != KeyNone { out = append(out, k) } i += n case c == '\r' || c == '\n': - out = append(out, key{typ: keyEnter}) + out = append(out, Key{Typ: KeyEnter}) i++ case c == '\t': - out = append(out, key{typ: keyTab}) + out = append(out, Key{Typ: KeyTab}) i++ case c == 0x7f, c == 0x08: - out = append(out, key{typ: keyBackspace}) + out = append(out, Key{Typ: KeyBackspace}) i++ case c == 0x03: - out = append(out, key{typ: keyCtrlC}) + out = append(out, Key{Typ: KeyCtrlC}) i++ case c == 0x04: - out = append(out, key{typ: keyCtrlD}) + out = append(out, Key{Typ: KeyCtrlD}) i++ case c == 0x01: - out = append(out, key{typ: keyHome}) + out = append(out, Key{Typ: KeyHome}) i++ case c == 0x05: - out = append(out, key{typ: keyEnd}) + out = append(out, Key{Typ: KeyEnd}) i++ case c == 0x15: - out = append(out, key{typ: keyCtrlU}) + out = append(out, Key{Typ: KeyCtrlU}) i++ case c == 0x0b: - out = append(out, key{typ: keyCtrlK}) + out = append(out, Key{Typ: KeyCtrlK}) i++ case c == 0x17: - out = append(out, key{typ: keyCtrlW}) + out = append(out, Key{Typ: KeyCtrlW}) i++ case c == 0x0c: - out = append(out, key{typ: keyCtrlL}) + out = append(out, Key{Typ: KeyCtrlL}) i++ case c < 0x20: i++ // other control bytes are ignored @@ -144,16 +144,16 @@ func parseChunk(b []byte) []key { i++ continue } - out = append(out, key{typ: keyRune, runes: []rune{r}}) + out = append(out, Key{Typ: KeyRune, Runes: []rune{r}}) i += size } } return out } -func parseEscape(b []byte) (int, key) { +func parseEscape(b []byte) (int, Key) { if len(b) < 2 { - return 1, key{typ: keyEsc} + return 1, Key{Typ: KeyEsc} } switch b[1] { case '[': @@ -162,28 +162,28 @@ func parseEscape(b []byte) (int, key) { if len(b) >= 3 { switch b[2] { case 'A': - return 3, key{typ: keyUp} + return 3, Key{Typ: KeyUp} case 'B': - return 3, key{typ: keyDown} + return 3, Key{Typ: KeyDown} case 'C': - return 3, key{typ: keyRight} + return 3, Key{Typ: KeyRight} case 'D': - return 3, key{typ: keyLeft} + return 3, Key{Typ: KeyLeft} case 'H': - return 3, key{typ: keyHome} + return 3, Key{Typ: KeyHome} case 'F': - return 3, key{typ: keyEnd} + return 3, Key{Typ: KeyEnd} } } - return 2, key{typ: keyEsc} + return 2, Key{Typ: KeyEsc} case 'b': - return 2, key{typ: keyWordLeft} + return 2, Key{Typ: KeyWordLeft} case 'f': - return 2, key{typ: keyWordRight} + return 2, Key{Typ: KeyWordRight} case 0x7f, 0x08: - return 2, key{typ: keyCtrlW} // Alt+Backspace deletes a word + return 2, Key{Typ: KeyCtrlW} // Alt+Backspace deletes a word case '\r', '\n': - return 2, key{typ: keyAltEnter} + return 2, Key{Typ: KeyAltEnter} default: // Unhandled Alt+ combinations are swallowed so they do not insert // stray characters into the input. @@ -191,17 +191,17 @@ func parseEscape(b []byte) (int, key) { if size < 1 { size = 1 } - return 1 + size, key{typ: keyNone} + return 1 + size, Key{Typ: KeyNone} } } -func parseCSI(b []byte) (int, key) { +func parseCSI(b []byte) (int, Key) { j := 2 for j < len(b) && (b[j] < 0x40 || b[j] > 0x7e) { j++ } if j >= len(b) { - return len(b), key{typ: keyNone} // incomplete sequence + return len(b), Key{Typ: KeyNone} // incomplete sequence } final := b[j] params := string(b[2:j]) @@ -225,34 +225,34 @@ func parseCSI(b []byte) (int, key) { switch final { case 'A': - return consumed, key{typ: keyUp} + return consumed, Key{Typ: KeyUp} case 'B': - return consumed, key{typ: keyDown} + return consumed, Key{Typ: KeyDown} case 'C': if wordMod() { - return consumed, key{typ: keyWordRight} + return consumed, Key{Typ: KeyWordRight} } - return consumed, key{typ: keyRight} + return consumed, Key{Typ: KeyRight} case 'D': if wordMod() { - return consumed, key{typ: keyWordLeft} + return consumed, Key{Typ: KeyWordLeft} } - return consumed, key{typ: keyLeft} + return consumed, Key{Typ: KeyLeft} case 'H': - return consumed, key{typ: keyHome} + return consumed, Key{Typ: KeyHome} case 'F': - return consumed, key{typ: keyEnd} + return consumed, Key{Typ: KeyEnd} case 'Z': - return consumed, key{typ: keyShiftTab} + return consumed, Key{Typ: KeyShiftTab} case '~': switch n, _ := strconv.Atoi(strings.SplitN(params, ";", 2)[0]); n { case 1, 7: - return consumed, key{typ: keyHome} + return consumed, Key{Typ: KeyHome} case 4, 8: - return consumed, key{typ: keyEnd} + return consumed, Key{Typ: KeyEnd} case 3: - return consumed, key{typ: keyDelete} + return consumed, Key{Typ: KeyDelete} } } - return consumed, key{typ: keyNone} + return consumed, Key{Typ: KeyNone} } diff --git a/pkg/leantui/ui/keys_test.go b/pkg/leantui/ui/keys_test.go new file mode 100644 index 0000000000..1bf1da50bf --- /dev/null +++ b/pkg/leantui/ui/keys_test.go @@ -0,0 +1,92 @@ +package ui + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func singleKey(t *testing.T, b string) Key { + t.Helper() + p := &InputParser{} + keys := p.Feed([]byte(b)) + require.Len(t, keys, 1) + return keys[0] +} + +func TestParseSimplekeys(t *testing.T) { + t.Parallel() + assert.Equal(t, KeyEnter, singleKey(t, "\r").Typ) + assert.Equal(t, KeyEnter, singleKey(t, "\n").Typ) + assert.Equal(t, KeyTab, singleKey(t, "\t").Typ) + assert.Equal(t, KeyBackspace, singleKey(t, "\x7f").Typ) + assert.Equal(t, KeyBackspace, singleKey(t, "\x08").Typ) + assert.Equal(t, KeyCtrlC, singleKey(t, "\x03").Typ) + assert.Equal(t, KeyCtrlD, singleKey(t, "\x04").Typ) + assert.Equal(t, KeyHome, singleKey(t, "\x01").Typ) + assert.Equal(t, KeyEnd, singleKey(t, "\x05").Typ) + assert.Equal(t, KeyCtrlW, singleKey(t, "\x17").Typ) +} + +func TestParseRunes(t *testing.T) { + t.Parallel() + k := singleKey(t, "a") + assert.Equal(t, KeyRune, k.Typ) + assert.Equal(t, []rune{'a'}, k.Runes) + + k = singleKey(t, "é") + assert.Equal(t, KeyRune, k.Typ) + assert.Equal(t, []rune{'é'}, k.Runes) +} + +func TestParseEscapeSequences(t *testing.T) { + t.Parallel() + assert.Equal(t, KeyUp, singleKey(t, "\x1b[A").Typ) + assert.Equal(t, KeyDown, singleKey(t, "\x1b[B").Typ) + assert.Equal(t, KeyRight, singleKey(t, "\x1b[C").Typ) + assert.Equal(t, KeyLeft, singleKey(t, "\x1b[D").Typ) + assert.Equal(t, KeyUp, singleKey(t, "\x1bOA").Typ) + assert.Equal(t, KeyWordRight, singleKey(t, "\x1b[1;5C").Typ) + assert.Equal(t, KeyWordLeft, singleKey(t, "\x1b[1;5D").Typ) + assert.Equal(t, KeyDelete, singleKey(t, "\x1b[3~").Typ) + assert.Equal(t, KeyHome, singleKey(t, "\x1b[H").Typ) + assert.Equal(t, KeyEnd, singleKey(t, "\x1b[F").Typ) + assert.Equal(t, KeyShiftTab, singleKey(t, "\x1b[Z").Typ) + assert.Equal(t, KeyWordLeft, singleKey(t, "\x1bb").Typ) + assert.Equal(t, KeyWordRight, singleKey(t, "\x1bf").Typ) + assert.Equal(t, KeyAltEnter, singleKey(t, "\x1b\r").Typ) +} + +func TestParseLoneEscape(t *testing.T) { + t.Parallel() + assert.Equal(t, KeyEsc, singleKey(t, "\x1b").Typ) +} + +func TestParseBracketedPaste(t *testing.T) { + t.Parallel() + k := singleKey(t, "\x1b[200~hello world\x1b[201~") + assert.Equal(t, KeyPaste, k.Typ) + assert.Equal(t, "hello world", string(k.Runes)) +} + +func TestParseBracketedPasteAcrossReads(t *testing.T) { + t.Parallel() + p := &InputParser{} + assert.Empty(t, p.Feed([]byte("\x1b[200~hel"))) + assert.Empty(t, p.Feed([]byte("lo"))) + keys := p.Feed([]byte(" there\x1b[201~")) + require.Len(t, keys, 1) + assert.Equal(t, KeyPaste, keys[0].Typ) + assert.Equal(t, "hello there", string(keys[0].Runes)) +} + +func TestParseMixedRun(t *testing.T) { + t.Parallel() + p := &InputParser{} + keys := p.Feed([]byte("hi\r")) + require.Len(t, keys, 3) + assert.Equal(t, KeyRune, keys[0].Typ) + assert.Equal(t, KeyRune, keys[1].Typ) + assert.Equal(t, KeyEnter, keys[2].Typ) +} diff --git a/pkg/leantui/render.go b/pkg/leantui/ui/render.go similarity index 52% rename from pkg/leantui/render.go rename to pkg/leantui/ui/render.go index 2eae5c9661..e6957e2816 100644 --- a/pkg/leantui/render.go +++ b/pkg/leantui/ui/render.go @@ -1,4 +1,4 @@ -package leantui +package ui import ( "strings" @@ -8,49 +8,49 @@ import ( "github.com/docker/docker-agent/pkg/tui/components/markdown" ) -// renderUserLines renders a submitted user message as committed scrollback, +// RenderUserLines renders a submitted user message as committed scrollback, // echoing it with the same prompt marker used by the input box. -func renderUserLines(text string, width int) []string { - return renderUserLinesWith(text, width, stAccent(), stPrimary()) +func RenderUserLines(text string, width int) []string { + return RenderUserLinesWith(text, width, StAccent(), StPrimary()) } -func renderPendingUserLines(text string, width int) []string { - muted := stMuted() - return renderUserLinesWith(text, width, muted, muted) +func RenderPendingUserLines(text string, width int) []string { + muted := StMuted() + return RenderUserLinesWith(text, width, muted, muted) } -func renderUserLinesWith(text string, width int, promptStyle, textStyle lipgloss.Style) []string { +func RenderUserLinesWith(text string, width int, promptStyle, textStyle lipgloss.Style) []string { text = strings.TrimRight(text, "\n") - wrapped := wrapANSI(text, width-promptWidth) + wrapped := WrapANSI(text, width-PromptWidth) out := make([]string, 0, len(wrapped)) for i, line := range wrapped { - prefix := promptStyle.Render(promptText) + prefix := promptStyle.Render(PromptText) if i > 0 { - prefix = continuation + prefix = Continuation } out = append(out, prefix+textStyle.Render(line)) } return out } -// renderReasoningLines renders agent reasoning as dimmed italic text. -func renderReasoningLines(text string, width int) []string { +// RenderReasoningLines renders agent reasoning as dimmed italic text. +func RenderReasoningLines(text string, width int) []string { text = strings.TrimSpace(text) if text == "" { return nil } - style := stReasoning() + style := StReasoning() var out []string - for _, line := range wrapANSI(text, width-2) { + for _, line := range WrapANSI(text, width-2) { out = append(out, " "+style.Render(line)) } return out } -// renderAssistantLines renders an assistant message as markdown. Each returned +// RenderAssistantLines renders an assistant message as markdown. Each returned // line is guaranteed to fit within width so the differential renderer's row // accounting stays correct. -func renderAssistantLines(text string, width int) []string { +func RenderAssistantLines(text string, width int) []string { text = strings.TrimRight(text, "\n") if strings.TrimSpace(text) == "" { return nil @@ -58,13 +58,13 @@ func renderAssistantLines(text string, width int) []string { rendered, err := markdown.NewRenderer(width).Render(text) if err != nil { - return wrapANSI(text, width) + return WrapANSI(text, width) } var out []string for line := range strings.SplitSeq(strings.Trim(rendered, "\n"), "\n") { - if displayWidth(line) > width { - out = append(out, wrapANSI(line, width)...) + if DisplayWidth(line) > width { + out = append(out, WrapANSI(line, width)...) continue } out = append(out, line) @@ -72,13 +72,13 @@ func renderAssistantLines(text string, width int) []string { return out } -func renderNoticeLines(prefix, text string, width int, style lipgloss.Style) []string { - wrapped := wrapANSI(text, width-displayWidth(prefix)) +func RenderNoticeLines(prefix, text string, width int, style lipgloss.Style) []string { + wrapped := WrapANSI(text, width-DisplayWidth(prefix)) out := make([]string, 0, len(wrapped)) for i, line := range wrapped { p := prefix if i > 0 { - p = strings.Repeat(" ", displayWidth(prefix)) + p = strings.Repeat(" ", DisplayWidth(prefix)) } out = append(out, style.Render(p+line)) } diff --git a/pkg/leantui/renderer.go b/pkg/leantui/ui/renderer.go similarity index 86% rename from pkg/leantui/renderer.go rename to pkg/leantui/ui/renderer.go index eb980df964..b41ed4549b 100644 --- a/pkg/leantui/renderer.go +++ b/pkg/leantui/ui/renderer.go @@ -1,4 +1,4 @@ -package leantui +package ui import ( "bufio" @@ -18,7 +18,7 @@ const ( seqDisableBracketedPaste = "\x1b[?2004l" ) -// renderer draws the whole conversation as a single, growing array of lines and +// Renderer draws the whole conversation as a single, growing array of lines and // keeps it in sync with the terminal using minimal, differential updates. It // writes to the normal screen buffer (never the alternate screen): content that // scrolls off the top becomes immutable terminal scrollback, exactly like a @@ -29,7 +29,7 @@ const ( // produces the full set of lines; the renderer diffs them against the previous // frame and rewrites only the changed rows within the visible viewport, letting // the terminal scroll naturally when content is appended past the bottom. -type renderer struct { +type Renderer struct { w *bufio.Writer width int @@ -42,26 +42,26 @@ type renderer struct { needsRedraw bool } -func newRenderer(w *bufio.Writer, width, height int) *renderer { - return &renderer{w: w, width: width, height: height} +func NewRenderer(w *bufio.Writer, width, height int) *Renderer { + return &Renderer{w: w, width: width, height: height} } -// setSize records a new terminal size and forces a clean repaint on the next +// SetSize records a new terminal size and forces a clean repaint on the next // frame, since wrapping and the viewport both change with the dimensions. -func (r *renderer) setSize(width, height int) { +func (r *Renderer) SetSize(width, height int) { r.width = width r.height = height r.needsRedraw = true } -// repaint forces the next frame to clear the screen and redraw from scratch. -func (r *renderer) repaint() { +// Repaint forces the next frame to clear the screen and redraw from scratch. +func (r *Renderer) Repaint() { r.needsRedraw = true } -// frame reconciles the screen with newLines and places the hardware cursor at +// Frame reconciles the screen with newLines and places the hardware cursor at // (cursorLine, cursorCol), where cursorLine is an index into newLines. -func (r *renderer) frame(newLines []string, cursorLine, cursorCol int) { +func (r *Renderer) Frame(newLines []string, cursorLine, cursorCol int) { if len(newLines) == 0 { newLines = []string{""} } @@ -144,7 +144,7 @@ func (r *renderer) frame(newLines []string, cursorLine, cursorCol int) { // fullRedraw repaints every line. When wipe is set it also clears the screen // and scrollback first (used on resize); otherwise it assumes a clean line and // streams the content out, letting the terminal scroll as needed. -func (r *renderer) fullRedraw(newLines []string, cursorLine, cursorCol int, wipe bool) { +func (r *Renderer) fullRedraw(newLines []string, cursorLine, cursorCol int, wipe bool) { var b strings.Builder b.WriteString(seqSyncStart) b.WriteString(seqHideCursor) @@ -173,10 +173,10 @@ func (r *renderer) fullRedraw(newLines []string, cursorLine, cursorCol int, wipe r.prev = newLines } -// eraseBelow drops everything from buffer row `line` downward (the interactive +// EraseBelow drops everything from buffer row `line` downward (the interactive // chrome), leaving the cursor on that now-blank row so the shell prompt returns // directly beneath the conversation. Used on exit. -func (r *renderer) eraseBelow(line int) { +func (r *Renderer) EraseBelow(line int) { lo := r.viewportTop hi := r.viewportTop + r.height - 1 if line < lo { @@ -200,7 +200,7 @@ func (r *renderer) eraseBelow(line int) { // moveRow emits a vertical cursor move from buffer row "from" to "to". Both // rows are assumed to be within the visible viewport. -func (r *renderer) moveRow(b *strings.Builder, from, to int) { +func (r *Renderer) moveRow(b *strings.Builder, from, to int) { switch d := to - from; { case d > 0: b.WriteString(ansi.CursorDown(d)) @@ -211,7 +211,7 @@ func (r *renderer) moveRow(b *strings.Builder, from, to int) { // moveCursor positions the hardware cursor at (line, col), clamping both the // current and target rows to the visible viewport, and returns the row it ended on. -func (r *renderer) moveCursor(b *strings.Builder, from, line, col int) int { +func (r *Renderer) moveCursor(b *strings.Builder, from, line, col int) int { lo := r.viewportTop hi := r.viewportTop + r.height - 1 if from < lo { @@ -234,7 +234,11 @@ func (r *renderer) moveCursor(b *strings.Builder, from, line, col int) int { return line } -func (r *renderer) write(s string) { +func (r *Renderer) ViewportTop() int { + return r.viewportTop +} + +func (r *Renderer) write(s string) { _, _ = r.w.WriteString(s) _ = r.w.Flush() } diff --git a/pkg/leantui/renderer_test.go b/pkg/leantui/ui/renderer_test.go similarity index 78% rename from pkg/leantui/renderer_test.go rename to pkg/leantui/ui/renderer_test.go index c7805a90cd..1a5dc8d944 100644 --- a/pkg/leantui/renderer_test.go +++ b/pkg/leantui/ui/renderer_test.go @@ -1,4 +1,4 @@ -package leantui +package ui import ( "bufio" @@ -10,16 +10,16 @@ import ( "github.com/stretchr/testify/assert" ) -func newTestRenderer(height int) (*renderer, *bytes.Buffer) { +func newTestRenderer(height int) (*Renderer, *bytes.Buffer) { var buf bytes.Buffer w := bufio.NewWriter(&buf) - return newRenderer(w, 80, height), &buf + return NewRenderer(w, 80, height), &buf } func TestRendererFirstFrame(t *testing.T) { t.Parallel() r, buf := newTestRenderer(24) - r.frame([]string{"alpha", "beta", "input"}, 2, 0) + r.Frame([]string{"alpha", "beta", "input"}, 2, 0) out := buf.String() assert.Contains(t, out, seqSyncStart) @@ -32,11 +32,11 @@ func TestRendererFirstFrame(t *testing.T) { func TestRendererInPlaceUpdate(t *testing.T) { t.Parallel() r, buf := newTestRenderer(24) - r.frame([]string{"alpha", "beta", "in"}, 2, 2) + r.Frame([]string{"alpha", "beta", "in"}, 2, 2) buf.Reset() // Only the last line changes; the differ should rewrite just that row. - r.frame([]string{"alpha", "beta", "input"}, 2, 5) + r.Frame([]string{"alpha", "beta", "input"}, 2, 5) out := buf.String() assert.Contains(t, out, "input") assert.NotContains(t, out, "alpha") // unchanged rows are not rewritten @@ -45,10 +45,10 @@ func TestRendererInPlaceUpdate(t *testing.T) { func TestRendererAppendScrolls(t *testing.T) { t.Parallel() r, buf := newTestRenderer(3) // tiny viewport forces scrolling - r.frame([]string{"l1", "l2", "input"}, 2, 0) + r.Frame([]string{"l1", "l2", "input"}, 2, 0) buf.Reset() - r.frame([]string{"l1", "l2", "l3", "l4", "input"}, 4, 0) + r.Frame([]string{"l1", "l2", "l3", "l4", "input"}, 4, 0) out := buf.String() // Appending past the bottom scrolls via CRLF and the viewport tracks the tail. assert.Contains(t, out, "\r\n") @@ -58,10 +58,10 @@ func TestRendererAppendScrolls(t *testing.T) { func TestRendererShrinkClearsTrailing(t *testing.T) { t.Parallel() r, buf := newTestRenderer(24) - r.frame([]string{"a", "b", "c", "d"}, 3, 0) + r.Frame([]string{"a", "b", "c", "d"}, 3, 0) buf.Reset() - r.frame([]string{"a", "b"}, 1, 0) + r.Frame([]string{"a", "b"}, 1, 0) out := buf.String() assert.Contains(t, out, seqEraseLine) assert.Equal(t, []string{"a", "b"}, r.prev) @@ -70,10 +70,10 @@ func TestRendererShrinkClearsTrailing(t *testing.T) { func TestRendererNoChangeOnlyMovesCursor(t *testing.T) { t.Parallel() r, buf := newTestRenderer(24) - r.frame([]string{"a", "b", "c"}, 0, 0) + r.Frame([]string{"a", "b", "c"}, 0, 0) buf.Reset() - r.frame([]string{"a", "b", "c"}, 2, 0) + r.Frame([]string{"a", "b", "c"}, 2, 0) out := buf.String() assert.NotContains(t, out, seqEraseLine) // nothing redrawn assert.Contains(t, out, seqSyncStart) @@ -95,11 +95,11 @@ func TestRendererMoveCursorClampsCurrentRow(t *testing.T) { func TestRendererResizeForcesFullRedraw(t *testing.T) { t.Parallel() r, buf := newTestRenderer(24) - r.frame([]string{"a", "b", "c"}, 0, 0) + r.Frame([]string{"a", "b", "c"}, 0, 0) buf.Reset() - r.setSize(100, 30) - r.frame([]string{"a", "b", "c"}, 0, 0) + r.SetSize(100, 30) + r.Frame([]string{"a", "b", "c"}, 0, 0) assert.Contains(t, buf.String(), seqClearScreen) } @@ -121,9 +121,9 @@ func TestDiffLines(t *testing.T) { func TestRendererEraseBelow(t *testing.T) { t.Parallel() r, buf := newTestRenderer(24) - r.frame([]string{"msg1", "msg2", "input", "footer"}, 2, 0) + r.Frame([]string{"msg1", "msg2", "input", "footer"}, 2, 0) buf.Reset() - r.eraseBelow(2) // drop the input/footer chrome + r.EraseBelow(2) // drop the input/footer chrome out := buf.String() assert.Contains(t, out, seqShowCursor) assert.Equal(t, 2, r.cursorRow) diff --git a/pkg/leantui/ui/screen.go b/pkg/leantui/ui/screen.go new file mode 100644 index 0000000000..91abe1d84e --- /dev/null +++ b/pkg/leantui/ui/screen.go @@ -0,0 +1,61 @@ +package ui + +import "github.com/docker/docker-agent/pkg/tui/service" + +// Screen aggregates the lean TUI presentation models and lays out a full frame. +type Screen struct { + Transcript *Transcript + Editor *Editor + Autocomplete *Autocomplete + Status StatusModel + Confirm *ConfirmModel +} + +func NewScreen(workingDir, branch, editorPlaceholder string) *Screen { + return &Screen{ + Transcript: NewTranscript(), + Editor: NewEditor(editorPlaceholder), + Autocomplete: NewAutocomplete(), + Status: StatusModel{WorkingDir: workingDir, Branch: branch}, + } +} + +// Frame produces the full terminal frame and cursor position. +func (s *Screen) Frame(width, _, spinnerFrame int, busy bool, sessionState service.SessionStateReader, pendingUsers []PendingUserMessage) (lines []string, cursorLine, cursorCol int) { + lines = s.Transcript.Lines(width, spinnerFrame, busy, sessionState, pendingUsers) + + lines = append(lines, s.Autocomplete.Render(width)...) + + inputStart := len(lines) + if s.Confirm != nil { + confirmLines := s.Confirm.Render(width) + lines = append(lines, confirmLines...) + cursorLine = inputStart + max(len(confirmLines)-1, 0) + if len(confirmLines) > 0 { + cursorCol = min(DisplayWidth(confirmLines[len(confirmLines)-1]), max(width-1, 0)) + } + } else { + editorLines, row, col := s.Editor.Layout(width) + lines = append(lines, editorLines...) + cursorLine = inputStart + row + cursorCol = col + } + + lines = append(lines, "") + lines = append(lines, RenderStatus(s.Status, width)...) + + return lines, cursorLine, cursorCol +} + +// ConfirmModel holds a pending tool-approval prompt. +type ConfirmModel struct { + Tool string + View ToolView +} + +func (c *ConfirmModel) Render(width int) []string { + lines := []string{Truncate(StWarning().Render("● Approve tool call"), width)} + lines = append(lines, RenderTool(c.View, width)...) + lines = append(lines, Truncate(StMuted().Render("[y] yes [a] always this tool [s] whole session [n] no"), width)) + return lines +} diff --git a/pkg/leantui/ui/status.go b/pkg/leantui/ui/status.go new file mode 100644 index 0000000000..b85e121133 --- /dev/null +++ b/pkg/leantui/ui/status.go @@ -0,0 +1,132 @@ +package ui + +import ( + "fmt" + "strconv" + "strings" + + pathx "github.com/docker/docker-agent/pkg/path" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" +) + +// StatusModel is the snapshot of run state shown in the footer. +type StatusModel struct { + WorkingDir string + Branch string + + Agent string + Model string + Thinking string + + ContextLength int64 + ContextLimit int64 + Tokens int64 // input + output tokens used so far + Cost float64 + CostKnown bool +} + +// RenderStatus builds the two-line footer: +// +// +// · · · +func RenderStatus(d StatusModel, width int) []string { + dir := StSecondary().Render(Truncate(pathx.ShortenHome(d.WorkingDir), max(10, width/2))) + left1 := dir + if d.Branch != "" { + left1 += StMuted().Render(" ⎇ " + d.Branch) + } + + right1 := "" + if d.Agent != "" { + right1 = StAccent().Render(d.Agent) + } + + left2 := RenderContext(d) + + var rightParts []string + if d.Model != "" { + rightParts = append(rightParts, d.Model) + } + if d.Thinking != "" { + rightParts = append(rightParts, d.Thinking) + } + right2 := StMuted().Render(strings.Join(rightParts, " · ")) + + return []string{ + ComposeLine(left1, right1, width), + ComposeLine(left2, right2, width), + } +} + +// RenderContext renders the context and cost portion of the status. +func RenderContext(d StatusModel) string { + Cost := renderCostSuffix(d) + if d.ContextLimit <= 0 { + if d.Tokens > 0 { + return StMuted().Render(FormatTokens(d.Tokens)+" tokens") + Cost + } + return RenderBar(0) + StMuted().Render(" 0% · 0/0") + Cost + } + + pct := float64(d.ContextLength) / float64(d.ContextLimit) + if pct > 1 { + pct = 1 + } + bar := RenderBar(pct) + label := fmt.Sprintf(" %d%% · %s/%s", + int(pct*100+0.5), + FormatTokens(d.ContextLength), + FormatTokens(d.ContextLimit), + ) + return bar + StMuted().Render(label) + Cost +} + +func renderCostSuffix(d StatusModel) string { + if !d.CostKnown { + return "" + } + return StMuted().Render(" · ") + StAccent().Render(toolcommon.FormatCostUSD(d.Cost)) +} + +// ContextBarWidth is the cell width of the context-usage gauge. +const ContextBarWidth = 10 + +// RenderBar renders the context usage gauge. +func RenderBar(pct float64) string { + filled := min(int(pct*float64(ContextBarWidth)+0.5), ContextBarWidth) + style := StSuccess() + switch { + case pct >= 0.85: + style = StError() + case pct >= 0.6: + style = StWarning() + } + return style.Render(strings.Repeat("█", filled)) + StMuted().Render(strings.Repeat("░", ContextBarWidth-filled)) +} + +// ComposeLine right-aligns right within width, truncating left if necessary. +func ComposeLine(left, right string, width int) string { + lw := DisplayWidth(left) + rw := DisplayWidth(right) + if rw > width { + return Truncate(right, width) + } + if lw+rw+1 > width { + left = Truncate(left, max(0, width-rw-1)) + lw = DisplayWidth(left) + } + gap := max(1, width-lw-rw) + return left + strings.Repeat(" ", gap) + right +} + +// FormatTokens formats a token count for compact status display. +func FormatTokens(n int64) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%.1fk", float64(n)/1_000) + default: + return strconv.FormatInt(n, 10) + } +} diff --git a/pkg/leantui/ui/status_test.go b/pkg/leantui/ui/status_test.go new file mode 100644 index 0000000000..5b8fe6f143 --- /dev/null +++ b/pkg/leantui/ui/status_test.go @@ -0,0 +1,71 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatTokens(t *testing.T) { + t.Parallel() + assert.Equal(t, "500", FormatTokens(500)) + assert.Equal(t, "999", FormatTokens(999)) + assert.Equal(t, "1.0k", FormatTokens(1000)) + assert.Equal(t, "1.2k", FormatTokens(1234)) + assert.Equal(t, "1.0M", FormatTokens(1_000_000)) + assert.Equal(t, "2.5M", FormatTokens(2_500_000)) +} + +func TestComposeLineRightAligns(t *testing.T) { + t.Parallel() + out := ComposeLine("left", "right", 20) + assert.Equal(t, 20, DisplayWidth(out)) + assert.GreaterOrEqual(t, len(out), len("left")+len("right")) + assert.Contains(t, out, "left") + assert.Contains(t, out, "right") +} + +func TestComposeLineTruncatesLeft(t *testing.T) { + t.Parallel() + out := ComposeLine("a very long left side that does not fit", "right", 15) + assert.LessOrEqual(t, DisplayWidth(out), 15) + assert.Contains(t, out, "right") +} + +func TestRenderBarWidth(t *testing.T) { + t.Parallel() + assert.Equal(t, ContextBarWidth, DisplayWidth(RenderBar(0.5))) + assert.Equal(t, ContextBarWidth, DisplayWidth(RenderBar(0))) + assert.Equal(t, ContextBarWidth, DisplayWidth(RenderBar(1))) + assert.Equal(t, ContextBarWidth, DisplayWidth(RenderBar(1.5))) // clamped +} + +func TestRenderContextShowsZerosBeforeUsage(t *testing.T) { + t.Parallel() + out := RenderContext(StatusModel{}) + assert.NotContains(t, out, "context") + assert.Contains(t, out, "0% · 0/0") +} + +func TestRenderStatusFitsWidth(t *testing.T) { + t.Parallel() + d := StatusModel{ + WorkingDir: "/home/user/project", + Branch: "main", + Agent: "coder", + Model: "openai/gpt-5", + Thinking: "high", + ContextLength: 24_000, + ContextLimit: 200_000, + Tokens: 24_000, + Cost: 0.05, + CostKnown: true, + } + lines := RenderStatus(d, 80) + assert.Len(t, lines, 2) + assert.Contains(t, strings.Join(lines, "\n"), "$0.05") + for _, l := range lines { + assert.LessOrEqual(t, DisplayWidth(l), 80) + } +} diff --git a/pkg/leantui/style.go b/pkg/leantui/ui/style.go similarity index 54% rename from pkg/leantui/style.go rename to pkg/leantui/ui/style.go index 388725a00f..3e8cc5567f 100644 --- a/pkg/leantui/style.go +++ b/pkg/leantui/ui/style.go @@ -1,4 +1,4 @@ -package leantui +package ui import ( "charm.land/lipgloss/v2" @@ -9,20 +9,20 @@ import ( // The style helpers are evaluated lazily (on each call) so they always reflect // the theme that styles.ApplyTheme installed before the TUI started. -func stAccent() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Accent) } -func stMuted() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextMutedGray) } -func stSecondary() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextSecondary) } -func stPrimary() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextPrimary) } -func stBold() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextPrimary).Bold(true) } -func stError() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Error) } -func stWarning() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Warning) } -func stSuccess() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Success) } -func stPlaceholder() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextMuted) } -func stReasoning() lipgloss.Style { +func StAccent() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Accent) } +func StMuted() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextMutedGray) } +func StSecondary() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextSecondary) } +func StPrimary() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextPrimary) } +func StBold() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextPrimary).Bold(true) } +func StError() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Error) } +func StWarning() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Warning) } +func StSuccess() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.Success) } +func StPlaceholder() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextMuted) } +func StReasoning() lipgloss.Style { return lipgloss.NewStyle().Foreground(styles.TextMutedGray).Italic(true) } -func stToolBox(width int) lipgloss.Style { +func StToolBox(width int) lipgloss.Style { if width < 1 { width = 1 } @@ -38,7 +38,7 @@ func stToolBox(width int) lipgloss.Style { } const ( - promptText = "❯ " - promptWidth = 2 - continuation = " " + PromptText = "❯ " + PromptWidth = 2 + Continuation = " " ) diff --git a/pkg/leantui/terminal.go b/pkg/leantui/ui/terminal.go similarity index 78% rename from pkg/leantui/terminal.go rename to pkg/leantui/ui/terminal.go index dd970a94a2..3a03b239a1 100644 --- a/pkg/leantui/terminal.go +++ b/pkg/leantui/ui/terminal.go @@ -1,4 +1,4 @@ -package leantui +package ui import ( "bufio" @@ -11,12 +11,12 @@ import ( "golang.org/x/term" ) -// terminal owns the raw-mode TTY: it switches the input file descriptor into +// Terminal owns the raw-mode TTY: it switches the input file descriptor into // raw mode, exposes a cancelable reader for keyboard input, a buffered writer // for output, and the current window size. It deliberately never enters the // alternate screen so the conversation is written to (and scrolls in) the // normal terminal buffer. -type terminal struct { +type Terminal struct { in *os.File out *os.File writer *bufio.Writer @@ -31,7 +31,7 @@ type terminal struct { stopResize chan struct{} } -func newTerminal(in, out *os.File) (*terminal, error) { +func NewTerminal(in, out *os.File) (*Terminal, error) { state, err := term.MakeRaw(int(in.Fd())) if err != nil { return nil, fmt.Errorf("enabling raw mode: %w", err) @@ -43,7 +43,7 @@ func newTerminal(in, out *os.File) (*terminal, error) { return nil, fmt.Errorf("creating input reader: %w", err) } - t := &terminal{ + t := &Terminal{ in: in, out: out, writer: bufio.NewWriterSize(out, 16*1024), @@ -63,10 +63,10 @@ func newTerminal(in, out *os.File) (*terminal, error) { return t, nil } -// resized blocks until the terminal is resized, then refreshes the cached +// Resized blocks until the terminal is resized, then refreshes the cached // dimensions and reports the new size. It returns ok=false once the resize // channel is closed during shutdown. -func (t *terminal) resized() (w, h int, ok bool) { +func (t *Terminal) Resized() (w, h int, ok bool) { size, open := <-t.resize if !open { return 0, 0, false @@ -75,7 +75,7 @@ func (t *terminal) resized() (w, h int, ok bool) { return t.width, t.height, true } -func (t *terminal) startResizeWatcher() { +func (t *Terminal) startResizeWatcher() { lastW, lastH := t.width, t.height go func() { ticker := time.NewTicker(250 * time.Millisecond) @@ -113,7 +113,7 @@ func sendLatestResize(ch chan [2]int, size [2]int) { ch <- size } -func (t *terminal) querySize() (w, h int) { +func (t *Terminal) querySize() (w, h int) { w, h, err := term.GetSize(int(t.out.Fd())) if err != nil { return 0, 0 @@ -131,21 +131,29 @@ func normalizeTerminalSize(w, h int) (int, int) { return w, h } -func (t *terminal) size() (w, h int) { +func (t *Terminal) Size() (w, h int) { return t.width, t.height } -func (t *terminal) writeString(s string) { +func (t *Terminal) Reader() cancelreader.CancelReader { + return t.reader +} + +func (t *Terminal) Writer() *bufio.Writer { + return t.writer +} + +func (t *Terminal) writeString(s string) { _, _ = t.writer.WriteString(s) } -func (t *terminal) flush() { +func (t *Terminal) flush() { _ = t.writer.Flush() } -// restore tears the terminal back down: it disables bracketed paste, cancels +// Restore tears the terminal back down: it disables bracketed paste, cancels // the reader, restores the saved terminal state and stops watching for resizes. -func (t *terminal) restore() { +func (t *Terminal) Restore() { t.writeString(seqDisableBracketedPaste) t.writeString(seqShowCursor) t.flush() diff --git a/pkg/leantui/text.go b/pkg/leantui/ui/text.go similarity index 56% rename from pkg/leantui/text.go rename to pkg/leantui/ui/text.go index f2062e1b0d..75160b2c4c 100644 --- a/pkg/leantui/text.go +++ b/pkg/leantui/ui/text.go @@ -1,4 +1,4 @@ -package leantui +package ui import ( "strings" @@ -7,13 +7,13 @@ import ( "github.com/mattn/go-runewidth" ) -// displayWidth returns the rendered cell width of s, ignoring ANSI escape +// DisplayWidth returns the rendered cell width of s, ignoring ANSI escape // sequences. -func displayWidth(s string) int { +func DisplayWidth(s string) int { return ansi.StringWidth(s) } -func runeWidth(r rune) int { +func RuneWidth(r rune) int { if r == '\t' { return 1 } @@ -24,30 +24,30 @@ func runeWidth(r rune) int { return w } -// truncate shortens s to at most w cells, appending an ellipsis when it had to +// Truncate shortens s to at most w cells, appending an ellipsis when it had to // cut anything. -func truncate(s string, w int) string { +func Truncate(s string, w int) string { if w <= 0 { return "" } - if displayWidth(s) <= w { + if DisplayWidth(s) <= w { return s } return ansi.Truncate(s, w, "…") } -// padRight pads s with spaces up to w cells. It never truncates. -func padRight(s string, w int) string { - gap := w - displayWidth(s) +// PadRight pads s with spaces up to w cells. It never Truncates. +func PadRight(s string, w int) string { + gap := w - DisplayWidth(s) if gap <= 0 { return s } return s + strings.Repeat(" ", gap) } -// wrapANSI hard-wraps s to width w, keeping ANSI styling intact and returning +// WrapANSI hard-wraps s to width w, keeping ANSI styling intact and returning // one string per physical row. Existing newlines in s start new rows. -func wrapANSI(s string, w int) []string { +func WrapANSI(s string, w int) []string { if w < 1 { w = 1 } diff --git a/pkg/leantui/tool.go b/pkg/leantui/ui/tool.go similarity index 62% rename from pkg/leantui/tool.go rename to pkg/leantui/ui/tool.go index 40bf06d85d..201a1bcfa0 100644 --- a/pkg/leantui/tool.go +++ b/pkg/leantui/ui/tool.go @@ -1,4 +1,4 @@ -package leantui +package ui import ( "fmt" @@ -14,39 +14,55 @@ import ( tuitypes "github.com/docker/docker-agent/pkg/tui/types" ) -// toolView is the render state of a single tool call. It deliberately stores +// ToolView is the render state of a single tool call. It deliberately stores // the same TUI message shape used by the full-screen TUI so the lean renderer // can delegate the visual representation to pkg/tui/components/tool. -type toolView struct { +type ToolView struct { message *tuitypes.Message - images []inlineImage + images []InlineImage lastWidth int lastLines []string } -const maxToolOutputLines = 12 +func (t *ToolView) Message() *tuitypes.Message { + if t == nil { + return nil + } + return t.message +} + +func (t *ToolView) SetImages(images []InlineImage) { + if t != nil { + t.images = images + } +} + +const MaxToolOutputLines = 12 -func newToolView(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status tuitypes.ToolStatus) *toolView { - return &toolView{ - message: tuitypes.ToolCallMessage(agentName, toolCall, ensureToolDefinition(toolCall, toolDef), status), +// NewToolView creates a tool call render model. +func NewToolView(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status tuitypes.ToolStatus) *ToolView { + return &ToolView{ + message: tuitypes.ToolCallMessage(agentName, toolCall, EnsureToolDefinition(toolCall, toolDef), status), } } -func ensureToolDefinition(toolCall tools.ToolCall, toolDef tools.Tool) tools.Tool { +// EnsureToolDefinition fills a missing tool definition name from the call. +func EnsureToolDefinition(toolCall tools.ToolCall, toolDef tools.Tool) tools.Tool { if toolDef.Name == "" { toolDef.Name = toolCall.Function.Name } return toolDef } -// renderTool renders a tool call with the same renderer registry used by the +// RenderTool renders a tool call with the same renderer registry used by the // full TUI. This keeps built-in tools and registered custom renderers visually // consistent between the normal and lean interfaces. -func renderTool(t toolView, width int) []string { - return renderToolWithState(&t, width, 0, service.StaticSessionState{}) +func RenderTool(t ToolView, width int) []string { + return RenderToolWithState(&t, width, 0, service.StaticSessionState{}) } -func renderToolWithState(t *toolView, width, frame int, sessionState service.SessionStateReader) []string { +// RenderToolWithState renders a tool call using session state. +func RenderToolWithState(t *ToolView, width, frame int, sessionState service.SessionStateReader) []string { if width < 1 { width = 1 } @@ -57,7 +73,7 @@ func renderToolWithState(t *toolView, width, frame int, sessionState service.Ses sessionState = service.StaticSessionState{} } - boxStyle := stToolBox(width) + boxStyle := StToolBox(width) innerWidth := max(width-boxStyle.GetHorizontalFrameSize(), 1) view := toolcomponent.New(t.message, sessionState) @@ -69,7 +85,7 @@ func renderToolWithState(t *toolView, width, frame int, sessionState service.Ses lines := splitRenderedTool(renderToolBox(view.View(), width), width) for _, img := range t.images { - lines = append(lines, renderInlineImage(img, width)...) + lines = append(lines, RenderInlineImage(img, width)...) } if t.shouldKeepLastPendingLines(width, lines) { @@ -90,10 +106,10 @@ func renderToolBox(content string, width int) string { if content == "" { return "" } - return styles.RenderComposite(stToolBox(width), content) + return styles.RenderComposite(StToolBox(width), content) } -func (t *toolView) shouldKeepLastPendingLines(width int, lines []string) bool { +func (t *ToolView) shouldKeepLastPendingLines(width int, lines []string) bool { if t.message.ToolStatus != tuitypes.ToolStatusPending || t.lastWidth != width || len(t.lastLines) == 0 { return false } @@ -110,7 +126,7 @@ func cloneLines(lines []string) []string { func totalContentWidth(lines []string) int { total := 0 for _, line := range lines { - total += displayWidth(strings.TrimRight(ansi.Strip(line), " ")) + total += DisplayWidth(strings.TrimRight(ansi.Strip(line), " ")) } return total } @@ -126,8 +142,8 @@ func splitRenderedTool(rendered string, width int) []string { var out []string for line := range strings.SplitSeq(rendered, "\n") { - if displayWidth(line) > width { - out = append(out, wrapANSI(line, width)...) + if DisplayWidth(line) > width { + out = append(out, WrapANSI(line, width)...) continue } out = append(out, line) @@ -135,18 +151,19 @@ func splitRenderedTool(rendered string, width int) []string { return out } -func renderToolOutput(output string, width int) []string { +// RenderToolOutput renders plain streamed shell output. +func RenderToolOutput(output string, width int) []string { lines := strings.Split(strings.TrimRight(output, "\n"), "\n") var out []string - if len(lines) > maxToolOutputLines { - hidden := len(lines) - maxToolOutputLines - out = append(out, " "+stMuted().Render(fmt.Sprintf("… (%d earlier lines)", hidden))) - lines = lines[len(lines)-maxToolOutputLines:] + if len(lines) > MaxToolOutputLines { + hidden := len(lines) - MaxToolOutputLines + out = append(out, " "+StMuted().Render(fmt.Sprintf("… (%d earlier lines)", hidden))) + lines = lines[len(lines)-MaxToolOutputLines:] } for _, l := range lines { - for _, wl := range wrapANSI(l, width-2) { - out = append(out, " "+stMuted().Render(wl)) + for _, wl := range WrapANSI(l, width-2) { + out = append(out, " "+StMuted().Render(wl)) } } return out diff --git a/pkg/leantui/ui/toolstate.go b/pkg/leantui/ui/toolstate.go new file mode 100644 index 0000000000..539713d5fb --- /dev/null +++ b/pkg/leantui/ui/toolstate.go @@ -0,0 +1,150 @@ +package ui + +import ( + "slices" + "strings" + "time" + + "github.com/docker/docker-agent/pkg/tools" + tuitypes "github.com/docker/docker-agent/pkg/tui/types" +) + +// ToolResult is the runtime-free data needed to finish a tool call view. +type ToolResult struct { + Response string + Result *tools.ToolCallResult + AgentName string + ToolDefinition tools.Tool + Images []InlineImage +} + +// ToolTracker holds the render state of in-flight tool calls, keyed by id and +// kept in call order so the conversation shows them as they arrive. +type ToolTracker struct { + byID map[string]*ToolView + order []string +} + +func NewToolTracker() *ToolTracker { + return &ToolTracker{byID: map[string]*ToolView{}} +} + +// Reset clears all tracked tool calls. +func (t *ToolTracker) Reset() { + t.byID = map[string]*ToolView{} + t.order = nil +} + +// Empty reports whether there are no tracked tool calls. +func (t *ToolTracker) Empty() bool { return len(t.order) == 0 } + +// Get returns a tracked tool call by id. +func (t *ToolTracker) Get(id string) *ToolView { return t.byID[id] } + +// Len reports the number of tracked tool calls. +func (t *ToolTracker) Len() int { return len(t.order) } + +// ByIDLen reports the number of tracked tool-call ids. +func (t *ToolTracker) ByIDLen() int { return len(t.byID) } + +// ForEach visits the tracked tools in call order, skipping nil entries. +func (t *ToolTracker) ForEach(fn func(*ToolView)) { + for _, id := range t.order { + if tv := t.byID[id]; tv != nil { + fn(tv) + } + } +} + +// Remove deletes a tracked tool call by id. +func (t *ToolTracker) Remove(id string) { + if id == "" { + return + } + delete(t.byID, id) + t.order = slices.DeleteFunc(t.order, func(s string) bool { return s == id }) +} + +// Upsert creates or updates a tracked tool call. Argument fragments streamed +// while the call is still pending are concatenated. +func (t *ToolTracker) Upsert(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status tuitypes.ToolStatus) { + id := ToolViewID(toolCall) + tv := t.byID[id] + if tv == nil { + tv = NewToolView(agentName, toolCall, toolDef, status) + t.byID[id] = tv + t.order = append(t.order, id) + return + } + + msg := tv.message + if msg == nil { + msg = NewToolView(agentName, toolCall, toolDef, status).message + tv.message = msg + return + } + + if agentName != "" { + msg.Sender = agentName + } + if toolDef.Name != "" || toolCall.Function.Name != "" { + msg.ToolDefinition = EnsureToolDefinition(toolCall, toolDef) + } + msg.ToolStatus = status + if status == tuitypes.ToolStatusRunning && msg.StartedAt == nil { + now := time.Now() + msg.StartedAt = &now + } + if toolCall.ID != "" { + msg.ToolCall.ID = toolCall.ID + } + if toolCall.Type != "" { + msg.ToolCall.Type = toolCall.Type + } + if toolCall.Function.Name != "" { + msg.ToolCall.Function.Name = toolCall.Function.Name + } + if toolCall.Function.Arguments != "" { + if status == tuitypes.ToolStatusPending { + msg.ToolCall.Function.Arguments += toolCall.Function.Arguments + } else { + msg.ToolCall.Function.Arguments = toolCall.Function.Arguments + } + } +} + +// Finish marks a tool call complete and returns an immutable snapshot. It +// returns nil when there is nothing to render. +func (t *ToolTracker) Finish(id string, result ToolResult) *ToolView { + tv := t.byID[id] + if tv == nil { + toolCall := tools.ToolCall{ID: id, Function: tools.FunctionCall{Name: result.ToolDefinition.Name}} + tv = NewToolView(result.AgentName, toolCall, result.ToolDefinition, tuitypes.ToolStatusCompleted) + } + if tv.message == nil { + return nil + } + + status := tuitypes.ToolStatusCompleted + if result.Result != nil && result.Result.IsError { + status = tuitypes.ToolStatusError + } + tv.message.ToolStatus = status + tv.message.ToolDefinition = EnsureToolDefinition(tv.message.ToolCall, result.ToolDefinition) + tv.message.Content = strings.ReplaceAll(result.Response, "\t", " ") + tv.message.ToolResult = result.Result.WithoutPayload() + tv.images = result.Images + + msg := *tv.message + snapshot := &ToolView{message: &msg, images: tv.images} + t.Remove(id) + return snapshot +} + +// ToolViewID returns a stable id for a tool call view. +func ToolViewID(toolCall tools.ToolCall) string { + if toolCall.ID != "" { + return toolCall.ID + } + return toolCall.Function.Name +} diff --git a/pkg/leantui/ui/transcript.go b/pkg/leantui/ui/transcript.go new file mode 100644 index 0000000000..d211639843 --- /dev/null +++ b/pkg/leantui/ui/transcript.go @@ -0,0 +1,210 @@ +package ui + +import ( + "strings" + + "github.com/docker/docker-agent/pkg/tools" + "github.com/docker/docker-agent/pkg/tui/service" + tuitypes "github.com/docker/docker-agent/pkg/tui/types" +) + +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type blockKind int + +type PendingUserKind int + +const ( + blockReasoning blockKind = iota + blockAssistant +) + +const ( + PendingUserSteer PendingUserKind = iota + PendingUserFollowUp +) + +type PendingUserMessage struct { + Display string + Content string + Kind PendingUserKind +} + +// pendingBlock accumulates the text of the block currently being streamed. +type pendingBlock struct { + kind blockKind + text strings.Builder +} + +// block is a finalized piece of the conversation. Its lines are rendered lazily +// and cached per width, so finalized content is not re-rendered every frame and +// only reflows when the terminal is resized. +type block struct { + render func(width int) []string + cacheW int + cache []string + cached bool +} + +func (b *block) lines(width int) []string { + if !b.cached || b.cacheW != width { + b.cache = b.render(width) + b.cacheW = width + b.cached = true + } + return b.cache +} + +// Transcript owns everything that scrolls: the finalized conversation blocks, +// the in-progress streamed block, and the in-flight tool calls. Committed +// blocks are immutable scrollback; the pending block and tool calls are the +// live region that changes each frame until they finalize into blocks. +type Transcript struct { + blocks []*block + pending *pendingBlock + toolz *ToolTracker +} + +// NewTranscript creates an empty transcript. +func NewTranscript() *Transcript { + return &Transcript{toolz: NewToolTracker()} +} + +// ClearActive drops the live region (the streamed block and any in-flight tool +// calls) while keeping the committed scrollback intact. Used when starting a +// new session. +func (t *Transcript) ClearActive() { + t.pending = nil + t.toolz.Reset() +} + +// AddBlock appends a finalized, lazily-rendered block to the conversation. +func (t *Transcript) AddBlock(render func(width int) []string) { + t.blocks = append(t.blocks, &block{render: render}) +} + +func (t *Transcript) appendPending(kind blockKind, content string) { + if content == "" { + return + } + if t.pending == nil || t.pending.kind != kind { + t.FlushPending() + t.pending = &pendingBlock{kind: kind} + } + t.pending.text.WriteString(content) +} + +// AppendReasoning appends streamed reasoning text. +func (t *Transcript) AppendReasoning(content string) { t.appendPending(blockReasoning, content) } + +// AppendAssistant appends streamed assistant text. +func (t *Transcript) AppendAssistant(content string) { t.appendPending(blockAssistant, content) } + +// FlushPending finalizes the in-progress streamed block into the conversation. +func (t *Transcript) FlushPending() { + if t.pending == nil { + return + } + text := t.pending.text.String() + kind := t.pending.kind + t.pending = nil + + switch kind { + case blockReasoning: + t.AddBlock(func(w int) []string { return RenderReasoningLines(text, w) }) + case blockAssistant: + t.AddBlock(func(w int) []string { return RenderAssistantLines(text, w) }) + } +} + +// UpsertTool creates or updates an in-flight tool call. +func (t *Transcript) UpsertTool(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status tuitypes.ToolStatus) { + t.toolz.Upsert(agentName, toolCall, toolDef, status) +} + +// Tool returns an in-flight tool call by id. +func (t *Transcript) Tool(id string) *ToolView { return t.toolz.Get(id) } + +// RemoveTool removes an in-flight tool call by id. +func (t *Transcript) RemoveTool(id string) { t.toolz.Remove(id) } + +// FinishTool commits a completed tool call as an immutable block. +func (t *Transcript) FinishTool(id string, result ToolResult, sessionState service.SessionStateReader) { + view := t.toolz.Finish(id, result) + if view == nil { + return + } + t.AddBlock(func(w int) []string { return RenderToolWithState(view, w, 0, sessionState) }) +} + +// Lines renders everything that scrolls: finalized blocks, the in-progress +// streamed block, running tool calls, and user messages waiting to be accepted +// by the runtime. A blank line separates each entry. The spinner is shown only +// while busy with nothing yet streaming. +func (t *Transcript) Lines(width, spinnerFrame int, busy bool, sessionState service.SessionStateReader, pendingUsers []PendingUserMessage) []string { + var lines []string + for _, b := range t.blocks { + lines = append(lines, b.lines(width)...) + lines = append(lines, "") + } + if t.pending != nil { + lines = append(lines, t.pendingLines(width)...) + lines = append(lines, "") + } + t.toolz.ForEach(func(tv *ToolView) { + lines = append(lines, RenderToolWithState(tv, width, spinnerFrame, sessionState)...) + lines = append(lines, "") + }) + if busy && t.pending == nil && t.toolz.Empty() { + lines = append(lines, spinnerLine(spinnerFrame), "") + } + for _, msg := range pendingUsers { + lines = append(lines, RenderPendingUserLines(msg.Display, width)...) + lines = append(lines, "") + } + return lines +} + +// Clear drops all transcript content and active tool state. +func (t *Transcript) Clear() { + t.blocks = nil + t.pending = nil + t.toolz.Reset() +} + +// BlockCount reports the number of committed transcript blocks. +func (t *Transcript) BlockCount() int { return len(t.blocks) } + +// BlockLines renders a committed block by index. +func (t *Transcript) BlockLines(index, width int) []string { + if index < 0 || index >= len(t.blocks) { + return nil + } + return t.blocks[index].lines(width) +} + +// ToolCount reports the number of in-flight tool calls. +func (t *Transcript) ToolCount() int { return t.toolz.Len() } + +// ToolByIDCount reports the number of tracked tool-call ids. +func (t *Transcript) ToolByIDCount() int { return t.toolz.ByIDLen() } + +// pendingLines renders the message currently being streamed. Assistant text is +// rendered as markdown live (the same renderer used once it is finalized), so +// formatting appears as it streams. +func (t *Transcript) pendingLines(width int) []string { + text := t.pending.text.String() + switch t.pending.kind { + case blockReasoning: + return RenderReasoningLines(text, width) + case blockAssistant: + return RenderAssistantLines(text, width) + default: + return nil + } +} + +func spinnerLine(frame int) string { + f := spinnerFrames[frame%len(spinnerFrames)] + return StAccent().Render(f) + " " + StMuted().Render("Working…") +} diff --git a/pkg/leantui/ui/usage.go b/pkg/leantui/ui/usage.go new file mode 100644 index 0000000000..58a217928f --- /dev/null +++ b/pkg/leantui/ui/usage.go @@ -0,0 +1,99 @@ +package ui + +// UsageSnapshot is the per-session token and cost usage summarized in the +// status footer. +type UsageSnapshot struct { + ContextLength int64 + ContextLimit int64 + Tokens int64 + Cost float64 +} + +// UsageTracker aggregates per-session token usage so the footer can show the +// active session's context window alongside the total cost of the whole run +// (the root session plus any nested agent sessions). It keeps a stack of +// in-flight sessions so the "active" session is whichever stream is on top. +type UsageTracker struct { + bySession map[string]UsageSnapshot + rootSessionID string + latestSessionID string + stack []string +} + +func NewUsageTracker() *UsageTracker { + return &UsageTracker{bySession: map[string]UsageSnapshot{}} +} + +func (u *UsageTracker) Reset() { + u.bySession = map[string]UsageSnapshot{} + u.rootSessionID = "" + u.latestSessionID = "" + u.stack = nil +} + +// StreamStarted pushes a newly-started session onto the active stack, adopting +// the first one as the root session. +func (u *UsageTracker) StreamStarted(sessionID string) { + if sessionID == "" { + return + } + if len(u.stack) == 0 { + u.rootSessionID = sessionID + } + u.stack = append(u.stack, sessionID) +} + +// StreamStopped pops the most recently-started session off the active stack. +func (u *UsageTracker) StreamStopped() { + if n := len(u.stack); n > 0 { + u.stack = u.stack[:n-1] + } +} + +// Record stores usage for a session, adopting the first session seen as the +// root when no stream has started yet. +func (u *UsageTracker) Record(sessionID string, snapshot UsageSnapshot) { + if u.rootSessionID == "" && len(u.bySession) == 0 { + u.rootSessionID = sessionID + } + u.bySession[sessionID] = snapshot + u.latestSessionID = sessionID +} + +func (u *UsageTracker) Empty() bool { return len(u.bySession) == 0 } + +func (u *UsageTracker) TotalCost() float64 { + var total float64 + for _, usage := range u.bySession { + total += usage.Cost + } + return total +} + +// Active returns the usage of the session whose context the footer should show: +// the top of the active stack, else the root, else the most recent, else the +// sole recorded session. +func (u *UsageTracker) Active() (UsageSnapshot, bool) { + if n := len(u.stack); n > 0 { + usage, ok := u.bySession[u.stack[n-1]] + return usage, ok + } + if u.rootSessionID != "" { + usage, ok := u.bySession[u.rootSessionID] + return usage, ok + } + if u.latestSessionID != "" { + usage, ok := u.bySession[u.latestSessionID] + return usage, ok + } + if len(u.bySession) == 1 { + for _, usage := range u.bySession { + return usage, true + } + } + return UsageSnapshot{}, false +} + +func (u *UsageTracker) RootSessionID() string { + return u.rootSessionID +} diff --git a/pkg/leantui/update.go b/pkg/leantui/update.go index 9445a554fc..4edbfbe68f 100644 --- a/pkg/leantui/update.go +++ b/pkg/leantui/update.go @@ -10,76 +10,77 @@ import ( "charm.land/lipgloss/v2" "github.com/docker/docker-agent/pkg/effort" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/tui/messages" ) -func (m *model) handleKey(ctx context.Context, k key) { - if m.confirm != nil { +func (m *model) handleKey(ctx context.Context, k ui.Key) { + if m.screen.Confirm != nil { m.handleConfirmKey(k) return } - switch k.typ { - case keyCtrlC: + switch k.Typ { + case ui.KeyCtrlC: m.handleInterrupt() - case keyCtrlD: - if m.editor.isEmpty() { + case ui.KeyCtrlD: + if m.screen.Editor.IsEmpty() { m.quit() } else { - m.editor.deleteForward() + m.screen.Editor.DeleteForward() } - case keyEnter: + case ui.KeyEnter: m.handleEnter(ctx) - case keyAltEnter: - m.editor.insertNewline() - case keyTab: + case ui.KeyAltEnter: + m.screen.Editor.InsertNewline() + case ui.KeyTab: m.handleTab() - case keyShiftTab: + case ui.KeyShiftTab: m.handleCycleThinkingLevel(ctx) - case keyUp: - if m.ac.active { - m.ac.moveUp() - } else if !m.editor.up(m.width) { - m.editor.historyPrev() + case ui.KeyUp: + if m.screen.Autocomplete.Active { + m.screen.Autocomplete.MoveUp() + } else if !m.screen.Editor.Up(m.width) { + m.screen.Editor.HistoryPrev() } - case keyDown: - if m.ac.active { - m.ac.moveDown() - } else if !m.editor.down(m.width) { - m.editor.historyNext() + case ui.KeyDown: + if m.screen.Autocomplete.Active { + m.screen.Autocomplete.MoveDown() + } else if !m.screen.Editor.Down(m.width) { + m.screen.Editor.HistoryNext() } - case keyLeft: - m.editor.moveLeft() - case keyRight: - m.editor.moveRight() - case keyWordLeft: - m.editor.moveWordLeft() - case keyWordRight: - m.editor.moveWordRight() - case keyHome: - m.editor.moveLineStart() - case keyEnd: - m.editor.moveLineEnd() - case keyBackspace: - m.editor.backspace() - case keyDelete: - m.editor.deleteForward() - case keyCtrlU: - m.editor.deleteToLineStart() - case keyCtrlK: - m.editor.deleteToLineEnd() - case keyCtrlW: - m.editor.deleteWordBack() - case keyEsc: - m.ac.dismiss() - case keyCtrlL: + case ui.KeyLeft: + m.screen.Editor.MoveLeft() + case ui.KeyRight: + m.screen.Editor.MoveRight() + case ui.KeyWordLeft: + m.screen.Editor.MoveWordLeft() + case ui.KeyWordRight: + m.screen.Editor.MoveWordRight() + case ui.KeyHome: + m.screen.Editor.MoveLineStart() + case ui.KeyEnd: + m.screen.Editor.MoveLineEnd() + case ui.KeyBackspace: + m.screen.Editor.Backspace() + case ui.KeyDelete: + m.screen.Editor.DeleteForward() + case ui.KeyCtrlU: + m.screen.Editor.DeleteToLineStart() + case ui.KeyCtrlK: + m.screen.Editor.DeleteToLineEnd() + case ui.KeyCtrlW: + m.screen.Editor.DeleteWordBack() + case ui.KeyEsc: + m.screen.Autocomplete.Dismiss() + case ui.KeyCtrlL: m.clearScreen() - case keyRune, keyPaste: - m.editor.insert(k.runes) + case ui.KeyRune, ui.KeyPaste: + m.screen.Editor.Insert(k.Runes) } - m.ac.sync(m.editor.text()) + m.screen.Autocomplete.Sync(m.screen.Editor.Text()) } func (m *model) handleInterrupt() { @@ -91,33 +92,33 @@ func (m *model) handleInterrupt() { m.queue = nil m.pendingUsers = nil m.ignoredUsers = nil - m.transcript.addBlock(func(int) []string { return []string{stWarning().Render("⏹ Cancelled")} }) - case !m.editor.isEmpty(): - m.editor.reset() - m.ac.dismiss() + m.screen.Transcript.AddBlock(func(int) []string { return []string{ui.StWarning().Render("⏹ Cancelled")} }) + case !m.screen.Editor.IsEmpty(): + m.screen.Editor.Reset() + m.screen.Autocomplete.Dismiss() default: m.quit() } } func (m *model) handleEnter(ctx context.Context) { - if m.ac.active { - if cmd, ok := m.ac.current(); ok { - m.ac.dismiss() - m.submitEditor(ctx, "/"+cmd.name) + if m.screen.Autocomplete.Active { + if cmd, ok := m.screen.Autocomplete.Current(); ok { + m.screen.Autocomplete.Dismiss() + m.submitEditor(ctx, "/"+cmd.Name) return } } - m.submitEditor(ctx, m.editor.text()) + m.submitEditor(ctx, m.screen.Editor.Text()) } func (m *model) handleTab() { - if !m.ac.active { + if !m.screen.Autocomplete.Active { return } - if cmd, ok := m.ac.current(); ok { - m.editor.setText("/" + cmd.name + " ") - m.ac.sync(m.editor.text()) + if cmd, ok := m.screen.Autocomplete.Current(); ok { + m.screen.Editor.SetText("/" + cmd.Name + " ") + m.screen.Autocomplete.Sync(m.screen.Editor.Text()) } } @@ -130,7 +131,7 @@ func (m *model) handleCycleThinkingLevel(ctx context.Context) { m.reportThinkingLevelError("change", err) return } - m.status.thinking = level.String() + m.status.Thinking = level.String() } // handleSetThinkingLevel applies the /effort command: it sets the current @@ -140,12 +141,12 @@ func (m *model) handleSetThinkingLevel(ctx context.Context, level string) { return } if level == "" { - m.addNotice("", "Usage: /effort ", stMuted()) + m.addNotice("", "Usage: /effort ", ui.StMuted()) return } parsed, ok := effort.Parse(level) if !ok { - m.addNotice("✗ ", fmt.Sprintf("Unknown effort level %q (valid: none, minimal, low, medium, high, xhigh, max)", level), stError()) + m.addNotice("✗ ", fmt.Sprintf("Unknown effort level %q (valid: none, minimal, low, medium, high, xhigh, max)", level), ui.StError()) return } applied, err := m.app.SetAgentThinkingLevel(ctx, parsed) @@ -153,8 +154,8 @@ func (m *model) handleSetThinkingLevel(ctx context.Context, level string) { m.reportThinkingLevelError("set", err) return } - m.status.thinking = applied.String() - m.addNotice("", "Reasoning effort set to "+applied.String(), stMuted()) + m.status.Thinking = applied.String() + m.addNotice("", "Reasoning effort set to "+applied.String(), ui.StMuted()) } // thinkingLevelChangeable reports whether the reasoning-effort level can be @@ -164,7 +165,7 @@ func (m *model) thinkingLevelChangeable() bool { return false } if !m.app.SupportsModelSwitching() { - m.addNotice("", "Thinking levels can't be changed with remote runtimes", stMuted()) + m.addNotice("", "Thinking levels can't be changed with remote runtimes", ui.StMuted()) return false } return true @@ -174,10 +175,10 @@ func (m *model) thinkingLevelChangeable() bool { // distinguishing the unsupported-model case from other failures. func (m *model) reportThinkingLevelError(action string, err error) { if errors.Is(err, runtime.ErrUnsupported) { - m.addNotice("", "Current model does not support thinking levels", stMuted()) + m.addNotice("", "Current model does not support thinking levels", ui.StMuted()) return } - m.addNotice("✗ ", fmt.Sprintf("Failed to %s thinking level: %v", action, err), stError()) + m.addNotice("✗ ", fmt.Sprintf("Failed to %s thinking level: %v", action, err), ui.StError()) } type busySubmitMode int @@ -206,9 +207,9 @@ func (m *model) submit(ctx context.Context, text string, opts submitOptions) { return } if opts.fromEditor { - m.editor.rememberHistory(trimmed) - m.editor.reset() - m.ac.dismiss() + m.screen.Editor.RememberHistory(trimmed) + m.screen.Editor.Reset() + m.screen.Autocomplete.Dismiss() } if strings.HasPrefix(trimmed, "/") && m.handleSlash(ctx, trimmed, opts.busyMode) { @@ -230,7 +231,7 @@ func (m *model) handleSlash(ctx context.Context, text string, mode busySubmitMod case "new": m.app.NewSession() m.resetConversation() - m.addNotice("", "Started a new session.", stMuted()) + m.addNotice("", "Started a new session.", ui.StMuted()) m.refreshCommands(ctx) return true case "clear": @@ -270,16 +271,16 @@ func (m *model) handleSlash(ctx context.Context, text string, mode busySubmitMod func (m *model) dispatchUserMessage(ctx context.Context, display, content string, mode busySubmitMode) { if m.app.IsReadOnly() { m.addUserEcho(display) - m.addNotice("⚠ ", "This session is read-only.", stWarning()) + m.addNotice("⚠ ", "This session is read-only.", ui.StWarning()) return } if m.busy { if mode == busySubmitSteer { if err := m.app.Steer(ctx, runtime.QueuedMessage{Content: content}); err != nil { - m.addNotice("⚠ ", "Could not steer current response: "+err.Error(), stWarning()) + m.addNotice("⚠ ", "Could not steer current response: "+err.Error(), ui.StWarning()) return } - m.addPendingUser(display, content, pendingUserSteer) + m.addPendingUser(display, content, ui.PendingUserSteer) return } m.enqueueFollowUp(display, content) @@ -291,7 +292,7 @@ func (m *model) dispatchUserMessage(ctx context.Context, display, content string } func (m *model) enqueueFollowUp(display, content string) { - msg := pendingUserMessage{display: display, content: content, kind: pendingUserFollowUp} + msg := ui.PendingUserMessage{Display: display, Content: content, Kind: ui.PendingUserFollowUp} m.queue = append(m.queue, msg) m.pendingUsers = append(m.pendingUsers, msg) } @@ -317,7 +318,7 @@ func (m *model) sendFirstMessage(ctx context.Context, msg, attachPath string) { m.addUserEcho(trimmed) m.ignoreUserEcho(content) case len(atts) > 0: - m.addNotice("", "(attached "+atts[0].Name+")", stMuted()) + m.addNotice("", "(attached "+atts[0].Name+")", ui.StMuted()) m.ignoreUserEcho(content) default: return @@ -355,27 +356,27 @@ func (m *model) refreshCommands(ctx context.Context) { if m.disabledCommands[name] { continue } - cmds = append(cmds, command{name: name, desc: c.DisplayText(), kind: cmdAgent}) + cmds = append(cmds, ui.Command{Name: name, Desc: c.DisplayText(), Kind: ui.CmdAgent}) } for _, sk := range m.app.CurrentAgentSkills() { - cmds = append(cmds, command{name: sk.Name, desc: sk.Description, kind: cmdAgent}) + cmds = append(cmds, ui.Command{Name: sk.Name, Desc: sk.Description, Kind: ui.CmdAgent}) } - m.ac.setCommands(cmds) + m.screen.Autocomplete.SetCommands(cmds) } -func (m *model) handleConfirmKey(k key) { - if k.typ == keyEsc { +func (m *model) handleConfirmKey(k ui.Key) { + if k.Typ == ui.KeyEsc { m.resolveConfirm(runtime.ResumeReject("rejected by user")) return } - if k.typ != keyRune || len(k.runes) == 0 { + if k.Typ != ui.KeyRune || len(k.Runes) == 0 { return } - switch k.runes[0] { + switch k.Runes[0] { case 'y', 'Y': m.resolveConfirm(runtime.ResumeApprove()) case 'a', 'A': - m.resolveConfirm(runtime.ResumeApproveTool(m.confirm.tool)) + m.resolveConfirm(runtime.ResumeApproveTool(m.screen.Confirm.Tool)) case 's', 'S': m.resolveConfirm(runtime.ResumeApproveSession()) case 'n', 'N': @@ -385,7 +386,7 @@ func (m *model) handleConfirmKey(k key) { func (m *model) resolveConfirm(req runtime.ResumeRequest) { m.app.Resume(req) - m.confirm = nil + m.screen.Confirm = nil } func (m *model) resetConversation() { @@ -393,22 +394,22 @@ func (m *model) resetConversation() { m.runCancel() m.runCancel = nil } - m.transcript.clearActive() + m.screen.Transcript.ClearActive() m.queue = nil m.pendingUsers = nil m.ignoredUsers = nil m.busy = false - m.confirm = nil - m.usage.reset() - m.status.contextLength = 0 - m.status.contextLimit = 0 - m.status.tokens = 0 - m.status.cost = 0 - m.status.costKnown = false + m.screen.Confirm = nil + m.usage.Reset() + m.status.ContextLength = 0 + m.status.ContextLimit = 0 + m.status.Tokens = 0 + m.status.Cost = 0 + m.status.CostKnown = false } func (m *model) clearScreen() { - m.r.repaint() + m.r.Repaint() } func (m *model) quit() { @@ -419,22 +420,22 @@ func (m *model) quit() { } func (m *model) addUserEcho(text string) { - m.transcript.addBlock(func(w int) []string { return renderUserLines(text, w) }) + m.screen.Transcript.AddBlock(func(w int) []string { return ui.RenderUserLines(text, w) }) } -func (m *model) addPendingUser(display, content string, kind pendingUserKind) { - m.pendingUsers = append(m.pendingUsers, pendingUserMessage{display: display, content: content, kind: kind}) +func (m *model) addPendingUser(display, content string, kind ui.PendingUserKind) { + m.pendingUsers = append(m.pendingUsers, ui.PendingUserMessage{Display: display, Content: content, Kind: kind}) } -func (m *model) consumePendingUser(kind pendingUserKind, content string) (pendingUserMessage, bool) { +func (m *model) consumePendingUser(kind ui.PendingUserKind, content string) (ui.PendingUserMessage, bool) { for i, msg := range m.pendingUsers { - if msg.kind != kind || !samePendingUserContent(msg.content, content) { + if msg.Kind != kind || !samePendingUserContent(msg.Content, content) { continue } m.pendingUsers = append(m.pendingUsers[:i], m.pendingUsers[i+1:]...) return msg, true } - return pendingUserMessage{}, false + return ui.PendingUserMessage{}, false } func samePendingUserContent(pending, emitted string) bool { @@ -457,25 +458,25 @@ func (m *model) consumeIgnoredUserEcho(content string) bool { } func (m *model) addNotice(prefix, text string, style lipgloss.Style) { - m.transcript.addBlock(func(w int) []string { return renderNoticeLines(prefix, text, w, style) }) + m.screen.Transcript.AddBlock(func(w int) []string { return ui.RenderNoticeLines(prefix, text, w, style) }) } func (m *model) commitHelp() { - m.transcript.addBlock(func(int) []string { + m.screen.Transcript.AddBlock(func(int) []string { return []string{ - stBold().Render("Commands"), - stMuted().Render(" /new start a new session"), - stMuted().Render(" /compact summarize and compact the conversation"), - stMuted().Render(" /effort set the model's reasoning effort (e.g. /effort high)"), - stMuted().Render(" /clear clear the screen"), - stMuted().Render(" /help show this help"), - stMuted().Render(" /exit quit"), + ui.StBold().Render("Commands"), + ui.StMuted().Render(" /new start a new session"), + ui.StMuted().Render(" /compact summarize and compact the conversation"), + ui.StMuted().Render(" /effort set the model's reasoning effort (e.g. /effort high)"), + ui.StMuted().Render(" /clear clear the screen"), + ui.StMuted().Render(" /help show this help"), + ui.StMuted().Render(" /exit quit"), "", - stBold().Render("Shortcuts"), - stMuted().Render(" Enter send Alt+Enter insert newline"), - stMuted().Render(" Up/Down history Tab complete command"), - stMuted().Render(" Shift+Tab cycle thinking Ctrl+C cancel / quit"), - stMuted().Render(" Ctrl+W delete previous word"), + ui.StBold().Render("Shortcuts"), + ui.StMuted().Render(" Enter send Alt+Enter insert newline"), + ui.StMuted().Render(" Up/Down history Tab complete command"), + ui.StMuted().Render(" Shift+Tab cycle thinking Ctrl+C cancel / quit"), + ui.StMuted().Render(" Ctrl+W delete previous word"), } }) } diff --git a/pkg/leantui/update_test.go b/pkg/leantui/update_test.go index e8190c5fcb..a2c06d52a0 100644 --- a/pkg/leantui/update_test.go +++ b/pkg/leantui/update_test.go @@ -9,6 +9,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/effort" + "github.com/docker/docker-agent/pkg/leantui/ui" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/sessiontitle" @@ -128,11 +129,11 @@ func TestShiftTabCyclesThinkingLevel(t *testing.T) { m := bareModel(24) m.app = app.New(t.Context(), rt, session.New()) - m.handleKey(t.Context(), key{typ: keyShiftTab}) + m.handleKey(t.Context(), ui.Key{Typ: ui.KeyShiftTab}) assert.Equal(t, 1, rt.cycleCalls) - assert.Equal(t, "high", m.status.thinking) - assert.Empty(t, m.transcript.blocks) + assert.Equal(t, "high", m.status.Thinking) + assert.Zero(t, m.screen.Transcript.BlockCount()) } func TestShiftTabReportsUnsupportedThinkingLevel(t *testing.T) { @@ -141,11 +142,11 @@ func TestShiftTabReportsUnsupportedThinkingLevel(t *testing.T) { m := bareModel(24) m.app = app.New(t.Context(), rt, session.New()) - m.handleKey(t.Context(), key{typ: keyShiftTab}) + m.handleKey(t.Context(), ui.Key{Typ: ui.KeyShiftTab}) assert.Equal(t, 1, rt.cycleCalls) - assert.Empty(t, m.status.thinking) - assert.Len(t, m.transcript.blocks, 1) + assert.Empty(t, m.status.Thinking) + assert.Equal(t, 1, m.screen.Transcript.BlockCount()) } func TestEffortCommandSetsThinkingLevel(t *testing.T) { @@ -158,7 +159,7 @@ func TestEffortCommandSetsThinkingLevel(t *testing.T) { assert.Equal(t, 1, rt.setCalls) assert.Equal(t, effort.High, rt.setLevel) - assert.Equal(t, "high", m.status.thinking) + assert.Equal(t, "high", m.status.Thinking) } func TestEffortCommandRejectsUnknownLevel(t *testing.T) { @@ -170,8 +171,8 @@ func TestEffortCommandRejectsUnknownLevel(t *testing.T) { m.handleSetThinkingLevel(t.Context(), "turbo") assert.Zero(t, rt.setCalls) - assert.Empty(t, m.status.thinking) - assert.Len(t, m.transcript.blocks, 1) + assert.Empty(t, m.status.Thinking) + assert.Equal(t, 1, m.screen.Transcript.BlockCount()) } func TestEditorSubmitWhileBusySteersAndRendersAtStreamEnd(t *testing.T) { @@ -180,8 +181,8 @@ func TestEditorSubmitWhileBusySteersAndRendersAtStreamEnd(t *testing.T) { m := bareModel(24) m.app = app.New(t.Context(), rt, session.New()) m.busy = true - m.transcript.appendPending(blockAssistant, "assistant is still streaming") - m.editor.setText("turn left") + m.screen.Transcript.AppendAssistant("assistant is still streaming") + m.screen.Editor.SetText("turn left") m.handleEnter(t.Context()) @@ -191,7 +192,7 @@ func TestEditorSubmitWhileBusySteersAndRendersAtStreamEnd(t *testing.T) { assert.Empty(t, m.queue) assert.Len(t, m.pendingUsers, 1) - joined := strings.Join(m.transcript.lines(80, 0, true, m.sessionState, m.pendingUsers), "\n") + joined := strings.Join(m.screen.Transcript.Lines(80, 0, true, m.sessionState, m.pendingUsers), "\n") assistantAt := strings.Index(joined, "assistant is still streaming") steerAt := strings.Index(joined, "turn left") assert.NotEqual(t, -1, assistantAt) @@ -203,14 +204,14 @@ func TestSteeredUserEventConfirmsPendingAfterAssistant(t *testing.T) { t.Parallel() m := bareModel(24) m.busy = true - m.transcript.appendPending(blockAssistant, "assistant response") - m.addPendingUser("/change", "resolved steering prompt", pendingUserSteer) + m.screen.Transcript.AppendAssistant("assistant response") + m.addPendingUser("/change", "resolved steering prompt", ui.PendingUserSteer) m.handleEvent(t.Context(), runtime.UserMessage("resolved steering prompt\n", "session", nil, 1)) assert.Empty(t, m.pendingUsers) - assert.Len(t, m.transcript.blocks, 2) - joined := strings.Join(m.transcript.lines(80, 0, true, m.sessionState, nil), "\n") + assert.Equal(t, 2, m.screen.Transcript.BlockCount()) + joined := strings.Join(m.screen.Transcript.Lines(80, 0, true, m.sessionState, nil), "\n") assistantAt := strings.Index(joined, "assistant response") steerAt := strings.Index(joined, "/change") assert.NotEqual(t, -1, assistantAt) diff --git a/pkg/leantui/usage.go b/pkg/leantui/usage.go index c3674ed074..27f5794f90 100644 --- a/pkg/leantui/usage.go +++ b/pkg/leantui/usage.go @@ -1,110 +1,19 @@ package leantui -import "github.com/docker/docker-agent/pkg/runtime" - -// usageSnapshot is the per-session token and cost usage summarized in the -// status footer. -type usageSnapshot struct { - contextLength int64 - contextLimit int64 - tokens int64 - cost float64 -} - -// usageTracker aggregates per-session token usage so the footer can show the -// active session's context window alongside the total cost of the whole run -// (the root session plus any nested agent sessions). It keeps a stack of -// in-flight sessions so the "active" session is whichever stream is on top. -type usageTracker struct { - bySession map[string]usageSnapshot - rootSessionID string - latestSessionID string - stack []string -} - -func newUsageTracker() *usageTracker { - return &usageTracker{bySession: map[string]usageSnapshot{}} -} - -func (u *usageTracker) reset() { - u.bySession = map[string]usageSnapshot{} - u.rootSessionID = "" - u.latestSessionID = "" - u.stack = nil -} - -// streamStarted pushes a newly-started session onto the active stack, adopting -// the first one as the root session. -func (u *usageTracker) streamStarted(sessionID string) { - if sessionID == "" { - return - } - if len(u.stack) == 0 { - u.rootSessionID = sessionID - } - u.stack = append(u.stack, sessionID) -} - -// streamStopped pops the most recently-started session off the active stack. -func (u *usageTracker) streamStopped() { - if n := len(u.stack); n > 0 { - u.stack = u.stack[:n-1] - } -} - -// record stores usage for a session, adopting the first session seen as the -// root when no stream has started yet. -func (u *usageTracker) record(sessionID string, snapshot usageSnapshot) { - if u.rootSessionID == "" && len(u.bySession) == 0 { - u.rootSessionID = sessionID - } - u.bySession[sessionID] = snapshot - u.latestSessionID = sessionID -} - -func (u *usageTracker) empty() bool { return len(u.bySession) == 0 } - -func (u *usageTracker) totalCost() float64 { - var total float64 - for _, usage := range u.bySession { - total += usage.cost - } - return total -} - -// active returns the usage of the session whose context the footer should show: -// the top of the active stack, else the root, else the most recent, else the -// sole recorded session. -func (u *usageTracker) active() (usageSnapshot, bool) { - if n := len(u.stack); n > 0 { - usage, ok := u.bySession[u.stack[n-1]] - return usage, ok - } - if u.rootSessionID != "" { - usage, ok := u.bySession[u.rootSessionID] - return usage, ok - } - if u.latestSessionID != "" { - usage, ok := u.bySession[u.latestSessionID] - return usage, ok - } - if len(u.bySession) == 1 { - for _, usage := range u.bySession { - return usage, true - } - } - return usageSnapshot{}, false -} +import ( + "github.com/docker/docker-agent/pkg/leantui/ui" + "github.com/docker/docker-agent/pkg/runtime" +) // trackStreamStarted records a newly-started stream and refreshes the footer. func (m *model) trackStreamStarted(sessionID string) { - m.usage.streamStarted(sessionID) + m.usage.StreamStarted(sessionID) m.applyUsageSnapshot() } // trackStreamStopped records a finished stream and refreshes the footer. func (m *model) trackStreamStopped() { - m.usage.streamStopped() + m.usage.StreamStopped() m.applyUsageSnapshot() } @@ -113,45 +22,45 @@ func (m *model) setTokenUsage(sessionID string, usage *runtime.Usage) { return } - snapshot := usageSnapshot{ - contextLength: usage.ContextLength, - contextLimit: usage.ContextLimit, - tokens: usage.InputTokens + usage.OutputTokens, - cost: usage.Cost, + snapshot := ui.UsageSnapshot{ + ContextLength: usage.ContextLength, + ContextLimit: usage.ContextLimit, + Tokens: usage.InputTokens + usage.OutputTokens, + Cost: usage.Cost, } if sessionID == "" { // Once session-scoped usage exists, it is authoritative for the chat // footer. Empty-session usage comes from side work such as RAG indexing. - if m.usage.empty() { + if m.usage.Empty() { m.applyStatusUsage(snapshot, usage.Cost, true) } return } - m.usage.record(sessionID, snapshot) + m.usage.Record(sessionID, snapshot) m.applyUsageSnapshot() } // applyUsageSnapshot pushes the tracker's derived footer usage onto the status // line: the active session's context window plus the run's total cost. func (m *model) applyUsageSnapshot() { - if m.usage.empty() { + if m.usage.Empty() { return } - totalCost := m.usage.totalCost() - if usage, ok := m.usage.active(); ok { + totalCost := m.usage.TotalCost() + if usage, ok := m.usage.Active(); ok { m.applyStatusUsage(usage, totalCost, true) return } - m.status.cost = totalCost - m.status.costKnown = true + m.status.Cost = totalCost + m.status.CostKnown = true } -func (m *model) applyStatusUsage(usage usageSnapshot, cost float64, costKnown bool) { - m.status.contextLength = usage.contextLength - m.status.contextLimit = usage.contextLimit - m.status.tokens = usage.tokens - m.status.cost = cost - m.status.costKnown = costKnown +func (m *model) applyStatusUsage(usage ui.UsageSnapshot, cost float64, costKnown bool) { + m.status.ContextLength = usage.ContextLength + m.status.ContextLimit = usage.ContextLimit + m.status.Tokens = usage.Tokens + m.status.Cost = cost + m.status.CostKnown = costKnown } diff --git a/pkg/leantui/view.go b/pkg/leantui/view.go index 51a5ba9e7d..b92968adb4 100644 --- a/pkg/leantui/view.go +++ b/pkg/leantui/view.go @@ -1,45 +1,7 @@ package leantui -// buildLines produces the entire frame: the conversation, the slash-command -// popup, the input box (or a confirmation prompt) and the status footer. It -// returns the lines plus the hardware cursor position (a line index and column). +// buildLines produces the entire frame and cursor position. func (m *model) buildLines() (lines []string, cursorLine, cursorCol int) { - width := m.width - - lines = m.transcript.lines(width, m.spinnerFrame, m.busy, m.sessionState, m.pendingUsers) - - lines = append(lines, m.ac.render(width)...) - - inputStart := len(lines) - if m.confirm != nil { - confirmLines := m.confirm.render(width) - lines = append(lines, confirmLines...) - cursorLine = inputStart + max(len(confirmLines)-1, 0) - if len(confirmLines) > 0 { - cursorCol = min(displayWidth(confirmLines[len(confirmLines)-1]), max(width-1, 0)) - } - } else { - editorLines, row, col := m.editor.layout(width) - lines = append(lines, editorLines...) - cursorLine = inputStart + row - cursorCol = col - } - - lines = append(lines, "") - lines = append(lines, renderStatus(m.status, width)...) - - return lines, cursorLine, cursorCol -} - -// confirmState holds a pending tool-approval prompt. -type confirmState struct { - tool string // raw tool name, used to scope "always allow" - toolView toolView -} - -func (c *confirmState) render(width int) []string { - lines := []string{truncate(stWarning().Render("● Approve tool call"), width)} - lines = append(lines, renderTool(c.toolView, width)...) - lines = append(lines, truncate(stMuted().Render("[y] yes [a] always this tool [s] whole session [n] no"), width)) - return lines + m.screen.Status = m.status + return m.screen.Frame(m.width, m.height, m.spinnerFrame, m.busy, m.sessionState, m.pendingUsers) }