diff --git a/cmd/root/run.go b/cmd/root/run.go index c650c98f6..f55d5d67f 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -27,6 +27,7 @@ import ( "github.com/docker/docker-agent/pkg/teamloader" "github.com/docker/docker-agent/pkg/telemetry" "github.com/docker/docker-agent/pkg/tui" + "github.com/docker/docker-agent/pkg/tui/lean" "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/userconfig" ) @@ -49,6 +50,7 @@ type runExecFlags struct { cpuProfile string memProfile string forceTUI bool + lean bool sandbox bool sandboxTemplate string @@ -112,6 +114,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { _ = cmd.PersistentFlags().MarkHidden("memprofile") cmd.PersistentFlags().BoolVar(&flags.forceTUI, "force-tui", false, "Force TUI mode even when not in a terminal") _ = cmd.PersistentFlags().MarkHidden("force-tui") + cmd.PersistentFlags().BoolVar(&flags.lean, "lean", false, "Use the lean terminal UI") cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)") cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "", "Template image for the sandbox (passed to docker sandbox create -t)") cmd.MarkFlagsMutuallyExclusive("fake", "record") @@ -120,6 +123,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().BoolVar(&flags.exec, "exec", false, "Execute without a TUI") cmd.PersistentFlags().BoolVar(&flags.hideToolCalls, "hide-tool-calls", false, "Hide the tool calls in the output") cmd.PersistentFlags().BoolVar(&flags.outputJSON, "json", false, "Output results in JSON format") + cmd.MarkFlagsMutuallyExclusive("exec", "lean") } func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) error { @@ -137,7 +141,7 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) error { ctx := cmd.Context() out := cli.NewPrinter(cmd.OutOrStdout()) - useTUI := !f.exec && (f.forceTUI || isatty.IsTerminal(os.Stdout.Fd())) + useTUI := !f.exec && (f.lean || f.forceTUI || isatty.IsTerminal(os.Stdout.Fd())) return f.runOrExec(ctx, out, args, useTUI) } @@ -258,6 +262,14 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s return f.handleExecMode(ctx, out, rt, sess, args) } + if f.lean { + opts, err := f.buildLeanOptions(args) + if err != nil { + return err + } + return lean.Run(ctx, rt, sess, opts) + } + applyTheme() opts, err := f.buildAppOpts(args) if err != nil { @@ -420,10 +432,6 @@ func readInitialMessage(args []string) (*string, error) { } func (f *runExecFlags) launchTUI(ctx context.Context, out *cli.Printer, rt runtime.Runtime, sess *session.Session, args []string, useTUI bool) error { - if useTUI { - applyTheme() - } - if f.dryRun { out.Println("Dry run mode enabled. Agent initialized but will not execute.") return nil @@ -433,6 +441,15 @@ func (f *runExecFlags) launchTUI(ctx context.Context, out *cli.Printer, rt runti return f.handleExecMode(ctx, out, rt, sess, args) } + if f.lean { + opts, err := f.buildLeanOptions(args) + if err != nil { + return err + } + return lean.Run(ctx, rt, sess, opts) + } + + applyTheme() opts, err := f.buildAppOpts(args) if err != nil { return err @@ -463,6 +480,25 @@ func (f *runExecFlags) buildAppOpts(args []string) ([]app.Opt, error) { return opts, nil } +func (f *runExecFlags) buildLeanOptions(args []string) (lean.Options, error) { + firstMessage, err := readInitialMessage(args) + if err != nil { + return lean.Options{}, err + } + + opts := lean.Options{ + FirstMessageAttachment: f.attachmentPath, + ExitAfterFirstResponse: f.exitAfterResponse, + } + if firstMessage != nil { + opts.FirstMessage = *firstMessage + } + if len(args) > 2 { + opts.QueuedMessages = append(opts.QueuedMessages, args[2:]...) + } + return opts, nil +} + // buildSessionOpts returns the canonical set of session options derived from // CLI flags and agent configuration. Both the initial session and spawned // sessions use this method so their options never drift apart. diff --git a/pkg/tui/lean/app.go b/pkg/tui/lean/app.go new file mode 100644 index 000000000..5c7150646 --- /dev/null +++ b/pkg/tui/lean/app.go @@ -0,0 +1,641 @@ +package lean + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + tea "charm.land/bubbletea/v2" + + appcore "github.com/docker/docker-agent/pkg/app" + "github.com/docker/docker-agent/pkg/chat" + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/session" + "github.com/docker/docker-agent/pkg/tools" + tuiMessages "github.com/docker/docker-agent/pkg/tui/messages" +) + +type promptKind string + +const ( + promptToolConfirm promptKind = "tool-confirm" + promptMaxIters promptKind = "max-iters" + promptElicitation promptKind = "elicitation" +) + +type promptState struct { + kind promptKind + tool *runtime.ToolCallConfirmationEvent + maxIters int + elicitation *runtime.ElicitationRequestEvent +} + +type App struct { + application *appcore.App + options Options + descriptor SessionDescriptor + + ui *TUI + transcript *Container + pendingArea *Container + editor *Editor + header *HeaderBlock + footer *FooterBlock + md *markdownRenderer + + usage UsageTotals + connection ConnectionStatus + streaming bool + exiting bool + + toolsExpanded bool + thinkingHidden bool + hideToolResults bool + + cancel context.CancelFunc + runCancel context.CancelFunc + + currentAssistant *AssistantBlock + currentThinking *ThinkingBlock + currentAssistantText string + currentThinkingText string + currentInsertAnchor Component + toolBlocks map[string]*ToolBlock + thinkingBlocks []*ThinkingBlock + pendingPrompt *promptState + hasAssistantContent bool + queuedMessages []string + + shutdownCh chan struct{} +} + +func NewApp(application *appcore.App, descriptor SessionDescriptor, opts Options) *App { + terminal := NewProcessTerminal() + ui := NewTUI(terminal) + md := newMarkdownRenderer() + app := &App{ + application: application, + options: opts, + descriptor: descriptor, + ui: ui, + transcript: &Container{}, + pendingArea: &Container{}, + editor: NewEditor(), + header: &HeaderBlock{Descriptor: descriptor, Connection: ConnectionConnected}, + footer: &FooterBlock{Descriptor: descriptor}, + md: md, + connection: ConnectionConnected, + cancel: func() {}, + toolBlocks: make(map[string]*ToolBlock), + queuedMessages: append([]string(nil), opts.QueuedMessages...), + hideToolResults: application.Session() != nil && application.Session().HideToolResults, + shutdownCh: make(chan struct{}), + } + app.editor.OnSubmit(func(text string) { go app.handleSubmit(text, nil) }) + app.buildLayout() + app.installGlobalShortcuts() + return app +} + +func (a *App) Run() error { + history := a.application.Session().GetAllMessages() + a.renderHistory(history) + if len(history) == 0 { + a.appendTranscriptBlock(&BannerBlock{}) + } + a.syncUsageFromSession() + a.refreshChrome() + if err := a.ui.Start(); err != nil { + return err + } + a.ui.SetFocus(a.editor) + subscribeCtx, cancel := context.WithCancel(context.Background()) + a.cancel = cancel + go a.application.SubscribeWith(subscribeCtx, func(msg tea.Msg) { + if ev, ok := msg.(runtime.Event); ok { + a.handleEvent(ev) + } + }) + if a.options.FirstMessage != "" { + var attachments []tuiMessages.Attachment + if a.options.FirstMessageAttachment != "" { + attachments = append(attachments, tuiMessages.Attachment{ + Name: filepath.Base(a.options.FirstMessageAttachment), + FilePath: a.options.FirstMessageAttachment, + }) + } + go a.handleSubmit(a.options.FirstMessage, attachments) + } + <-a.shutdownCh + return nil +} + +func (a *App) buildLayout() { + a.ui.AddChild(a.header) + a.ui.AddChild(Spacer{Height: 1}) + a.ui.AddChild(a.transcript) + a.ui.AddChild(a.pendingArea) + a.ui.AddChild(a.editor) + a.ui.AddChild(a.footer) +} + +func (a *App) installGlobalShortcuts() { + a.ui.AddInputListener(func(data string) bool { + key := ParseKey(data) + if a.pendingPrompt != nil { + if a.handlePromptKey(key) { + return true + } + } + switch key { + case "ctrl+c": + a.shutdown() + return true + case "escape": + switch { + case a.pendingPrompt != nil: + a.cancelPrompt() + case a.streaming: + a.cancelActiveRun() + default: + a.shutdown() + } + return true + case "ctrl+t": + a.toggleToolsExpanded() + return true + case "ctrl+g": + a.toggleThinkingHidden() + return true + default: + return false + } + }) +} + +func (a *App) refreshChrome() { + a.header.Descriptor = a.descriptor + a.header.Connection = a.connection + a.header.ToolsExpanded = a.toolsExpanded + a.header.ThinkingHidden = a.thinkingHidden + a.header.PromptActive = a.pendingPrompt != nil + a.footer.Descriptor = a.descriptor + a.footer.Usage = a.usage + a.footer.Streaming = a.streaming + if a.pendingPrompt != nil { + a.pendingArea.SetChildren(a.pendingPromptBlock()) + } else { + a.pendingArea.SetChildren() + } + switch { + case a.streaming: + a.editor.SetBorderColor(yellowStyle.Render) + case a.pendingPrompt != nil: + a.editor.SetBorderColor(magentaStyle.Render) + default: + a.editor.SetBorderColor(blueStyle.Render) + } + a.editor.SetDisableSubmit(a.streaming || a.pendingPrompt != nil) + a.ui.RequestRender() +} + +func (a *App) setStreaming(streaming bool) { + a.streaming = streaming + a.refreshChrome() +} + +func (a *App) toggleToolsExpanded() { + a.toolsExpanded = !a.toolsExpanded + for _, tool := range a.toolBlocks { + tool.Expanded = a.toolsExpanded + } + a.refreshChrome() +} + +func (a *App) toggleThinkingHidden() { + a.thinkingHidden = !a.thinkingHidden + for _, block := range a.thinkingBlocks { + block.Hidden = a.thinkingHidden + } + a.refreshChrome() +} + +func (a *App) handleSubmit(text string, attachments []tuiMessages.Attachment) { + input := strings.TrimSpace(text) + if input == "" || a.streaming || a.pendingPrompt != nil { + return + } + a.appendTranscriptBlock(&UserBlock{Content: text}) + a.editor.Reset() + a.setStreaming(true) + runCtx, cancel := context.WithCancel(context.Background()) + a.runCancel = cancel + a.application.Run(runCtx, cancel, text, attachments) +} + +func (a *App) cancelActiveRun() { + if !a.streaming { + return + } + if a.runCancel != nil { + a.runCancel() + a.runCancel = nil + } + a.appendTranscriptBlock(&NoticeBlock{Message: "Cancellation requested…", Kind: "info"}) + a.ui.RequestRender() +} + +func (a *App) shutdown() { + if a.exiting { + return + } + a.exiting = true + if a.runCancel != nil { + a.runCancel() + a.runCancel = nil + } + a.cancel() + a.ui.Stop() + close(a.shutdownCh) +} + +func (a *App) handleEvent(event runtime.Event) { + if event == nil { + return + } + switch ev := event.(type) { + case *runtime.StreamStartedEvent: + a.setStreaming(true) + case *runtime.StreamStoppedEvent: + a.finalizeStreamingState() + a.runCancel = nil + a.setStreaming(false) + if a.options.ExitAfterFirstResponse && a.hasAssistantContent { + a.shutdown() + return + } + a.processNextQueuedMessage() + case *runtime.AgentChoiceReasoningEvent: + a.handleThinkingDelta(ev.Content) + case *runtime.AgentChoiceEvent: + a.handleAssistantDelta(ev.Content) + case *runtime.PartialToolCallEvent: + a.handleToolCallDelta(ev.ToolCall.ID, ev.ToolCall.Function.Name, ev.ToolCall.Function.Arguments) + case *runtime.ToolCallConfirmationEvent: + a.handleToolCallConfirmation(ev) + case *runtime.ToolCallEvent: + a.handleToolCallStart(ev.ToolCall.ID, ev.ToolCall.Function.Name, ev.ToolCall.Function.Arguments) + case *runtime.ToolCallResponseEvent: + a.handleToolCallResult(ev.ToolCallID, ev.ToolDefinition.Name, ev.Response, ev.Result != nil && ev.Result.IsError) + case *runtime.TokenUsageEvent: + a.applyUsage(ev.Usage) + case *runtime.ErrorEvent: + a.appendTranscriptBlock(&NoticeBlock{Message: ev.Error, Kind: "error"}) + case *runtime.WarningEvent: + a.appendTranscriptBlock(&NoticeBlock{Message: ev.Message, Kind: "info"}) + case *runtime.MaxIterationsReachedEvent: + a.pendingPrompt = &promptState{kind: promptMaxIters, maxIters: ev.MaxIterations} + a.setStreaming(false) + case *runtime.ElicitationRequestEvent: + a.pendingPrompt = &promptState{kind: promptElicitation, elicitation: ev} + a.setStreaming(false) + case *runtime.AgentInfoEvent: + a.descriptor.AgentName = orDefault(ev.AgentName, a.descriptor.AgentName) + a.descriptor.Model = orDefault(ev.Model, a.descriptor.Model) + if len(a.application.Session().GetAllMessages()) == 0 && ev.WelcomeMessage != "" { + a.appendTranscriptBlock(&NoticeBlock{Message: ev.WelcomeMessage, Kind: "info"}) + } + case *runtime.SessionTitleEvent: + // no-op for now; lean TUI keeps the header compact. + case *runtime.ModelFallbackEvent: + a.appendTranscriptBlock(&NoticeBlock{Message: fmt.Sprintf("Model fallback: %s → %s (%s)", ev.FailedModel, ev.FallbackModel, ev.Reason), Kind: "info"}) + case *runtime.HookBlockedEvent: + a.handleToolCallResult(ev.ToolCall.ID, ev.ToolDefinition.Name, ev.Message, true) + case *runtime.SessionCompactionEvent: + a.appendTranscriptBlock(&NoticeBlock{Message: fmt.Sprintf("Session compaction %s.", ev.Status), Kind: "info"}) + case *runtime.AgentSwitchingEvent: + if ev.Switching { + a.appendTranscriptBlock(&NoticeBlock{Message: fmt.Sprintf("Switching agent: %s → %s", ev.FromAgent, ev.ToAgent), Kind: "info"}) + } + case *runtime.UserMessageEvent: + if !a.streaming && strings.TrimSpace(ev.Message) != "" { + a.appendTranscriptBlock(&NoticeBlock{Message: ev.Message, Kind: "sub-agent"}) + } + } + a.refreshChrome() +} + +func (a *App) applyUsage(usage *runtime.Usage) { + if usage == nil { + return + } + a.usage.Prompt = usage.InputTokens + a.usage.Completion = usage.OutputTokens + a.usage.Total = usage.ContextLength + a.usage.Cost = usage.Cost + if usage.LastMessage != nil { + a.usage.CacheRead = usage.LastMessage.CachedInputTokens + a.usage.CacheCreation = usage.LastMessage.CacheWriteTokens + if usage.LastMessage.Model != "" { + a.descriptor.Model = usage.LastMessage.Model + } + } +} + +func (a *App) syncUsageFromSession() { + sess := a.application.Session() + if sess == nil { + return + } + a.usage.Prompt = sess.InputTokens + a.usage.Completion = sess.OutputTokens + a.usage.Total = sess.InputTokens + sess.OutputTokens + a.usage.Cost = sess.OwnCost() +} + +func (a *App) handleThinkingDelta(delta string) { + if delta == "" { + return + } + a.currentThinkingText += delta + if a.currentThinking == nil { + block := &ThinkingBlock{Content: a.currentThinkingText, Hidden: a.thinkingHidden, MD: a.md} + a.thinkingBlocks = append(a.thinkingBlocks, block) + a.appendTranscriptBlock(block) + a.currentThinking = block + return + } + a.currentThinking.Content = a.currentThinkingText +} + +func (a *App) handleAssistantDelta(delta string) { + if delta == "" { + return + } + a.hasAssistantContent = true + a.currentAssistantText += delta + if a.currentAssistant == nil { + block := &AssistantBlock{Content: a.currentAssistantText, MD: a.md} + a.appendTranscriptBlock(block) + a.currentAssistant = block + return + } + a.currentAssistant.Content = a.currentAssistantText +} + +func (a *App) getOrCreateToolBlock(toolCallID, name, initialArgs string) *ToolBlock { + if strings.TrimSpace(toolCallID) == "" { + return nil + } + if block, ok := a.toolBlocks[toolCallID]; ok { + return block + } + block := &ToolBlock{ID: toolCallID, ToolName: name, Args: initialArgs, Status: ToolPending, Expanded: a.toolsExpanded, HideToolResults: a.hideToolResults} + a.toolBlocks[toolCallID] = block + a.insertTranscriptBlockAfter(a.currentInsertAnchor, block) + return block +} + +func (a *App) handleToolCallDelta(toolCallID, name, argsDelta string) { + block := a.getOrCreateToolBlock(toolCallID, name, "") + if block == nil { + return + } + block.ToolName = name + block.Args += argsDelta + a.currentInsertAnchor = block +} + +func (a *App) handleToolCallConfirmation(ev *runtime.ToolCallConfirmationEvent) { + block := a.getOrCreateToolBlock(ev.ToolCall.ID, ev.ToolCall.Function.Name, ev.ToolCall.Function.Arguments) + if block != nil { + block.ToolName = ev.ToolCall.Function.Name + block.Status = ToolConfirmation + } + a.pendingPrompt = &promptState{kind: promptToolConfirm, tool: ev} + a.currentInsertAnchor = block +} + +func (a *App) handleToolCallStart(toolCallID, name, fullArgs string) { + block := a.getOrCreateToolBlock(toolCallID, name, fullArgs) + if block == nil { + return + } + block.ToolName = name + if len(fullArgs) >= len(block.Args) { + block.Args = fullArgs + } + block.Status = ToolRunning + a.pendingPrompt = nil + a.currentAssistant = nil + a.currentThinking = nil + a.currentAssistantText = "" + a.currentThinkingText = "" + a.currentInsertAnchor = block +} + +func (a *App) handleToolCallResult(toolCallID, name, content string, isError bool) { + block := a.getOrCreateToolBlock(toolCallID, name, "") + if block == nil { + return + } + block.ToolName = name + block.Result = content + if isError { + block.Status = ToolError + } else { + block.Status = ToolCompleted + } + a.currentInsertAnchor = block +} + +func (a *App) finalizeStreamingState() { + a.currentAssistant = nil + a.currentThinking = nil + a.currentAssistantText = "" + a.currentThinkingText = "" + a.currentInsertAnchor = nil + a.pendingPrompt = nil +} + +func (a *App) renderHistory(history []session.Message) { + toolResults := map[string]session.Message{} + for _, message := range history { + if message.Message.Role == chat.MessageRoleTool && message.Message.ToolCallID != "" { + toolResults[message.Message.ToolCallID] = message + } + } + for _, message := range history { + if message.Implicit { + continue + } + switch message.Message.Role { + case chat.MessageRoleUser: + a.appendTranscriptBlock(&UserBlock{Content: message.Message.Content}) + case chat.MessageRoleAssistant: + if message.Message.ReasoningContent != "" { + block := &ThinkingBlock{Content: message.Message.ReasoningContent, Hidden: a.thinkingHidden, MD: a.md} + a.thinkingBlocks = append(a.thinkingBlocks, block) + a.appendTranscriptBlock(block) + } + if message.Message.Content != "" { + a.appendTranscriptBlock(&AssistantBlock{Content: message.Message.Content, MD: a.md}) + } + for _, toolCall := range message.Message.ToolCalls { + tool := &ToolBlock{ID: toolCall.ID, ToolName: toolCall.Function.Name, Expanded: a.toolsExpanded, Args: toolCall.Function.Arguments, HideToolResults: a.hideToolResults} + if result, ok := toolResults[toolCall.ID]; ok { + tool.Result = result.Message.Content + if result.Message.IsError { + tool.Status = ToolError + } else { + tool.Status = ToolCompleted + } + } else { + tool.Status = ToolRunning + } + a.toolBlocks[tool.ID] = tool + a.appendTranscriptBlock(tool) + } + } + } +} + +func (a *App) appendTranscriptBlock(block Component) { + a.transcript.AddChild(block) + a.currentInsertAnchor = block +} + +func (a *App) insertTranscriptBlockAfter(anchor, block Component) { + if anchor == nil { + a.appendTranscriptBlock(block) + return + } + for i, child := range a.transcript.Children { + if child != anchor { + continue + } + children := append([]Component{}, a.transcript.Children[:i+1]...) + children = append(children, block) + children = append(children, a.transcript.Children[i+1:]...) + a.transcript.Children = children + a.currentInsertAnchor = block + return + } + a.appendTranscriptBlock(block) +} + +func (a *App) processNextQueuedMessage() { + if a.streaming || len(a.queuedMessages) == 0 || a.pendingPrompt != nil { + return + } + next := a.queuedMessages[0] + a.queuedMessages = a.queuedMessages[1:] + go a.handleSubmit(next, nil) +} + +func (a *App) pendingPromptBlock() Component { + if a.pendingPrompt == nil { + return nil + } + switch a.pendingPrompt.kind { + case promptToolConfirm: + toolName := a.pendingPrompt.tool.ToolCall.Function.Name + return &PromptBlock{ + Title: "tool approval", + Body: fmt.Sprintf("Allow %s to run?", toolName), + Actions: "Y approve once · N reject · T always allow this tool · A allow all for session · Esc reject", + } + case promptMaxIters: + return &PromptBlock{ + Title: "max iterations", + Body: fmt.Sprintf("The agent reached the max iteration limit (%d).", a.pendingPrompt.maxIters), + Actions: "Y continue · N stop", + } + case promptElicitation: + body := strings.TrimSpace(a.pendingPrompt.elicitation.Message) + if a.pendingPrompt.elicitation.URL != "" { + body += "\n\n" + a.pendingPrompt.elicitation.URL + } + actions := "Y accept · N decline · C cancel · Esc cancel" + if a.pendingPrompt.elicitation.Mode != "url" && !isOAuthPrompt(a.pendingPrompt.elicitation) { + actions = "N decline · C cancel · Esc cancel · form input requires full TUI" + } + return &PromptBlock{Title: "elicitation", Body: body, Actions: actions} + default: + return nil + } +} + +func isOAuthPrompt(ev *runtime.ElicitationRequestEvent) bool { + if ev == nil || ev.Meta == nil { + return false + } + kind, _ := ev.Meta["cagent/type"].(string) + return kind == "oauth_flow" +} + +func (a *App) handlePromptKey(key string) bool { + if a.pendingPrompt == nil { + return false + } + switch a.pendingPrompt.kind { + case promptToolConfirm: + switch strings.ToLower(key) { + case "y": + a.application.Resume(runtime.ResumeApprove()) + case "n", "escape": + a.application.Resume(runtime.ResumeReject("rejected in lean TUI")) + case "t": + pattern := a.pendingPrompt.tool.ToolCall.Function.Name + a.application.Resume(runtime.ResumeApproveTool(pattern)) + case "a": + a.application.Resume(runtime.ResumeApproveSession()) + default: + return false + } + a.pendingPrompt = nil + a.refreshChrome() + return true + case promptMaxIters: + switch strings.ToLower(key) { + case "y": + a.application.Resume(runtime.ResumeApprove()) + case "n", "escape": + a.application.Resume(runtime.ResumeReject("stopped after reaching max iterations")) + default: + return false + } + a.pendingPrompt = nil + a.refreshChrome() + return true + case promptElicitation: + var action tools.ElicitationAction + switch strings.ToLower(key) { + case "y": + if a.pendingPrompt.elicitation.Mode != "url" && !isOAuthPrompt(a.pendingPrompt.elicitation) { + return false + } + action = tools.ElicitationActionAccept + case "n": + action = tools.ElicitationActionDecline + case "c", "escape": + action = tools.ElicitationActionCancel + default: + return false + } + _ = a.application.ResumeElicitation(context.Background(), action, nil) + a.pendingPrompt = nil + a.refreshChrome() + return true + default: + return false + } +} + +func (a *App) cancelPrompt() { + if a.pendingPrompt == nil { + return + } + _ = a.handlePromptKey("escape") +} diff --git a/pkg/tui/lean/blocks.go b/pkg/tui/lean/blocks.go new file mode 100644 index 000000000..c4f1ecb9c --- /dev/null +++ b/pkg/tui/lean/blocks.go @@ -0,0 +1,392 @@ +package lean + +import ( + "image/color" + "strings" + + "charm.land/glamour/v2" + "charm.land/lipgloss/v2" + ansi "github.com/charmbracelet/x/ansi" +) + +type Component interface { + Render(width int) []string + Invalidate() +} + +type Container struct{ Children []Component } + +func (c *Container) AddChild(child Component) { c.Children = append(c.Children, child) } + +func (c *Container) SetChildren(children ...Component) { + c.Children = append([]Component(nil), children...) +} + +func (c *Container) Render(width int) []string { + var lines []string + for _, child := range c.Children { + lines = append(lines, child.Render(width)...) + } + return lines +} + +func (c *Container) Invalidate() { + for _, child := range c.Children { + child.Invalidate() + } +} + +type Spacer struct{ Height int } + +func (s Spacer) Render(width int) []string { + height := s.Height + if height <= 0 { + height = 1 + } + lines := make([]string, height) + for i := range lines { + lines[i] = padLine("", width, baseStyle.Render) + } + return lines +} + +func (s Spacer) Invalidate() {} + +type markdownRenderer struct{ cache map[int]*glamour.TermRenderer } + +func newMarkdownRenderer() *markdownRenderer { + return &markdownRenderer{cache: make(map[int]*glamour.TermRenderer)} +} + +func (md *markdownRenderer) render(text string, width int, preserveSpace bool) []string { + width = maxInt(20, width) + renderer, ok := md.cache[width] + if !ok { + r, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("dark"), + glamour.WithWordWrap(width), + glamour.WithPreservedNewLines(), + ) + if err == nil { + renderer = r + md.cache[width] = renderer + } + } + if renderer == nil { + return wrapPlain(text, width, preserveSpace) + } + out, err := renderer.Render(text) + if err != nil { + return wrapPlain(text, width, preserveSpace) + } + out = strings.TrimRight(out, "\n") + if out == "" { + return []string{""} + } + return strings.Split(out, "\n") +} + +type HeaderBlock struct { + Descriptor SessionDescriptor + Connection ConnectionStatus + ToolsExpanded bool + ThinkingHidden bool + PromptActive bool +} + +func (b *HeaderBlock) Render(width int) []string { + sessionShort := b.Descriptor.ID + if len(sessionShort) > 8 { + sessionShort = sessionShort[:8] + } + line1 := accentStyle.Render("docker-agent") + dimStyle.Render(" · ") + magentaStyle.Render(orDefault(b.Descriptor.AgentName, "unknown")) + if sessionShort != "" { + line1 += dimStyle.Render(" · ") + dimStyle.Render(sessionShort) + } + promptState := "none" + if b.PromptActive { + promptState = "open" + } + toolState := map[bool]string{true: "full", false: "preview"}[b.ToolsExpanded] + thinkingState := map[bool]string{true: "hidden", false: "shown"}[b.ThinkingHidden] + line2 := strings.Join([]string{ + "Enter send", + "Shift+Enter newline", + "Esc stop/quit", + "Ctrl+T tools:" + toolState, + "Ctrl+G thinking:" + thinkingState, + "prompt:" + promptState, + }, dimStyle.Render(" · ")) + return []string{padLine(line1, width, baseStyle.Render), padLine(mutedStyle.Render(line2), width, baseStyle.Render)} +} + +func (b *HeaderBlock) Invalidate() {} + +type FooterBlock struct { + Descriptor SessionDescriptor + Usage UsageTotals + Streaming bool +} + +func (b *FooterBlock) Render(width int) []string { + pathBits := []string{} + if b.Descriptor.WorkingDirectory != "" { + pathBits = append(pathBits, homeTilde(b.Descriptor.WorkingDirectory)) + } + if b.Descriptor.ID != "" { + pathBits = append(pathBits, b.Descriptor.ID) + } + pathLine := padLine(dimStyle.Render(middleTruncate(strings.Join(pathBits, " · "), width)), width, baseStyle.Render) + leftParts := []string{"ctx " + formatTokens(b.Usage.Prompt), "↓" + formatTokens(b.Usage.Completion)} + if b.Usage.CacheRead > 0 { + leftParts = append(leftParts, "🗎"+formatTokens(b.Usage.CacheRead)) + } + if b.Usage.Cost > 0 { + leftParts = append(leftParts, "$"+formatMoney(b.Usage.Cost)) + } + if b.Streaming { + leftParts = append(leftParts, yellowStyle.Render("working")) + } else { + leftParts = append(leftParts, dimStyle.Render("idle")) + } + leftStyled := mutedStyle.Render(strings.Join(leftParts, " · ")) + rightStyled := dimStyle.Render(orDefault(b.Descriptor.Model, "unknown model")) + if visibleWidth(leftStyled)+1+visibleWidth(rightStyled) <= width { + spaces := strings.Repeat(" ", maxInt(0, width-visibleWidth(leftStyled)-visibleWidth(rightStyled))) + return []string{pathLine, leftStyled + spaces + rightStyled} + } + return []string{pathLine, padLine(leftStyled+" "+rightStyled, width, baseStyle.Render)} +} + +func (b *FooterBlock) Invalidate() {} + +type UserBlock struct{ Content string } + +func (b *UserBlock) Render(width int) []string { + wrapped := wrapPlain(b.Content, maxInt(1, width-4), false) + lines := []string{"", padLine("", width, userBGStyle.Render)} + for _, line := range wrapped { + lines = append(lines, padLine(" "+line+" ", width, userBGStyle.Render)) + } + lines = append(lines, padLine("", width, userBGStyle.Render), "") + return lines +} + +func (b *UserBlock) Invalidate() {} + +type AssistantBlock struct { + Content string + MD *markdownRenderer +} + +func (b *AssistantBlock) Render(width int) []string { + return append([]string{""}, b.MD.render(b.Content, width, true)...) +} + +func (b *AssistantBlock) Invalidate() {} + +type ThinkingBlock struct { + Content string + Hidden bool + MD *markdownRenderer +} + +func (b *ThinkingBlock) Render(width int) []string { + if b.Hidden { + return []string{"", padLine(mutedStyle.Render("Thinking…"), width, baseStyle.Render)} + } + lines := []string{"", padLine(mutedStyle.Render("Thinking"), width, baseStyle.Render)} + for _, line := range b.MD.render(b.Content, width, true) { + lines = append(lines, padLine(mutedStyle.Italic(true).Render(line), width, baseStyle.Render)) + } + return lines +} + +func (b *ThinkingBlock) Invalidate() {} + +type ToolBlock struct { + ID string + ToolName string + Args string + Result string + Status ToolStatus + Expanded bool + HideToolResults bool +} + +func padStyledLine(text string, width int, bg color.Color) string { + if width <= 0 { + return "" + } + truncated := ansi.Truncate(text, width, "") + padding := max(0, width-ansi.StringWidth(truncated)) + return truncated + lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", padding)) +} + +func (b *ToolBlock) Render(width int) []string { + if strings.TrimSpace(b.ToolName) == "" && strings.TrimSpace(b.Args) == "" && strings.TrimSpace(b.Result) == "" { + return nil + } + previewResult := b.Result + if b.HideToolResults && previewResult != "" { + previewResult = "[hidden]" + } + preview := formatToolPreview(b.ToolName, b.Args, b.Status, previewResult, b.Expanded) + bgColor := toolBG + if b.Status == ToolError { + bgColor = errorBG + } + baseBg := lipgloss.NewStyle().Background(bgColor).Foreground(textColor) + mutedBg := lipgloss.NewStyle().Background(bgColor).Foreground(muted) + dimBg := lipgloss.NewStyle().Background(bgColor).Foreground(dim) + cyanBg := lipgloss.NewStyle().Background(bgColor).Foreground(cyan) + greenBg := lipgloss.NewStyle().Background(bgColor).Foreground(green) + yellowBg := lipgloss.NewStyle().Background(bgColor).Foreground(yellow) + redBg := lipgloss.NewStyle().Background(bgColor).Foreground(red) + + icon := dimBg.Render("○") + switch b.Status { + case ToolConfirmation: + icon = magentaStyle.Background(bgColor).Render("?") + case ToolRunning: + icon = yellowBg.Render("●") + case ToolCompleted: + icon = greenBg.Render("✓") + case ToolError: + icon = redBg.Render("✗") + } + lines := []string{"", padStyledLine("", width, bgColor), padStyledLine(baseBg.Render(" ")+icon+baseBg.Render(" ")+cyanBg.Render(b.ToolName)+baseBg.Render(" ")+dimBg.Render(preview.Summary), width, bgColor)} + for _, detail := range preview.Details { + switch detail { + case "args", "result", "error", "write preview", "diff preview", "input", "message": + styled := mutedBg.Render(detail) + switch detail { + case "error": + styled = redBg.Render(detail) + case "result": + styled = greenBg.Render(detail) + } + lines = append(lines, padStyledLine(baseBg.Render(" ")+styled, width, bgColor)) + default: + trimmed := strings.TrimSpace(detail) + for _, wrapped := range wrapPlain(detail, maxInt(1, width-4), true) { + styled := baseBg.Render(wrapped) + switch { + case strings.HasPrefix(trimmed, "+"): + styled = greenBg.Render(wrapped) + case strings.HasPrefix(trimmed, "-"): + styled = redBg.Render(wrapped) + case b.Status == ToolError: + styled = redBg.Render(wrapped) + } + lines = append(lines, padStyledLine(baseBg.Render(" ")+styled, width, bgColor)) + } + } + } + lines = append(lines, padStyledLine("", width, bgColor), "") + return lines +} + +func (b *ToolBlock) Invalidate() {} + +type BannerBlock struct{} + +func (b *BannerBlock) Render(width int) []string { + art := []string{ + `██████ ██████ ██████ ██ ██ ███████ ██████ █████ ██████ ███████ ███ ██ ████████`, + `██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ `, + `██ ██ ██ ██ ██ █████ █████ ██████ ███████ ██ ███ █████ ██ ██ ██ ██ `, + `██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ `, + `██████ ██████ ██████ ██ ██ ███████ ██ ██ ██ ██ ██████ ███████ ██ ████ ██ `, + } + if width <= 0 { + return nil + } + lines := []string{""} + for _, line := range art { + trimmed := ansi.Truncate(line, width, "") + styled := cyanStyle.Render(trimmed) + lines = append(lines, padLine(styled, width, baseStyle.Render)) + } + lines = append(lines, "") + return lines +} + +func (b *BannerBlock) Invalidate() {} + +type NoticeBlock struct { + Message string + Kind string +} + +func (b *NoticeBlock) Render(width int) []string { + bgColor := infoBG + labelText := "info" + labelStyle := lipgloss.NewStyle().Background(bgColor).Foreground(cyan) + textBg := lipgloss.NewStyle().Background(bgColor).Foreground(textColor) + dimBg := lipgloss.NewStyle().Background(bgColor).Foreground(dim) + + switch b.Kind { + case "error": + bgColor = errorBG + labelText = "error" + labelStyle = lipgloss.NewStyle().Background(bgColor).Foreground(red) + textBg = lipgloss.NewStyle().Background(bgColor).Foreground(textColor) + dimBg = lipgloss.NewStyle().Background(bgColor).Foreground(dim) + case "sub-agent": + bgColor = subAgentBG + labelText = "sub-agent" + labelStyle = lipgloss.NewStyle().Background(bgColor).Foreground(cyan) + textBg = lipgloss.NewStyle().Background(bgColor).Foreground(textColor) + dimBg = lipgloss.NewStyle().Background(bgColor).Foreground(dim) + } + + wrapped := wrapPlain(b.Message, maxInt(1, width-9), false) + lines := []string{"", padStyledLine("", width, bgColor)} + for i, line := range wrapped { + prefix := textBg.Render(" ") + if i == 0 { + prefix += labelStyle.Render(labelText) + textBg.Render(" ") + dimBg.Render("·") + textBg.Render(" ") + } else { + prefix += textBg.Render(" ") + } + lines = append(lines, padStyledLine(prefix+textBg.Render(line), width, bgColor)) + } + lines = append(lines, padStyledLine("", width, bgColor), "") + return lines +} + +func (b *NoticeBlock) Invalidate() {} + +type PromptBlock struct { + Title string + Body string + Actions string +} + +func (b *PromptBlock) Render(width int) []string { + labelStyle := lipgloss.NewStyle().Background(promptBG).Foreground(magenta) + textBg := lipgloss.NewStyle().Background(promptBG).Foreground(textColor) + mutedBg := lipgloss.NewStyle().Background(promptBG).Foreground(muted) + lines := []string{""} + lines = append(lines, padStyledLine("", width, promptBG)) + wrapped := wrapPlain(b.Body, maxInt(1, width-8), false) + for i, line := range wrapped { + prefix := textBg.Render(" ") + if i == 0 { + prefix += labelStyle.Render(b.Title) + textBg.Render(" ") + } else { + prefix += textBg.Render(strings.Repeat(" ", visibleWidth(b.Title)+1)) + } + lines = append(lines, padStyledLine(prefix+textBg.Render(line), width, promptBG)) + } + if b.Actions != "" { + for _, line := range wrapPlain(b.Actions, maxInt(1, width-4), false) { + lines = append(lines, padStyledLine(textBg.Render(" ")+mutedBg.Render(line), width, promptBG)) + } + } + lines = append(lines, padStyledLine("", width, promptBG)) + return lines +} + +func (b *PromptBlock) Invalidate() {} diff --git a/pkg/tui/lean/editor.go b/pkg/tui/lean/editor.go new file mode 100644 index 000000000..c9dfd493a --- /dev/null +++ b/pkg/tui/lean/editor.go @@ -0,0 +1,310 @@ +package lean + +import ( + "strings" + "unicode" + "unicode/utf8" + + ansi "github.com/charmbracelet/x/ansi" +) + +const cursorMarker = "\x1b_pi:c\a" + +type Focusable interface { + HandleInput(data string) + SetFocused(focused bool) + Focused() bool +} + +type Editor struct { + lines []string + cursorLine int + cursorCol int + focused bool + paddingX int + disableSubmit bool + borderColor func(...string) string + onSubmit func(string) +} + +func NewEditor() *Editor { + return &Editor{lines: []string{""}, paddingX: 1, borderColor: blueStyle.Render} +} + +func (e *Editor) Invalidate() {} +func (e *Editor) Focused() bool { return e.focused } +func (e *Editor) SetFocused(focused bool) { e.focused = focused } +func (e *Editor) SetBorderColor(fn func(...string) string) { e.borderColor = fn } +func (e *Editor) SetDisableSubmit(disable bool) { e.disableSubmit = disable } +func (e *Editor) OnSubmit(fn func(string)) { e.onSubmit = fn } +func (e *Editor) Value() string { return strings.Join(e.lines, "\n") } + +func (e *Editor) Reset() { + e.lines = []string{""} + e.cursorLine = 0 + e.cursorCol = 0 +} + +func (e *Editor) HandleInput(data string) { + if strings.HasPrefix(data, pastePrefix) && strings.HasSuffix(data, pasteSuffix) { + e.insertString(strings.TrimSuffix(strings.TrimPrefix(data, pastePrefix), pasteSuffix)) + return + } + + switch ParseKey(data) { + case "left": + e.moveLeft() + case "right": + e.moveRight() + case "up": + e.moveUp() + case "down": + e.moveDown() + case "home", "ctrl+a": + e.cursorCol = 0 + case "end", "ctrl+e": + e.cursorCol = len([]rune(e.currentLine())) + case "backspace": + e.backspace() + case "delete": + e.deleteForward() + case "ctrl+u": + e.deleteToStart() + case "ctrl+k": + e.deleteToEnd() + case "ctrl+w": + e.deleteWordBackward() + case "shift+enter", "alt+enter": + e.insertNewline() + case "enter": + if e.disableSubmit { + return + } + text := strings.TrimSpace(e.Value()) + if text == "" { + return + } + if e.onSubmit != nil { + e.onSubmit(e.Value()) + } + default: + if printable := DecodePrintable(data); printable != "" { + e.insertString(printable) + } + } +} + +func (e *Editor) Render(width int) []string { + if width <= 0 { + return nil + } + border := strings.Repeat("─", maxInt(1, width)) + border = e.borderColor(border) + contentWidth := maxInt(1, width-(e.paddingX*2)) + var content []string + for i, line := range e.lines { + content = append(content, e.renderLine(line, i == e.cursorLine, contentWidth)...) + } + if len(content) == 0 { + content = []string{cursorMarker} + } + lines := []string{border} + for _, line := range content { + lines = append(lines, padLine(strings.Repeat(" ", e.paddingX)+line+strings.Repeat(" ", e.paddingX), width, baseStyle.Render)) + } + lines = append(lines, border) + return lines +} + +func (e *Editor) renderLine(line string, withCursor bool, width int) []string { + runes := []rune(line) + cursorCol := e.cursorCol + if !withCursor || !e.focused { + cursorCol = -1 + } + out := []string{} + current := "" + currentWidth := 0 + currentIndex := 0 + + for i := 0; i <= len(runes); i++ { + if i == len(runes) { + if i == cursorCol { + current += cursorMarker + "\x1b[7m \x1b[0m" + } + out = append(out, current) + break + } + + r := string(runes[i]) + rw := ansi.StringWidth(r) + cell := r + cellWidth := rw + if i == cursorCol { + cell = cursorMarker + "\x1b[7m" + r + "\x1b[0m" + } + + if currentWidth+cellWidth > width && currentIndex > 0 { + out = append(out, current) + current = "" + currentWidth = 0 + currentIndex = 0 + } + + current += cell + currentWidth += cellWidth + currentIndex++ + } + + if len(out) == 0 { + return []string{""} + } + return out +} + +func (e *Editor) insertString(s string) { + for s != "" { + r, size := utf8.DecodeRuneInString(s) + if r == utf8.RuneError && size == 1 { + s = s[1:] + continue + } + if r == '\r' { + s = s[size:] + continue + } + if r == '\n' { + e.insertNewline() + s = s[size:] + continue + } + line := []rune(e.currentLine()) + before := string(line[:e.cursorCol]) + after := string(line[e.cursorCol:]) + e.lines[e.cursorLine] = before + string(r) + after + e.cursorCol++ + s = s[size:] + } +} + +func (e *Editor) insertNewline() { + line := []rune(e.currentLine()) + before := string(line[:e.cursorCol]) + after := string(line[e.cursorCol:]) + e.lines[e.cursorLine] = before + tail := append([]string{after}, e.lines[e.cursorLine+1:]...) + e.lines = append(e.lines[:e.cursorLine+1], tail...) + e.cursorLine++ + e.cursorCol = 0 +} + +func (e *Editor) backspace() { + if e.cursorCol > 0 { + line := []rune(e.currentLine()) + e.lines[e.cursorLine] = string(append(line[:e.cursorCol-1], line[e.cursorCol:]...)) + e.cursorCol-- + return + } + if e.cursorLine == 0 { + return + } + prev := e.lines[e.cursorLine-1] + cur := e.lines[e.cursorLine] + e.cursorCol = len([]rune(prev)) + e.lines[e.cursorLine-1] = prev + cur + e.lines = append(e.lines[:e.cursorLine], e.lines[e.cursorLine+1:]...) + e.cursorLine-- +} + +func (e *Editor) deleteForward() { + line := []rune(e.currentLine()) + if e.cursorCol < len(line) { + e.lines[e.cursorLine] = string(append(line[:e.cursorCol], line[e.cursorCol+1:]...)) + return + } + if e.cursorLine+1 >= len(e.lines) { + return + } + e.lines[e.cursorLine] += e.lines[e.cursorLine+1] + e.lines = append(e.lines[:e.cursorLine+1], e.lines[e.cursorLine+2:]...) +} + +func (e *Editor) deleteToStart() { + line := []rune(e.currentLine()) + e.lines[e.cursorLine] = string(line[e.cursorCol:]) + e.cursorCol = 0 +} + +func (e *Editor) deleteToEnd() { + line := []rune(e.currentLine()) + e.lines[e.cursorLine] = string(line[:e.cursorCol]) +} + +func (e *Editor) deleteWordBackward() { + line := []rune(e.currentLine()) + if e.cursorCol == 0 { + e.backspace() + return + } + start := e.cursorCol + for start > 0 && unicode.IsSpace(line[start-1]) { + start-- + } + for start > 0 && !unicode.IsSpace(line[start-1]) { + start-- + } + e.lines[e.cursorLine] = string(line[:start]) + string(line[e.cursorCol:]) + e.cursorCol = start +} + +func (e *Editor) moveLeft() { + if e.cursorCol > 0 { + e.cursorCol-- + return + } + if e.cursorLine > 0 { + e.cursorLine-- + e.cursorCol = len([]rune(e.currentLine())) + } +} + +func (e *Editor) moveRight() { + lineLen := len([]rune(e.currentLine())) + if e.cursorCol < lineLen { + e.cursorCol++ + return + } + if e.cursorLine+1 < len(e.lines) { + e.cursorLine++ + e.cursorCol = 0 + } +} + +func (e *Editor) moveUp() { + if e.cursorLine == 0 { + return + } + e.cursorLine-- + e.cursorCol = minInt(e.cursorCol, len([]rune(e.currentLine()))) +} + +func (e *Editor) moveDown() { + if e.cursorLine+1 >= len(e.lines) { + return + } + e.cursorLine++ + e.cursorCol = minInt(e.cursorCol, len([]rune(e.currentLine()))) +} + +func (e *Editor) currentLine() string { + if len(e.lines) == 0 { + e.lines = []string{""} + } + if e.cursorLine < 0 { + e.cursorLine = 0 + } + if e.cursorLine >= len(e.lines) { + e.cursorLine = len(e.lines) - 1 + } + return e.lines[e.cursorLine] +} diff --git a/pkg/tui/lean/keys.go b/pkg/tui/lean/keys.go new file mode 100644 index 000000000..a5c86c60f --- /dev/null +++ b/pkg/tui/lean/keys.go @@ -0,0 +1,290 @@ +package lean + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +const ( + modShift = 1 + modAlt = 2 + modCtrl = 4 + lockMask = 64 + 128 + + codepointEscape = 27 + codepointTab = 9 + codepointEnter = 13 + codepointSpace = 32 + codepointBackspace = 127 + + codepointArrowUp = -1 + codepointArrowDown = -2 + codepointArrowRight = -3 + codepointArrowLeft = -4 + + codepointDelete = -10 + codepointInsert = -11 + codepointHome = -12 + codepointEnd = -13 + codepointPageUp = -14 + codepointPageDown = -15 +) + +var ( + kittyCSIURe = regexp.MustCompile(`^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$`) + kittyArrowRe = regexp.MustCompile(`^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$`) + kittyFuncRe = regexp.MustCompile(`^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$`) + kittyHomeEndRe = regexp.MustCompile(`^\x1b\[1;(\d+)(?::(\d+))?([HF])$`) + modifyOtherRe = regexp.MustCompile(`^\x1b\[27;(\d+);(\d+)~$`) + symbolKeyLookup = map[rune]struct{}{'`': {}, '-': {}, '=': {}, '[': {}, ']': {}, '\\': {}, ';': {}, '\'': {}, ',': {}, '.': {}, '/': {}, '!': {}, '@': {}, '#': {}, '$': {}, '%': {}, '^': {}, '&': {}, '*': {}, '(': {}, ')': {}, '_': {}, '+': {}, '|': {}, '~': {}, '{': {}, '}': {}, ':': {}, '<': {}, '>': {}, '?': {}} +) + +type parsedKey struct { + codepoint int + modifier int + eventType int +} + +func ParseKey(data string) string { + switch data { + case keyShiftEnter: + return "shift+enter" + case keyAltEnter: + return "alt+enter" + } + + if kitty := parseKittySequence(data); kitty != nil { + if kitty.eventType == 3 { + return "" + } + return formatParsedKey(kitty.codepoint, kitty.modifier) + } + if mod := parseModifyOtherKeysSequence(data); mod != nil { + return formatParsedKey(mod.codepoint, mod.modifier) + } + + switch data { + case keyEscape: + return "escape" + case "\t": + return "tab" + case keyEnter, "\n": + return "enter" + case "\x00": + return "ctrl+space" + case " ": + return "space" + case keyBackspace, "\x08": + return "backspace" + case "\x1b[Z": + return "shift+tab" + case keyUp: + return "up" + case keyDown: + return "down" + case keyLeft: + return "left" + case keyRight: + return "right" + case keyHome, "\x1bOH": + return "home" + case keyEnd, "\x1bOF": + return "end" + case keyDelete: + return "delete" + case "\x1b[5~": + return "pageUp" + case "\x1b[6~": + return "pageDown" + } + + if len(data) == 1 { + b := data[0] + if b >= 1 && b <= 26 { + return "ctrl+" + string(rune(b+96)) + } + if b >= 32 && b < 127 { + return string(b) + } + } + + if strings.HasPrefix(data, "\x1b") && len(data) == 2 { + if b := data[1]; b >= 'a' && b <= 'z' { + return "alt+" + string(b) + } + } + + return "" +} + +func DecodePrintable(data string) string { + if s := decodeKittyPrintable(data); s != "" { + return s + } + if key := ParseKey(data); key != "" { + switch key { + case "space": + return " " + case "tab": + return "\t" + } + if len(key) == 1 { + return key + } + return "" + } + if strings.HasPrefix(data, "\x1b") { + return "" + } + if data == "" { + return "" + } + r, _ := utf8.DecodeRuneInString(data) + if r == utf8.RuneError || r < 32 { + return "" + } + return data +} + +func decodeKittyPrintable(data string) string { + m := kittyCSIURe.FindStringSubmatch(data) + if m == nil { + return "" + } + codepoint := atoiDefault(m[1], -1) + if codepoint < 0 { + return "" + } + shifted := -1 + if m[2] != "" { + shifted = atoiDefault(m[2], -1) + } + modifier := atoiDefault(m[4], 1) - 1 + eventType := atoiDefault(m[5], 1) + if eventType == 3 { + return "" + } + modifier &^= lockMask + if modifier&(modAlt|modCtrl) != 0 { + return "" + } + effective := codepoint + if modifier&modShift != 0 && shifted > 0 { + effective = shifted + } + if effective < 32 { + return "" + } + return string(rune(effective)) +} + +func parseKittySequence(data string) *parsedKey { + if m := kittyCSIURe.FindStringSubmatch(data); m != nil { + return &parsedKey{codepoint: atoiDefault(m[1], -1), modifier: atoiDefault(m[4], 1) - 1, eventType: atoiDefault(m[5], 1)} + } + if m := kittyArrowRe.FindStringSubmatch(data); m != nil { + modifier := atoiDefault(m[1], 1) - 1 + codepoint := codepointArrowUp + switch m[3] { + case "B": + codepoint = codepointArrowDown + case "C": + codepoint = codepointArrowRight + case "D": + codepoint = codepointArrowLeft + } + return &parsedKey{codepoint: codepoint, modifier: modifier, eventType: atoiDefault(m[2], 1)} + } + if m := kittyFuncRe.FindStringSubmatch(data); m != nil { + modifier := atoiDefault(m[2], 1) - 1 + eventType := atoiDefault(m[3], 1) + funcCodes := map[int]int{2: codepointInsert, 3: codepointDelete, 5: codepointPageUp, 6: codepointPageDown, 7: codepointHome, 8: codepointEnd} + if cp, ok := funcCodes[atoiDefault(m[1], -1)]; ok { + return &parsedKey{codepoint: cp, modifier: modifier, eventType: eventType} + } + } + if m := kittyHomeEndRe.FindStringSubmatch(data); m != nil { + modifier := atoiDefault(m[1], 1) - 1 + codepoint := codepointHome + if m[3] == "F" { + codepoint = codepointEnd + } + return &parsedKey{codepoint: codepoint, modifier: modifier, eventType: atoiDefault(m[2], 1)} + } + return nil +} + +func parseModifyOtherKeysSequence(data string) *parsedKey { + m := modifyOtherRe.FindStringSubmatch(data) + if m == nil { + return nil + } + return &parsedKey{codepoint: atoiDefault(m[2], -1), modifier: atoiDefault(m[1], 1) - 1, eventType: 1} +} + +func formatParsedKey(codepoint, modifier int) string { + modifier &^= lockMask + keyName := "" + switch codepoint { + case codepointEscape: + keyName = "escape" + case codepointTab: + keyName = "tab" + case codepointEnter: + keyName = "enter" + case codepointSpace: + keyName = "space" + case codepointBackspace: + keyName = "backspace" + case codepointDelete: + keyName = "delete" + case codepointInsert: + keyName = "insert" + case codepointHome: + keyName = "home" + case codepointEnd: + keyName = "end" + case codepointPageUp: + keyName = "pageUp" + case codepointPageDown: + keyName = "pageDown" + case codepointArrowUp: + keyName = "up" + case codepointArrowDown: + keyName = "down" + case codepointArrowLeft: + keyName = "left" + case codepointArrowRight: + keyName = "right" + default: + r := rune(codepoint) + if r >= '0' && r <= '9' { + keyName = string(r) + } else if r >= 'a' && r <= 'z' { + keyName = string(r) + } else if _, ok := symbolKeyLookup[r]; ok { + keyName = string(r) + } + } + if keyName == "" { + return "" + } + return formatKeyNameWithModifiers(keyName, modifier) +} + +func formatKeyNameWithModifiers(keyName string, modifier int) string { + modifier &^= lockMask + parts := make([]string, 0, 4) + if modifier&modCtrl != 0 { + parts = append(parts, "ctrl") + } + if modifier&modShift != 0 { + parts = append(parts, "shift") + } + if modifier&modAlt != 0 { + parts = append(parts, "alt") + } + parts = append(parts, keyName) + return strings.Join(parts, "+") +} diff --git a/pkg/tui/lean/preview.go b/pkg/tui/lean/preview.go new file mode 100644 index 000000000..ca110a78a --- /dev/null +++ b/pkg/tui/lean/preview.go @@ -0,0 +1,193 @@ +package lean + +import ( + "encoding/json" + "fmt" + "strings" +) + +type ToolPreview struct { + Summary string + Details []string +} + +func formatToolPreview(toolName, rawArgs string, status ToolStatus, result string, expanded bool) ToolPreview { + args := map[string]any{} + parsed := false + if err := json.Unmarshal([]byte(rawArgs), &args); err == nil { + parsed = true + } + details := []string{} + if !parsed { + partial := strings.TrimSpace(rawArgs) + if partial != "" { + details = append(details, "args") + details = append(details, previewText(partial, ternaryInt(expanded, 16, 4))...) + } + if result != "" { + if status == ToolError { + details = append(details, "error") + } else { + details = append(details, "result") + } + details = append(details, previewText(result, ternaryInt(expanded, 20, 6))...) + } + return ToolPreview{Summary: toolName, Details: details} + } + + summary := toolName + switch toolName { + case "read_file": + path := getString(args["path"]) + if path == "" { + path = "(unknown path)" + } + start := getNumber(args["start_line"]) + end := getNumber(args["end_line"]) + switch { + case start != nil && end != nil: + summary = fmt.Sprintf("%s:%d-%d", path, int(*start), int(*end)) + case start != nil: + summary = fmt.Sprintf("%s:%d", path, int(*start)) + default: + summary = path + } + case "write_file": + path := orDefault(getString(args["path"]), "(unknown path)") + content := getString(args["content"]) + summary = fmt.Sprintf("%s (%d lines)", path, countLines(content)) + details = append(details, "write preview") + details = append(details, previewText(content, ternaryInt(expanded, 18, 6))...) + case "edit_file": + path := orDefault(getString(args["path"]), "(unknown path)") + summary = path + diff := buildSimpleDiff(getString(args["old_text"]), getString(args["new_text"])) + if len(diff) > 0 { + details = append(details, "diff preview") + limit := ternaryInt(expanded, 20, 8) + if len(diff) > limit { + diff = diff[:limit] + } + details = append(details, diff...) + } + case "run_command", "shell", "run_shell", "run_shell_background", "run_background_job": + cmd := oneLine(getString(args["command"])) + if cmd == "" { + cmd = oneLine(getString(args["cmd"])) + } + if cmd == "" { + cmd = "(empty command)" + } + summary = "$ " + cmd + if cwd := getString(args["cwd"]); cwd != "" { + details = append(details, "cwd: "+cwd) + } + if timeout := getNumber(args["timeout"]); timeout != nil { + details = append(details, fmt.Sprintf("timeout: %gs", *timeout)) + } + case "list_directory", "directory_tree": + path := orDefault(getString(args["path"]), ".") + suffixes := []string{} + if depth := getNumber(args["depth"]); depth != nil { + suffixes = append(suffixes, fmt.Sprintf("depth=%d", int(*depth))) + } + if limit := getNumber(args["limit"]); limit != nil { + suffixes = append(suffixes, fmt.Sprintf("limit=%d", int(*limit))) + } + if len(suffixes) > 0 { + summary = fmt.Sprintf("%s (%s)", path, strings.Join(suffixes, ", ")) + } else { + summary = path + } + case "search", "search_files_content": + pattern := oneLine(getString(args["pattern"])) + if pattern == "" { + pattern = oneLine(getString(args["query"])) + } + path := orDefault(getString(args["path"]), ".") + if pattern == "" { + pattern = "(empty pattern)" + } + summary = fmt.Sprintf("%s in %s", pattern, path) + if glob := getString(args["glob"]); glob != "" { + details = append(details, "glob: "+glob) + } + case "run_agent", "task", "transfer_task", "handoff": + agent := orDefault(getString(args["agent"]), orDefault(getString(args["name"]), "child")) + summary = agent + if input := getString(args["task"]); input != "" { + details = append(details, "input") + details = append(details, previewText(input, ternaryInt(expanded, 12, 4))...) + } + } + + if result != "" { + if status == ToolError { + details = append(details, "error") + } else { + details = append(details, "result") + } + details = append(details, previewText(result, ternaryInt(expanded, 20, 6))...) + } + + return ToolPreview{Summary: summary, Details: details} +} + +func previewText(text string, maxLines int) []string { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return []string{"(empty)"} + } + lines := strings.Split(trimmed, "\n") + if len(lines) <= maxLines { + return lines + } + return append(lines[:maxLines], fmt.Sprintf("… %d more line(s)", len(lines)-maxLines)) +} + +func buildSimpleDiff(oldText, newText string) []string { + oldLines := strings.Split(oldText, "\n") + newLines := strings.Split(newText, "\n") + maxLines := maxInt(len(oldLines), len(newLines)) + out := make([]string, 0, maxLines*2) + for i := range maxLines { + var before, after string + if i < len(oldLines) { + before = oldLines[i] + } + if i < len(newLines) { + after = newLines[i] + } + if before == after { + if i < len(oldLines) { + out = append(out, " "+before) + } + continue + } + if i < len(oldLines) { + out = append(out, "- "+before) + } + if i < len(newLines) { + out = append(out, "+ "+after) + } + } + return out +} + +func countLines(s string) int { + if s == "" { + return 1 + } + return strings.Count(s, "\n") + 1 +} + +func oneLine(s string) string { return strings.Join(strings.Fields(s), " ") } +func getString(v any) string { s, _ := v.(string); return s } + +func getNumber(v any) *float64 { + n, ok := v.(float64) + if !ok { + return nil + } + return &n +} diff --git a/pkg/tui/lean/preview_test.go b/pkg/tui/lean/preview_test.go new file mode 100644 index 000000000..755c268b1 --- /dev/null +++ b/pkg/tui/lean/preview_test.go @@ -0,0 +1,18 @@ +package lean + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFormatToolPreviewShellUsesCmdField(t *testing.T) { + preview := formatToolPreview("shell", `{"cmd":"go test ./...","cwd":"/tmp"}`, ToolRunning, "", false) + require.Equal(t, "$ go test ./...", preview.Summary) + require.Contains(t, preview.Details, "cwd: /tmp") +} + +func TestFormatToolPreviewBackgroundJobUsesCmdField(t *testing.T) { + preview := formatToolPreview("run_background_job", `{"cmd":"npm run dev"}`, ToolPending, "", false) + require.Equal(t, "$ npm run dev", preview.Summary) +} diff --git a/pkg/tui/lean/run.go b/pkg/tui/lean/run.go new file mode 100644 index 000000000..4d532e94f --- /dev/null +++ b/pkg/tui/lean/run.go @@ -0,0 +1,19 @@ +package lean + +import ( + "context" + + appcore "github.com/docker/docker-agent/pkg/app" + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/session" +) + +func Run(ctx context.Context, rt runtime.Runtime, sess *session.Session, opts Options) error { + appOpts := []appcore.Opt{} + if gen := rt.TitleGenerator(); gen != nil { + appOpts = append(appOpts, appcore.WithTitleGenerator(gen)) + } + a := appcore.New(ctx, rt, sess, appOpts...) + model := NewApp(a, DescriptorFromState(rt, sess), opts) + return model.Run() +} diff --git a/pkg/tui/lean/terminal.go b/pkg/tui/lean/terminal.go new file mode 100644 index 000000000..1a411e809 --- /dev/null +++ b/pkg/tui/lean/terminal.go @@ -0,0 +1,307 @@ +package lean + +import ( + "os" + "strings" + "sync" + "time" + "unicode/utf8" + + "golang.org/x/term" +) + +const ( + keyEscape = "\x1b" + keyEnter = "\r" + keyBackspace = "\x7f" + keyLeft = "\x1b[D" + keyRight = "\x1b[C" + keyUp = "\x1b[A" + keyDown = "\x1b[B" + keyHome = "\x1b[H" + keyEnd = "\x1b[F" + keyDelete = "\x1b[3~" + keyShiftEnter = "shift+enter" + keyAltEnter = "alt+enter" + pastePrefix = "\x1b[200~" + pasteSuffix = "\x1b[201~" +) + +type ProcessTerminal struct { + in *os.File + out *os.File + state *term.State + onInput func(string) + onResize func() + stopCh chan struct{} + stopOnce sync.Once + inputBuf string + pasteBuf strings.Builder + inPaste bool + kitty bool + modOther bool + writeMu sync.Mutex + lastInputMu sync.Mutex + lastInputAt time.Time +} + +func NewProcessTerminal() *ProcessTerminal { + return &ProcessTerminal{in: os.Stdin, out: os.Stdout, stopCh: make(chan struct{})} +} + +func (t *ProcessTerminal) Start(onInput func(string), onResize func()) error { + st, err := term.MakeRaw(int(t.in.Fd())) + if err != nil { + return err + } + t.state = st + t.onInput = onInput + t.onResize = onResize + t.lastInputMu.Lock() + t.lastInputAt = time.Now() + t.lastInputMu.Unlock() + t.write("\x1b[?2004h") + t.write("\x1b[?25l") + t.write("\x1b[?u") + go func() { + time.Sleep(150 * time.Millisecond) + if !t.kitty && !t.modOther { + t.write("\x1b[>4;2m") + t.modOther = true + } + }() + go t.readLoop() + go t.resizeLoop() + return nil +} + +func (t *ProcessTerminal) Stop() { + t.stopOnce.Do(func() { + close(t.stopCh) + t.write("\x1b[?2004l") + if t.kitty { + t.write("\x1b[4;0m") + } + t.write("\x1b[?25h") + if t.state != nil { + _ = term.Restore(int(t.in.Fd()), t.state) + } + }) +} + +func (t *ProcessTerminal) DrainInput(maxWait, idleWait time.Duration) { + if t.kitty { + t.write("\x1b[4;0m") + t.modOther = false + } + + previousHandler := t.onInput + t.onInput = nil + defer func() { t.onInput = previousHandler }() + + t.inputBuf = "" + t.pasteBuf.Reset() + t.inPaste = false + + endTime := time.Now().Add(maxWait) + for { + now := time.Now() + if !now.Before(endTime) { + return + } + t.lastInputMu.Lock() + lastInputAt := t.lastInputAt + t.lastInputMu.Unlock() + if now.Sub(lastInputAt) >= idleWait { + return + } + time.Sleep(minDuration(idleWait, time.Until(endTime))) + } +} + +func (t *ProcessTerminal) Columns() int { + w, _, err := term.GetSize(int(t.out.Fd())) + if err != nil || w <= 0 { + return 80 + } + return w +} + +func (t *ProcessTerminal) Rows() int { + _, h, err := term.GetSize(int(t.out.Fd())) + if err != nil || h <= 0 { + return 24 + } + return h +} + +func (t *ProcessTerminal) Write(data string) { t.write(data) } +func (t *ProcessTerminal) HideCursor() { t.write("\x1b[?25l") } +func (t *ProcessTerminal) ClearScreen() { t.write("\x1b[2J\x1b[H") } + +func (t *ProcessTerminal) write(data string) { + t.writeMu.Lock() + defer t.writeMu.Unlock() + _, _ = t.out.WriteString(data) +} + +func (t *ProcessTerminal) resizeLoop() { + lastW, lastH := t.Columns(), t.Rows() + ticker := time.NewTicker(120 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-t.stopCh: + return + case <-ticker.C: + w, h := t.Columns(), t.Rows() + if w != lastW || h != lastH { + lastW, lastH = w, h + if t.onResize != nil { + t.onResize() + } + } + } + } +} + +func (t *ProcessTerminal) readLoop() { + buf := make([]byte, 4096) + for { + select { + case <-t.stopCh: + return + default: + } + n, err := t.in.Read(buf) + if err != nil { + return + } + if n > 0 { + t.feed(string(buf[:n])) + } + } +} + +func (t *ProcessTerminal) feed(data string) { + if data != "" { + t.lastInputMu.Lock() + t.lastInputAt = time.Now() + t.lastInputMu.Unlock() + } + t.inputBuf += data + for { + if t.inPaste { + if idx := strings.Index(t.inputBuf, pasteSuffix); idx >= 0 { + t.pasteBuf.WriteString(t.inputBuf[:idx]) + payload := t.pasteBuf.String() + t.pasteBuf.Reset() + t.inPaste = false + t.inputBuf = t.inputBuf[idx+len(pasteSuffix):] + t.emit(pastePrefix + payload + pasteSuffix) + continue + } + t.pasteBuf.WriteString(t.inputBuf) + t.inputBuf = "" + return + } + if t.inputBuf == "" { + return + } + if strings.HasPrefix(t.inputBuf, pastePrefix) { + t.inPaste = true + t.inputBuf = t.inputBuf[len(pastePrefix):] + continue + } + tok, rest, ok := nextToken(t.inputBuf) + if !ok { + return + } + t.inputBuf = rest + if tok == "\x1b[?1u" || strings.HasPrefix(tok, "\x1b[?") && strings.HasSuffix(tok, "u") { + t.kitty = true + t.write("\x1b[>7u") + continue + } + t.emit(normalizeToken(tok)) + } +} + +func (t *ProcessTerminal) emit(data string) { + if t.onInput != nil { + t.onInput(data) + } +} + +func nextToken(s string) (token, rest string, ok bool) { + if s == "" { + return "", s, false + } + if s[0] != 0x1b { + if len(s) >= 1 && (s[0]&0x80) == 0 { + return s[:1], s[1:], true + } + _, size := decodeRune(s) + if size == 0 || len(s) < size { + return "", s, false + } + return s[:size], s[size:], true + } + known := []string{"\x1b[A", "\x1b[B", "\x1b[C", "\x1b[D", "\x1b[H", "\x1b[F", "\x1b[3~", "\x1b[Z", "\x1bOH", "\x1bOF", "\x1b\r", "\x1b[13;2u", "\x1b[27;2;13~", "\x1b[200~", "\x1b[201~"} + for _, k := range known { + if strings.HasPrefix(s, k) { + return k, s[len(k):], true + } + if strings.HasPrefix(k, s) { + return "", s, false + } + } + if strings.HasPrefix(s, "\x1b[") { + for i := 2; i < len(s); i++ { + c := s[i] + if (c >= '@' && c <= '~') || c == 'u' { + return s[:i+1], s[i+1:], true + } + } + return "", s, false + } + if len(s) >= 2 { + return s[:2], s[2:], true + } + return "", s, false +} + +func normalizeToken(tok string) string { + switch tok { + case "\n": + return keyEnter + case "\x1bOH": + return keyHome + case "\x1bOF": + return keyEnd + case "\x1b\r": + return keyAltEnter + case "\x1b[13;2u", "\x1b[27;2;13~": + return keyShiftEnter + default: + return tok + } +} + +func decodeRune(s string) (rune, int) { + if s == "" { + return 0, 0 + } + r, size := utf8.DecodeRuneInString(s) + if r == utf8.RuneError && size == 1 { + return 0, 0 + } + return r, size +} diff --git a/pkg/tui/lean/theme.go b/pkg/tui/lean/theme.go new file mode 100644 index 000000000..4f142b3b6 --- /dev/null +++ b/pkg/tui/lean/theme.go @@ -0,0 +1,31 @@ +package lean + +import "charm.land/lipgloss/v2" + +var ( + accent = lipgloss.Color("#8b5cf6") + blue = lipgloss.Color("#3b82f6") + cyan = lipgloss.Color("#38bdf8") + green = lipgloss.Color("#34d399") + yellow = lipgloss.Color("#fbbf24") + red = lipgloss.Color("#f87171") + magenta = lipgloss.Color("#c084fc") + muted = lipgloss.Color("#94a3b8") + dim = lipgloss.Color("#64748b") + textColor = lipgloss.Color("#e2e8f0") + userBG = lipgloss.Color("#1f2942") + toolBG = lipgloss.Color("#111827") + infoBG = lipgloss.Color("#172554") + errorBG = lipgloss.Color("#3f1d1d") + promptBG = lipgloss.Color("#1e293b") + subAgentBG = lipgloss.Color("#0f172a") + baseStyle = lipgloss.NewStyle().Foreground(textColor) + accentStyle = lipgloss.NewStyle().Foreground(accent) + blueStyle = lipgloss.NewStyle().Foreground(blue) + cyanStyle = lipgloss.NewStyle().Foreground(cyan) + yellowStyle = lipgloss.NewStyle().Foreground(yellow) + magentaStyle = lipgloss.NewStyle().Foreground(magenta) + mutedStyle = lipgloss.NewStyle().Foreground(muted) + dimStyle = lipgloss.NewStyle().Foreground(dim) + userBGStyle = lipgloss.NewStyle().Background(userBG).Foreground(textColor) +) diff --git a/pkg/tui/lean/tui.go b/pkg/tui/lean/tui.go new file mode 100644 index 000000000..aa8f3874b --- /dev/null +++ b/pkg/tui/lean/tui.go @@ -0,0 +1,325 @@ +package lean + +import ( + "strconv" + "strings" + "sync" + "time" + + ansi "github.com/charmbracelet/x/ansi" +) + +type InputListener func(data string) (consume bool) + +type TUI struct { + terminal *ProcessTerminal + children []Component + focused Focusable + inputs []InputListener + + previousLines []string + previousWidth int + previousHeight int + cursorRow int + hardwareCursorRow int + maxLinesRendered int + previousViewport int + + renderMu sync.Mutex + requested bool + stopped bool +} + +func NewTUI(terminal *ProcessTerminal) *TUI { return &TUI{terminal: terminal} } +func (t *TUI) AddChild(child Component) { t.children = append(t.children, child) } + +func (t *TUI) SetFocus(f Focusable) { + if t.focused != nil { + t.focused.SetFocused(false) + } + t.focused = f + if t.focused != nil { + t.focused.SetFocused(true) + } +} + +func (t *TUI) AddInputListener(listener InputListener) { t.inputs = append(t.inputs, listener) } + +func (t *TUI) Start() error { + if err := t.terminal.Start(t.handleInput, t.RequestRender); err != nil { + return err + } + t.RequestRender() + return nil +} + +func (t *TUI) Stop() { + if t.stopped { + return + } + t.stopped = true + t.terminal.DrainInput(time.Second, 50*time.Millisecond) + t.terminal.Stop() +} + +func (t *TUI) RequestRender() { + t.renderMu.Lock() + if t.requested || t.stopped { + t.renderMu.Unlock() + return + } + t.requested = true + t.renderMu.Unlock() + go func() { + time.Sleep(8 * time.Millisecond) + t.renderMu.Lock() + t.requested = false + t.renderMu.Unlock() + t.doRender() + }() +} + +func (t *TUI) handleInput(data string) { + for _, input := range t.inputs { + if input(data) { + return + } + } + if t.focused != nil { + t.focused.HandleInput(data) + t.RequestRender() + } +} + +func (t *TUI) render(width int) []string { + var lines []string + for _, child := range t.children { + lines = append(lines, child.Render(width)...) + } + return lines +} + +func (t *TUI) doRender() { + if t.stopped { + return + } + + width := t.terminal.Columns() + height := t.terminal.Rows() + viewportTop := maxInt(0, t.maxLinesRendered-height) + prevViewportTop := t.previousViewport + hardwareCursorRow := t.hardwareCursorRow + computeLineDiff := func(targetRow int) int { + currentScreenRow := hardwareCursorRow - prevViewportTop + targetScreenRow := targetRow - viewportTop + return targetScreenRow - currentScreenRow + } + + newLines := t.render(width) + cursorPos := extractCursorPosition(newLines) + newLines = applyLineResets(newLines) + + fullRender := func(clearScreen bool) { + var buffer strings.Builder + buffer.WriteString("\x1b[?2026h") + if clearScreen { + buffer.WriteString("\x1b[2J\x1b[H\x1b[3J") + } + for i, line := range newLines { + if i > 0 { + buffer.WriteString("\r\n") + } + buffer.WriteString(line) + } + buffer.WriteString("\x1b[?2026l") + t.terminal.Write(buffer.String()) + t.cursorRow = maxInt(0, len(newLines)-1) + t.hardwareCursorRow = t.cursorRow + if clearScreen { + t.maxLinesRendered = len(newLines) + } else { + t.maxLinesRendered = maxInt(t.maxLinesRendered, len(newLines)) + } + t.previousViewport = maxInt(0, t.maxLinesRendered-height) + t.positionHardwareCursor(cursorPos, len(newLines), height) + t.previousLines = append([]string(nil), newLines...) + t.previousWidth = width + t.previousHeight = height + } + + if len(t.previousLines) == 0 { + fullRender(false) + return + } + if t.previousWidth != width || (t.previousHeight != 0 && t.previousHeight != height) { + fullRender(true) + return + } + if len(newLines) < t.maxLinesRendered { + fullRender(true) + return + } + + firstChanged, lastChanged := -1, -1 + maxLines := maxInt(len(newLines), len(t.previousLines)) + for i := range maxLines { + oldLine, newLine := "", "" + if i < len(t.previousLines) { + oldLine = t.previousLines[i] + } + if i < len(newLines) { + newLine = newLines[i] + } + if oldLine != newLine { + if firstChanged == -1 { + firstChanged = i + } + lastChanged = i + } + } + + appendedLines := len(newLines) > len(t.previousLines) + if appendedLines { + if firstChanged == -1 { + firstChanged = len(t.previousLines) + } + lastChanged = len(newLines) - 1 + } + appendStart := appendedLines && firstChanged == len(t.previousLines) && firstChanged > 0 + + if firstChanged == -1 { + t.positionHardwareCursor(cursorPos, len(newLines), height) + t.previousViewport = maxInt(0, t.maxLinesRendered-height) + t.previousHeight = height + return + } + + previousContentViewportTop := maxInt(0, len(t.previousLines)-height) + if firstChanged < previousContentViewportTop { + fullRender(true) + return + } + + var buffer strings.Builder + buffer.WriteString("\x1b[?2026h") + prevViewportBottom := prevViewportTop + height - 1 + moveTargetRow := firstChanged + if appendStart { + moveTargetRow = firstChanged - 1 + } + if moveTargetRow > prevViewportBottom { + currentScreenRow := maxInt(0, minInt(height-1, hardwareCursorRow-prevViewportTop)) + moveToBottom := height - 1 - currentScreenRow + if moveToBottom > 0 { + buffer.WriteString(moveDown(moveToBottom)) + } + scroll := moveTargetRow - prevViewportBottom + buffer.WriteString(strings.Repeat("\r\n", scroll)) + prevViewportTop += scroll + viewportTop += scroll + hardwareCursorRow = moveTargetRow + } + + lineDiff := computeLineDiff(moveTargetRow) + switch { + case lineDiff > 0: + buffer.WriteString(moveDown(lineDiff)) + case lineDiff < 0: + buffer.WriteString(moveUp(-lineDiff)) + } + if appendStart { + buffer.WriteString("\r\n") + } else { + buffer.WriteString("\r") + } + + renderEnd := minInt(lastChanged, len(newLines)-1) + for i := firstChanged; i <= renderEnd; i++ { + if i > firstChanged { + buffer.WriteString("\r\n") + } + buffer.WriteString("\x1b[2K") + buffer.WriteString(newLines[i]) + } + + buffer.WriteString("\x1b[?2026l") + t.terminal.Write(buffer.String()) + t.cursorRow = maxInt(0, len(newLines)-1) + t.hardwareCursorRow = renderEnd + t.maxLinesRendered = maxInt(t.maxLinesRendered, len(newLines)) + t.previousViewport = maxInt(0, t.maxLinesRendered-height) + t.positionHardwareCursor(cursorPos, len(newLines), height) + t.previousLines = append([]string(nil), newLines...) + t.previousWidth = width + t.previousHeight = height +} + +func (t *TUI) positionHardwareCursor(pos *cursorPosition, totalLines, height int) { + if pos == nil { + t.terminal.HideCursor() + return + } + viewportTop := maxInt(0, totalLines-height) + targetRow := pos.Row + currentScreenRow := t.hardwareCursorRow - viewportTop + targetScreenRow := targetRow - viewportTop + var buffer strings.Builder + buffer.WriteString("\x1b[?2026h") + delta := targetScreenRow - currentScreenRow + switch { + case delta > 0: + buffer.WriteString(moveDown(delta)) + case delta < 0: + buffer.WriteString(moveUp(-delta)) + } + buffer.WriteString("\r") + if pos.Col > 0 { + buffer.WriteString(moveRight(pos.Col)) + } + buffer.WriteString("\x1b[?25h\x1b[?2026l") + t.terminal.Write(buffer.String()) + t.hardwareCursorRow = targetRow +} + +type cursorPosition struct{ Row, Col int } + +func extractCursorPosition(lines []string) *cursorPosition { + for row, line := range lines { + before, after, found := strings.Cut(line, cursorMarker) + if !found { + continue + } + lines[row] = before + after + return &cursorPosition{Row: row, Col: ansi.StringWidth(before)} + } + return nil +} + +func applyLineResets(lines []string) []string { + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = line + "\x1b[0m" + } + return out +} + +func moveUp(n int) string { + if n <= 0 { + return "" + } + return "\x1b[" + strconv.Itoa(n) + "A" +} + +func moveDown(n int) string { + if n <= 0 { + return "" + } + return "\x1b[" + strconv.Itoa(n) + "B" +} + +func moveRight(n int) string { + if n <= 0 { + return "" + } + return "\x1b[" + strconv.Itoa(n) + "C" +} diff --git a/pkg/tui/lean/types.go b/pkg/tui/lean/types.go new file mode 100644 index 000000000..42aaa7b61 --- /dev/null +++ b/pkg/tui/lean/types.go @@ -0,0 +1,57 @@ +package lean + +import ( + "github.com/docker/docker-agent/pkg/runtime" + "github.com/docker/docker-agent/pkg/session" +) + +type SessionDescriptor struct { + ID string + AgentName string + Model string + WorkingDirectory string +} + +type UsageTotals struct { + Prompt int64 + Completion int64 + Total int64 + CacheRead int64 + CacheCreation int64 + Cost float64 +} + +type ToolStatus string + +type ConnectionStatus string + +const ( + ToolPending ToolStatus = "pending" + ToolConfirmation ToolStatus = "confirmation" + ToolRunning ToolStatus = "running" + ToolCompleted ToolStatus = "completed" + ToolError ToolStatus = "error" +) + +const ( + ConnectionDisconnected ConnectionStatus = "disconnected" + ConnectionConnected ConnectionStatus = "connected" +) + +type Options struct { + FirstMessage string + QueuedMessages []string + FirstMessageAttachment string + ExitAfterFirstResponse bool +} + +func DescriptorFromState(rt runtime.Runtime, sess *session.Session) SessionDescriptor { + if sess == nil { + return SessionDescriptor{AgentName: rt.CurrentAgentName()} + } + return SessionDescriptor{ + ID: sess.ID, + AgentName: rt.CurrentAgentName(), + WorkingDirectory: sess.WorkingDir, + } +} diff --git a/pkg/tui/lean/util.go b/pkg/tui/lean/util.go new file mode 100644 index 000000000..e682461fc --- /dev/null +++ b/pkg/tui/lean/util.go @@ -0,0 +1,141 @@ +package lean + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + ansi "github.com/charmbracelet/x/ansi" +) + +func wrapPlain(text string, width int, preserveSpace bool) []string { + if width <= 0 { + return []string{""} + } + text = strings.ReplaceAll(text, "\r\n", "\n") + parts := strings.Split(text, "\n") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { + out = append(out, "") + continue + } + wrapped := ansi.Wrap(part, width, " ") + if preserveSpace { + wrapped = ansi.Hardwrap(part, width, true) + } + out = append(out, strings.Split(wrapped, "\n")...) + } + if len(out) == 0 { + return []string{""} + } + return out +} + +func padLine(text string, width int, style func(...string) string) string { + if width <= 0 { + return "" + } + truncated := ansi.Truncate(text, width, "") + padding := max(0, width-ansi.StringWidth(truncated)) + return style(truncated + strings.Repeat(" ", padding)) +} + +func homeTilde(path string) string { + if path == "" { + return "" + } + home, err := os.UserHomeDir() + if err == nil && home != "" { + if path == home { + return "~" + } + if rel, err := filepath.Rel(home, path); err == nil && rel != "." && !strings.HasPrefix(rel, "..") { + return filepath.Join("~", rel) + } + } + return path +} + +func middleTruncate(text string, width int) string { + if ansi.StringWidth(text) <= width { + return text + } + if width <= 3 { + return ansi.Truncate(text, width, "") + } + runes := []rune(ansi.Strip(text)) + half := (width - 1) / 2 + head := string(runes[:minInt(len(runes), half)]) + tailLen := min(len(runes), maxInt(1, width-ansi.StringWidth(head)-1)) + tail := string(runes[len(runes)-tailLen:]) + return head + "…" + tail +} + +func visibleWidth(s string) int { return ansi.StringWidth(s) } + +func formatMoney(cost float64) string { + if cost < 0.01 { + return fmt.Sprintf("%.4f", cost) + } + return fmt.Sprintf("%.2f", cost) +} + +func formatTokens(count int64) string { + if count >= 1_000_000 { + return fmt.Sprintf("%.1fm", float64(count)/1_000_000) + } + if count >= 1_000 { + return fmt.Sprintf("%.1fk", float64(count)/1_000) + } + return strconv.FormatInt(count, 10) +} + +func atoiDefault(s string, fallback int) int { + if s == "" { + return fallback + } + v, err := strconv.Atoi(s) + if err != nil { + return fallback + } + return v +} + +func orDefault(s, fallback string) string { + if s == "" { + return fallback + } + return s +} + +func ternaryInt(cond bool, a, b int) int { + if cond { + return a + } + return b +} + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +}