mirror of
https://github.com/cline/cline.git
synced 2026-09-19 10:13:34 +08:00
bubbles (#6871)
* bubbles * making the input look nicer * okay nice - clearing properly * not allowing input while streaming command output * better placeholder text * way better resize handling
This commit is contained in:
+8
-2
@@ -4,6 +4,8 @@ go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/charmbracelet/huh v0.7.1-0.20251005153135-a01a1e304532
|
||||
github.com/cline/grpc-go v0.0.0
|
||||
@@ -21,8 +23,6 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
@@ -33,6 +33,7 @@ require (
|
||||
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
@@ -45,6 +46,7 @@ require (
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
@@ -55,4 +57,8 @@ require (
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
modernc.org/libc v1.37.6 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/sqlite v1.28.0 // indirect
|
||||
)
|
||||
|
||||
+14
-2
@@ -57,6 +57,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -65,6 +67,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -82,8 +86,6 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
@@ -96,6 +98,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -148,3 +152,11 @@ google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9x
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
|
||||
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
@@ -20,7 +21,7 @@ func NewRenderer(outputFormat string) *Renderer {
|
||||
if err != nil {
|
||||
mdRenderer = nil
|
||||
}
|
||||
|
||||
|
||||
return &Renderer{
|
||||
typewriter: NewTypewriterPrinter(DefaultTypewriterConfig()),
|
||||
mdRenderer: mdRenderer,
|
||||
@@ -39,9 +40,9 @@ func (r *Renderer) RenderMessage(prefix, text string, newline bool) error {
|
||||
}
|
||||
|
||||
if newline {
|
||||
fmt.Printf("%s: %s\n", prefix, clean)
|
||||
output.Printf("%s: %s\n", prefix, clean)
|
||||
} else {
|
||||
fmt.Printf("%s: %s", prefix, clean)
|
||||
output.Printf("%s: %s", prefix, clean)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -86,12 +87,12 @@ func (r *Renderer) RenderAPI(status string, apiInfo *types.APIRequestInfo) error
|
||||
usageInfo := r.formatUsageInfo(apiInfo.TokensIn, apiInfo.TokensOut, apiInfo.CacheReads, apiInfo.CacheWrites, apiInfo.Cost)
|
||||
markdown := fmt.Sprintf("## API %s `%s`", status, usageInfo)
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
output.Print(rendered)
|
||||
} else {
|
||||
// honestly i see no point in showing "### API processing request" here...
|
||||
// markdown := fmt.Sprintf("## API %s", status)
|
||||
// rendered := r.RenderMarkdown(markdown)
|
||||
// fmt.Printf("\n%s\n", rendered)
|
||||
// output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -109,7 +110,7 @@ func (r *Renderer) RenderRetry(attempt, maxAttempts, delaySec int) error {
|
||||
func (r *Renderer) RenderTaskCancelled() error {
|
||||
markdown := "## Task cancelled"
|
||||
rendered := r.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -126,16 +127,16 @@ func (r *Renderer) RenderTaskList(tasks []*cline.TaskItem) error {
|
||||
|
||||
r.typewriter.PrintfLn("=== Task History (showing last %d of %d total tasks) ===\n", len(recentTasks), len(tasks))
|
||||
|
||||
for i, task := range recentTasks {
|
||||
r.typewriter.PrintfLn("Task ID: %s", task.Id)
|
||||
for i, taskItem := range recentTasks {
|
||||
r.typewriter.PrintfLn("Task ID: %s", taskItem.Id)
|
||||
|
||||
description := task.Task
|
||||
description := taskItem.Task
|
||||
if len(description) > 1000 {
|
||||
description = description[:1000] + "..."
|
||||
}
|
||||
r.typewriter.PrintfLn("Message: %s", description)
|
||||
|
||||
usageInfo := r.formatUsageInfo(int(task.TokensIn), int(task.TokensOut), int(task.CacheReads), int(task.CacheWrites), task.TotalCost)
|
||||
usageInfo := r.formatUsageInfo(int(taskItem.TokensIn), int(taskItem.TokensOut), int(taskItem.CacheReads), int(taskItem.CacheWrites), taskItem.TotalCost)
|
||||
r.typewriter.PrintfLn("Usage : %s", usageInfo)
|
||||
|
||||
// Single space between tasks (except last)
|
||||
@@ -156,11 +157,11 @@ func (r *Renderer) RenderDebug(format string, args ...interface{}) error {
|
||||
}
|
||||
|
||||
func (r *Renderer) ClearLine() {
|
||||
fmt.Print("\r\033[K")
|
||||
output.Print("\r\033[K")
|
||||
}
|
||||
|
||||
func (r *Renderer) MoveCursorUp(n int) {
|
||||
fmt.Printf("\033[%dA", n)
|
||||
output.Printf("\033[%dA", n)
|
||||
}
|
||||
|
||||
func (r *Renderer) sanitizeText(text string) string {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
@@ -34,15 +35,15 @@ func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, s
|
||||
msg: msg,
|
||||
toolParser: NewToolResultParser(mdRenderer),
|
||||
}
|
||||
|
||||
|
||||
// Render rich header immediately when creating segment (if in rich mode)
|
||||
if shouldMarkdown && outputFormat != "plain" {
|
||||
header := ss.generateRichHeader()
|
||||
rendered, _ := mdRenderer.Render(header)
|
||||
fmt.Println()
|
||||
fmt.Print(rendered)
|
||||
output.Println("")
|
||||
output.Print(rendered)
|
||||
}
|
||||
|
||||
|
||||
return ss
|
||||
}
|
||||
|
||||
@@ -136,10 +137,10 @@ func (ss *StreamingSegment) renderFinal(currentBuffer string) {
|
||||
// Print the body content
|
||||
if bodyContent != "" {
|
||||
if !strings.HasSuffix(bodyContent, "\n") {
|
||||
fmt.Print(bodyContent)
|
||||
fmt.Println()
|
||||
output.Print(bodyContent)
|
||||
output.Println("")
|
||||
} else {
|
||||
fmt.Print(bodyContent)
|
||||
output.Print(bodyContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
)
|
||||
|
||||
// AskHandler handles ASK type messages
|
||||
@@ -80,12 +81,12 @@ func (h *AskHandler) handleFollowup(msg *types.ClineMessage, dc *DisplayContext)
|
||||
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
fmt.Print("\n")
|
||||
fmt.Print(rendered)
|
||||
fmt.Print("\n")
|
||||
output.Print("\n")
|
||||
output.Print(rendered)
|
||||
output.Print("\n")
|
||||
|
||||
// Render body
|
||||
fmt.Print(body)
|
||||
output.Print(body)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -97,7 +98,7 @@ func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayC
|
||||
// Just render the body content
|
||||
body := dc.ToolRenderer.GeneratePlanModeRespondBody(msg.Text)
|
||||
if body != "" {
|
||||
fmt.Print(body)
|
||||
output.Print(body)
|
||||
}
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
@@ -110,12 +111,12 @@ func (h *AskHandler) handlePlanModeRespond(msg *types.ClineMessage, dc *DisplayC
|
||||
|
||||
// Render header
|
||||
rendered := dc.Renderer.RenderMarkdown(header)
|
||||
fmt.Print("\n")
|
||||
fmt.Print(rendered)
|
||||
fmt.Print("\n")
|
||||
output.Print("\n")
|
||||
output.Print(rendered)
|
||||
output.Print("\n")
|
||||
|
||||
// Render body
|
||||
fmt.Print(body)
|
||||
output.Print(body)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -131,8 +132,8 @@ func (h *AskHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext)
|
||||
autoApprovalConflict := strings.HasSuffix(msg.Text, "REQ_APP")
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderCommandApprovalRequest(msg.Text, autoApprovalConflict)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -168,8 +169,8 @@ func (h *AskHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) err
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderToolApprovalRequest(&tool)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/cline/cli/pkg/cli/clerror"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
)
|
||||
|
||||
// SayHandler handles SAY type messages
|
||||
@@ -179,8 +180,8 @@ func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) err
|
||||
if dc.MessageIndex == 0 {
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
fmt.Printf("\n")
|
||||
output.Printf("%s", rendered)
|
||||
output.Printf("\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -189,12 +190,12 @@ func (h *SayHandler) handleText(msg *types.ClineMessage, dc *DisplayContext) err
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
output.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Cline responds\n\n%s", msg.Text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -209,12 +210,12 @@ func (h *SayHandler) handleReasoning(msg *types.ClineMessage, dc *DisplayContext
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(msg.Text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
output.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Cline is thinking\n\n%s", msg.Text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -230,12 +231,12 @@ func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *Display
|
||||
if dc.IsStreamingMode {
|
||||
// In streaming mode, header already shown by partial stream
|
||||
rendered = dc.Renderer.RenderMarkdown(text)
|
||||
fmt.Printf("%s\n", rendered)
|
||||
output.Printf("%s\n", rendered)
|
||||
} else {
|
||||
// In non-streaming mode, render header + body together
|
||||
markdown := fmt.Sprintf("### Task completed\n\n%s", text)
|
||||
rendered = dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -259,7 +260,7 @@ func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayCont
|
||||
if msg.Text != "" {
|
||||
markdown := formatUserMessage(msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("%s", rendered)
|
||||
output.Printf("%s", rendered)
|
||||
return nil
|
||||
} else {
|
||||
return dc.Renderer.RenderMessage("USER", "[Provided feedback without text]", true)
|
||||
@@ -323,8 +324,8 @@ func (h *SayHandler) handleCommand(msg *types.ClineMessage, dc *DisplayContext)
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandExecution(msg.Text)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderCommandExecution(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -336,8 +337,8 @@ func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayCon
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderCommandOutput(msg.Text)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderCommandOutput(msg.Text)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -349,8 +350,8 @@ func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) err
|
||||
}
|
||||
|
||||
// Use unified ToolRenderer
|
||||
output := dc.ToolRenderer.RenderToolExecution(&tool)
|
||||
fmt.Print(output)
|
||||
rendered := dc.ToolRenderer.RenderToolExecution(&tool)
|
||||
output.Print(rendered)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -485,7 +486,7 @@ func (h *SayHandler) handleCheckpointCreated(msg *types.ClineMessage, dc *Displa
|
||||
// Fallback to basic renderer if SystemRenderer not available
|
||||
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, msg.Timestamp)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf(rendered)
|
||||
output.Print(rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -510,7 +511,7 @@ func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayCont
|
||||
|
||||
markdown := fmt.Sprintf("### Progress\n\n%s", msg.Text)
|
||||
rendered := dc.Renderer.RenderMarkdown(markdown)
|
||||
fmt.Printf("\n%s\n", rendered)
|
||||
output.Printf("\n%s\n", rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// SuspendInputMsg tells the input model to suspend and hide
|
||||
type SuspendInputMsg struct{}
|
||||
|
||||
// ResumeInputMsg tells the input model to resume and show
|
||||
type ResumeInputMsg struct{}
|
||||
|
||||
// OutputCoordinator manages terminal output and coordinates with interactive input
|
||||
type OutputCoordinator struct {
|
||||
mu sync.Mutex
|
||||
program *tea.Program
|
||||
inputVisible atomic.Bool
|
||||
inputModel *InputModel // Reference to current input model for state restoration
|
||||
restartCallback func(*InputModel) // Callback to restart the program with preserved state
|
||||
}
|
||||
|
||||
var (
|
||||
globalCoordinator *OutputCoordinator
|
||||
coordinatorMu sync.Mutex
|
||||
)
|
||||
|
||||
// GetCoordinator returns the global output coordinator instance
|
||||
func GetCoordinator() *OutputCoordinator {
|
||||
coordinatorMu.Lock()
|
||||
defer coordinatorMu.Unlock()
|
||||
|
||||
if globalCoordinator == nil {
|
||||
globalCoordinator = &OutputCoordinator{}
|
||||
}
|
||||
return globalCoordinator
|
||||
}
|
||||
|
||||
// SetProgram sets the bubbletea program for input coordination
|
||||
func (oc *OutputCoordinator) SetProgram(program *tea.Program) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.program = program
|
||||
}
|
||||
|
||||
// SetInputModel sets the current input model reference for state preservation
|
||||
func (oc *OutputCoordinator) SetInputModel(model *InputModel) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.inputModel = model
|
||||
}
|
||||
|
||||
// SetRestartCallback sets the callback for restarting the program
|
||||
func (oc *OutputCoordinator) SetRestartCallback(callback func(*InputModel)) {
|
||||
oc.mu.Lock()
|
||||
defer oc.mu.Unlock()
|
||||
oc.restartCallback = callback
|
||||
}
|
||||
|
||||
// SetInputVisible sets whether input is currently visible
|
||||
func (oc *OutputCoordinator) SetInputVisible(visible bool) {
|
||||
oc.inputVisible.Store(visible)
|
||||
}
|
||||
|
||||
// IsInputVisible returns whether input is currently visible
|
||||
func (oc *OutputCoordinator) IsInputVisible() bool {
|
||||
return oc.inputVisible.Load()
|
||||
}
|
||||
|
||||
// Printf prints formatted output, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Printf(format string, args ...interface{}) {
|
||||
oc.mu.Lock()
|
||||
prog := oc.program
|
||||
model := oc.inputModel
|
||||
restart := oc.restartCallback
|
||||
visible := oc.inputVisible.Load()
|
||||
oc.mu.Unlock()
|
||||
|
||||
if visible && prog != nil && restart != nil && model != nil {
|
||||
// Kill/restart approach: completely stop the program, print, restart with state
|
||||
|
||||
// 1. Save the current input state (text, cursor position, etc.)
|
||||
savedModel := model.Clone()
|
||||
|
||||
// 2. Manually clear the form from terminal BEFORE quitting
|
||||
clearCodes := model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
|
||||
// 3. Quit the program
|
||||
prog.Send(Quit())
|
||||
|
||||
// Small delay to let program actually quit
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// 4. Print the output
|
||||
fmt.Printf(format, args...)
|
||||
|
||||
// 5. Restart with preserved state
|
||||
restart(savedModel)
|
||||
} else {
|
||||
// No input showing, just print normally
|
||||
fmt.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Println prints a line with newline, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Println(args ...interface{}) {
|
||||
oc.Printf("%s\n", fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
// Print prints output, suspending input if necessary
|
||||
func (oc *OutputCoordinator) Print(args ...interface{}) {
|
||||
oc.Printf("%s", fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
// Package-level convenience functions
|
||||
|
||||
// Printf prints formatted output via the global coordinator
|
||||
func Printf(format string, args ...interface{}) {
|
||||
GetCoordinator().Printf(format, args...)
|
||||
}
|
||||
|
||||
// Println prints a line with newline via the global coordinator
|
||||
func Println(args ...interface{}) {
|
||||
GetCoordinator().Println(args...)
|
||||
}
|
||||
|
||||
// Print prints output via the global coordinator
|
||||
func Print(args ...interface{}) {
|
||||
GetCoordinator().Print(args...)
|
||||
}
|
||||
|
||||
// SetProgram sets the bubbletea program on the global coordinator
|
||||
func SetProgram(program *tea.Program) {
|
||||
GetCoordinator().SetProgram(program)
|
||||
}
|
||||
|
||||
// SetInputVisible sets input visibility on the global coordinator
|
||||
func SetInputVisible(visible bool) {
|
||||
GetCoordinator().SetInputVisible(visible)
|
||||
}
|
||||
|
||||
// IsInputVisible checks input visibility on the global coordinator
|
||||
func IsInputVisible() bool {
|
||||
return GetCoordinator().IsInputVisible()
|
||||
}
|
||||
|
||||
// SetInputModel sets the input model on the global coordinator
|
||||
func SetInputModel(model *InputModel) {
|
||||
GetCoordinator().SetInputModel(model)
|
||||
}
|
||||
|
||||
// SetRestartCallback sets the restart callback on the global coordinator
|
||||
func SetRestartCallback(callback func(*InputModel)) {
|
||||
GetCoordinator().SetRestartCallback(callback)
|
||||
}
|
||||
|
||||
// Quit returns a Bubble Tea quit message
|
||||
func Quit() tea.Msg {
|
||||
return tea.Quit()
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// InputType represents the type of input being collected
|
||||
type InputType int
|
||||
|
||||
const (
|
||||
InputTypeMessage InputType = iota
|
||||
InputTypeApproval
|
||||
InputTypeFeedback
|
||||
)
|
||||
|
||||
// InputSubmitMsg is sent when the user submits input
|
||||
type InputSubmitMsg struct {
|
||||
Value string
|
||||
InputType InputType
|
||||
Approved bool // For approval type
|
||||
NeedsFeedback bool // For approval type
|
||||
}
|
||||
|
||||
// InputCancelMsg is sent when the user cancels input (Ctrl+C)
|
||||
type InputCancelMsg struct{}
|
||||
|
||||
// ChangeInputTypeMsg changes the current input type
|
||||
type ChangeInputTypeMsg struct {
|
||||
InputType InputType
|
||||
Title string
|
||||
Placeholder string
|
||||
}
|
||||
|
||||
// editorFinishedMsg is sent when the external editor finishes
|
||||
type editorFinishedMsg struct {
|
||||
content []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// InputModel is the bubbletea model for interactive input
|
||||
type InputModel struct {
|
||||
textarea textarea.Model
|
||||
suspended bool
|
||||
savedValue string
|
||||
inputType InputType
|
||||
title string
|
||||
placeholder string
|
||||
currentMode string // "plan" or "act"
|
||||
width int
|
||||
lastHeight int // Track height for cleanup on submit
|
||||
|
||||
// For approval type
|
||||
approvalOptions []string
|
||||
selectedOption int
|
||||
|
||||
// Styles (huh-inspired theme)
|
||||
styles fieldStyles
|
||||
}
|
||||
|
||||
// fieldStyles holds the styling for the input field
|
||||
type fieldStyles struct {
|
||||
base lipgloss.Style
|
||||
title lipgloss.Style
|
||||
textArea lipgloss.Style
|
||||
cursor lipgloss.Style
|
||||
placeholder lipgloss.Style
|
||||
selector lipgloss.Style
|
||||
selectedOption lipgloss.Style
|
||||
option lipgloss.Style
|
||||
}
|
||||
|
||||
// newFieldStyles creates huh-inspired styles (Charm theme)
|
||||
func newFieldStyles() fieldStyles {
|
||||
// Charm theme colors
|
||||
indigo := lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
|
||||
fuchsia := lipgloss.Color("#F780E2")
|
||||
normalFg := lipgloss.AdaptiveColor{Light: "235", Dark: "252"}
|
||||
green := lipgloss.AdaptiveColor{Light: "#02BA84", Dark: "#02BF87"}
|
||||
|
||||
return fieldStyles{
|
||||
base: lipgloss.NewStyle().
|
||||
PaddingLeft(1).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
BorderLeft(true).
|
||||
BorderForeground(lipgloss.Color("238")),
|
||||
title: lipgloss.NewStyle().
|
||||
Foreground(indigo).
|
||||
Bold(true),
|
||||
textArea: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
cursor: lipgloss.NewStyle().
|
||||
Foreground(green),
|
||||
placeholder: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"}),
|
||||
selector: lipgloss.NewStyle().
|
||||
Foreground(fuchsia).
|
||||
SetString("> "),
|
||||
selectedOption: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
option: lipgloss.NewStyle().
|
||||
Foreground(normalFg),
|
||||
}
|
||||
}
|
||||
|
||||
// NewInputModel creates a new input model
|
||||
func NewInputModel(inputType InputType, title, placeholder, currentMode string) InputModel {
|
||||
ta := textarea.New()
|
||||
ta.Placeholder = placeholder
|
||||
ta.Focus()
|
||||
ta.CharLimit = 0
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = "" // Remove prompt prefix (this is what adds the inner border!)
|
||||
ta.SetHeight(5)
|
||||
// Don't set width here - let WindowSizeMsg handle it
|
||||
// ta.SetWidth(80)
|
||||
|
||||
// Configure keybindings like huh does:
|
||||
// alt+enter and ctrl+j for newlines (textarea will handle these)
|
||||
ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j")
|
||||
|
||||
// Apply huh-like styling
|
||||
styles := newFieldStyles()
|
||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle() // No cursor line highlighting
|
||||
ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle() // No end-of-buffer styling
|
||||
ta.FocusedStyle.Placeholder = styles.placeholder
|
||||
ta.FocusedStyle.Text = styles.textArea
|
||||
ta.FocusedStyle.Prompt = lipgloss.NewStyle() // No prompt styling
|
||||
ta.Cursor.Style = styles.cursor
|
||||
ta.Cursor.TextStyle = styles.textArea
|
||||
|
||||
m := InputModel{
|
||||
textarea: ta,
|
||||
inputType: inputType,
|
||||
title: title,
|
||||
placeholder: placeholder,
|
||||
currentMode: currentMode,
|
||||
width: 0, // Will be set by first WindowSizeMsg
|
||||
styles: styles,
|
||||
}
|
||||
|
||||
// For approval type, set up options
|
||||
if inputType == InputTypeApproval {
|
||||
m.approvalOptions = []string{
|
||||
"Yes",
|
||||
"Yes, with feedback",
|
||||
"No",
|
||||
"No, with feedback",
|
||||
}
|
||||
m.selectedOption = 0
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m *InputModel) Init() tea.Cmd {
|
||||
return textarea.Blink
|
||||
}
|
||||
|
||||
// Update handles messages
|
||||
func (m *InputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case editorFinishedMsg:
|
||||
// External editor finished
|
||||
if msg.err == nil && len(msg.content) > 0 {
|
||||
m.textarea.SetValue(string(msg.content))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case SuspendInputMsg:
|
||||
// Save current value and suspend
|
||||
m.savedValue = m.textarea.Value()
|
||||
m.suspended = true
|
||||
return m, tea.ClearScreen
|
||||
|
||||
case ResumeInputMsg:
|
||||
// Restore value and resume
|
||||
m.textarea.SetValue(m.savedValue)
|
||||
m.suspended = false
|
||||
return m, nil
|
||||
|
||||
case ChangeInputTypeMsg:
|
||||
// Change input type (e.g., from approval to feedback)
|
||||
m.inputType = msg.InputType
|
||||
m.title = msg.Title
|
||||
m.placeholder = msg.Placeholder
|
||||
m.textarea.Placeholder = msg.Placeholder
|
||||
m.textarea.SetValue("")
|
||||
m.textarea.Focus()
|
||||
|
||||
if msg.InputType == InputTypeApproval {
|
||||
m.approvalOptions = []string{
|
||||
"Yes",
|
||||
"Yes, with feedback",
|
||||
"No",
|
||||
"No, with feedback",
|
||||
}
|
||||
m.selectedOption = 0
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.suspended {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Handle keys for text input types (Message/Feedback)
|
||||
if m.inputType == InputTypeMessage || m.inputType == InputTypeFeedback {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, func() tea.Msg { return InputCancelMsg{} }
|
||||
|
||||
case "ctrl+e":
|
||||
// Open external editor (like huh does)
|
||||
return m, m.openEditor()
|
||||
|
||||
case "enter":
|
||||
// Intercept enter for submit (textarea handles alt+enter and ctrl+j for newlines)
|
||||
return m.handleSubmit()
|
||||
|
||||
case "up", "down", "left", "right":
|
||||
// Let textarea handle navigation
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Pass all other keys to textarea (including alt+enter, ctrl+j for newlines)
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// Handle keys for approval type
|
||||
if m.inputType == InputTypeApproval {
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, func() tea.Msg { return InputCancelMsg{} }
|
||||
|
||||
case "enter":
|
||||
return m.handleSubmit()
|
||||
|
||||
case "up":
|
||||
if m.selectedOption > 0 {
|
||||
m.selectedOption--
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "down":
|
||||
if m.selectedOption < len(m.approvalOptions)-1 {
|
||||
m.selectedOption++
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handleSubmit handles submission based on input type
|
||||
func (m *InputModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
switch m.inputType {
|
||||
case InputTypeMessage:
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: value,
|
||||
InputType: InputTypeMessage,
|
||||
}
|
||||
}
|
||||
|
||||
case InputTypeApproval:
|
||||
selected := m.approvalOptions[m.selectedOption]
|
||||
approved := strings.HasPrefix(selected, "Yes")
|
||||
needsFeedback := strings.Contains(selected, "feedback")
|
||||
|
||||
if needsFeedback {
|
||||
// Switch to feedback input
|
||||
return m, func() tea.Msg {
|
||||
return ChangeInputTypeMsg{
|
||||
InputType: InputTypeFeedback,
|
||||
Title: "Your feedback",
|
||||
Placeholder: "/plan or /act to switch modes\ncntrl+e to open editor",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: "",
|
||||
InputType: InputTypeApproval,
|
||||
Approved: approved,
|
||||
NeedsFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case InputTypeFeedback:
|
||||
value := strings.TrimSpace(m.textarea.Value())
|
||||
return m, func() tea.Msg {
|
||||
return InputSubmitMsg{
|
||||
Value: value,
|
||||
InputType: InputTypeFeedback,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the model
|
||||
func (m *InputModel) View() string {
|
||||
if m.suspended {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
// Render title with mode indicator
|
||||
yellow := lipgloss.Color("3")
|
||||
blue := lipgloss.Color("4")
|
||||
|
||||
modeStyle := lipgloss.NewStyle()
|
||||
if m.currentMode == "plan" {
|
||||
modeStyle = modeStyle.Foreground(yellow)
|
||||
} else {
|
||||
modeStyle = modeStyle.Foreground(blue)
|
||||
}
|
||||
|
||||
modeIndicator := modeStyle.Render(fmt.Sprintf("[%s mode]", m.currentMode))
|
||||
titleText := m.styles.title.Render(m.title)
|
||||
fullTitle := fmt.Sprintf("%s %s", modeIndicator, titleText)
|
||||
parts = append(parts, fullTitle)
|
||||
|
||||
// Render based on input type
|
||||
switch m.inputType {
|
||||
case InputTypeMessage, InputTypeFeedback:
|
||||
parts = append(parts, m.textarea.View())
|
||||
|
||||
case InputTypeApproval:
|
||||
var options []string
|
||||
for i, option := range m.approvalOptions {
|
||||
if i == m.selectedOption {
|
||||
options = append(options, m.styles.selector.Render("")+m.styles.selectedOption.Render(option))
|
||||
} else {
|
||||
options = append(options, " "+m.styles.option.Render(option))
|
||||
}
|
||||
}
|
||||
parts = append(parts, strings.Join(options, "\n"))
|
||||
}
|
||||
|
||||
// Wrap everything in the base style with border
|
||||
content := strings.Join(parts, "\n")
|
||||
rendered := m.styles.base.Render(content)
|
||||
|
||||
// Add newline before the form (outside the border)
|
||||
rendered = "\n" + rendered
|
||||
|
||||
// Track height for cleanup
|
||||
m.lastHeight = lipgloss.Height(rendered)
|
||||
|
||||
return rendered
|
||||
}
|
||||
|
||||
// ClearScreen returns the ANSI codes to clear the input from the terminal
|
||||
// This is used when submitting to remove the form cleanly
|
||||
func (m *InputModel) ClearScreen() string {
|
||||
if m.lastHeight == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Move cursor up by lastHeight lines and clear from cursor to end of screen
|
||||
return fmt.Sprintf("\033[%dA\033[J", m.lastHeight)
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of the InputModel with all state preserved
|
||||
func (m *InputModel) Clone() *InputModel {
|
||||
// Create new textarea with same configuration
|
||||
ta := textarea.New()
|
||||
ta.SetValue(m.textarea.Value()) // Preserve user's text!
|
||||
ta.Placeholder = m.placeholder
|
||||
ta.CharLimit = 0
|
||||
ta.ShowLineNumbers = false
|
||||
ta.Prompt = ""
|
||||
ta.SetHeight(5)
|
||||
ta.SetWidth(m.width) // Use current width, not hardcoded 80!
|
||||
ta.Focus()
|
||||
|
||||
// Configure keybindings
|
||||
ta.KeyMap.InsertNewline.SetKeys("alt+enter", "ctrl+j")
|
||||
|
||||
// Apply styles
|
||||
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
||||
ta.FocusedStyle.EndOfBuffer = lipgloss.NewStyle()
|
||||
ta.FocusedStyle.Placeholder = m.styles.placeholder
|
||||
ta.FocusedStyle.Text = m.styles.textArea
|
||||
ta.FocusedStyle.Prompt = lipgloss.NewStyle()
|
||||
ta.Cursor.Style = m.styles.cursor
|
||||
ta.Cursor.TextStyle = m.styles.textArea
|
||||
|
||||
// Create cloned model
|
||||
clone := &InputModel{
|
||||
textarea: ta,
|
||||
suspended: false, // New program starts unsuspended
|
||||
savedValue: m.savedValue,
|
||||
inputType: m.inputType,
|
||||
title: m.title,
|
||||
placeholder: m.placeholder,
|
||||
currentMode: m.currentMode,
|
||||
width: m.width,
|
||||
lastHeight: m.lastHeight,
|
||||
approvalOptions: m.approvalOptions,
|
||||
selectedOption: m.selectedOption,
|
||||
styles: m.styles,
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// openEditor opens an external editor for composing the message
|
||||
func (m *InputModel) openEditor() tea.Cmd {
|
||||
// Get editor from environment or use nano as default
|
||||
editorCmd := "nano"
|
||||
editorArgs := []string{}
|
||||
|
||||
if editor := os.Getenv("EDITOR"); editor != "" {
|
||||
editorFields := strings.Fields(editor)
|
||||
if len(editorFields) > 0 {
|
||||
editorCmd = editorFields[0]
|
||||
if len(editorFields) > 1 {
|
||||
editorArgs = editorFields[1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create temp file with current content
|
||||
tmpFile, err := os.CreateTemp(os.TempDir(), "*.md")
|
||||
if err != nil {
|
||||
return func() tea.Msg {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Write current textarea value to temp file
|
||||
if err := os.WriteFile(tmpFile.Name(), []byte(m.textarea.Value()), 0o644); err != nil {
|
||||
return func() tea.Msg {
|
||||
return editorFinishedMsg{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the editor
|
||||
cmd := exec.Command(editorCmd, append(editorArgs, tmpFile.Name())...)
|
||||
return tea.ExecProcess(cmd, func(err error) tea.Msg {
|
||||
content, readErr := os.ReadFile(tmpFile.Name())
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
|
||||
if readErr != nil {
|
||||
return editorFinishedMsg{err: readErr}
|
||||
}
|
||||
|
||||
return editorFinishedMsg{content: content, err: err}
|
||||
})
|
||||
}
|
||||
+248
-135
@@ -8,29 +8,40 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// InputHandler manages interactive user input during follow mode
|
||||
type InputHandler struct {
|
||||
manager *Manager
|
||||
coordinator *StreamCoordinator
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
pollTicker *time.Ticker
|
||||
manager *Manager
|
||||
coordinator *StreamCoordinator
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
pollTicker *time.Ticker
|
||||
program *tea.Program
|
||||
programRunning bool
|
||||
programDoneChan chan struct{} // Signals when program actually exits
|
||||
resultChan chan output.InputSubmitMsg
|
||||
cancelChan chan struct{}
|
||||
feedbackApproval bool // Track if we're in feedback after approval
|
||||
feedbackApproved bool // Track the approval decision
|
||||
ctx context.Context // Context for restart callback
|
||||
}
|
||||
|
||||
// NewInputHandler creates a new input handler
|
||||
func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler {
|
||||
return &InputHandler{
|
||||
manager: manager,
|
||||
coordinator: coordinator,
|
||||
cancelFunc: cancelFunc,
|
||||
isRunning: false,
|
||||
pollTicker: time.NewTicker(500 * time.Millisecond),
|
||||
manager: manager,
|
||||
coordinator: coordinator,
|
||||
cancelFunc: cancelFunc,
|
||||
isRunning: false,
|
||||
pollTicker: time.NewTicker(500 * time.Millisecond),
|
||||
resultChan: make(chan output.InputSubmitMsg, 1),
|
||||
cancelChan: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +56,9 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
ih.isRunning = false
|
||||
ih.mu.Unlock()
|
||||
ih.pollTicker.Stop()
|
||||
if ih.program != nil {
|
||||
ih.program.Quit()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
@@ -56,7 +70,7 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
needsApproval, approvalMsg, err := ih.manager.CheckNeedsApproval(ctx)
|
||||
if err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
|
||||
output.Printf("\nDebug: CheckNeedsApproval error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -64,24 +78,18 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
if needsApproval {
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Lock output to prevent race with streaming display
|
||||
ih.coordinator.LockOutput()
|
||||
|
||||
// Show approval prompt
|
||||
approved, feedback, err := ih.promptForApproval(ctx, approvalMsg)
|
||||
|
||||
// Unlock output after form dismissed
|
||||
ih.coordinator.UnlockOutput()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if err == huh.ErrUserAborted || ctx.Err() != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Approval prompt error: %v\n", err)
|
||||
output.Printf("\nDebug: Approval prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -95,12 +103,12 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
}
|
||||
|
||||
if err := ih.manager.SendMessage(ctx, feedback, nil, nil, approveStr); err != nil {
|
||||
fmt.Printf("\nError sending approval: %v\n", err)
|
||||
output.Printf("\nError sending approval: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback)
|
||||
output.Printf("\nDebug: Approval sent (approved=%s, feedback=%q)\n", approveStr, feedback)
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
@@ -124,7 +132,7 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
}
|
||||
// Unexpected error
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: CheckSendEnabled error: %v\n", err)
|
||||
output.Printf("\nDebug: CheckSendEnabled error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -132,24 +140,18 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
// If we reach here, we can send a message
|
||||
ih.coordinator.SetInputAllowed(true)
|
||||
|
||||
// Lock output to prevent race with streaming display
|
||||
ih.coordinator.LockOutput()
|
||||
|
||||
// Show prompt and get input
|
||||
message, shouldSend, err := ih.promptForInput(ctx)
|
||||
|
||||
// Unlock output after form dismissed
|
||||
ih.coordinator.UnlockOutput()
|
||||
|
||||
if err != nil {
|
||||
// Check if the error is due to interrupt (Ctrl+C) or context cancellation
|
||||
if err == huh.ErrUserAborted || ctx.Err() != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
// User pressed Ctrl+C - cancel context to exit FollowConversation
|
||||
ih.cancelFunc()
|
||||
return
|
||||
}
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Input prompt error: %v\n", err)
|
||||
output.Printf("\nDebug: Input prompt error: %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -162,10 +164,10 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
if isModeSwitch {
|
||||
// Switch mode
|
||||
if err := ih.manager.SetMode(ctx, newMode, nil, nil, nil); err != nil {
|
||||
fmt.Printf("\nError switching to %s mode: %v\n", newMode, err)
|
||||
output.Printf("\nError switching to %s mode: %v\n", newMode, err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\nSwitched to %s mode\n", newMode)
|
||||
output.Printf("\nSwitched to %s mode\n", newMode)
|
||||
|
||||
// If there's remaining message, use it as the new message to send
|
||||
if remainingMessage != "" {
|
||||
@@ -184,12 +186,12 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
|
||||
// Send the message
|
||||
if err := ih.manager.SendMessage(ctx, message, nil, nil, ""); err != nil {
|
||||
fmt.Printf("\nError sending message: %v\n", err)
|
||||
output.Printf("\nError sending message: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("\nDebug: Message sent successfully\n")
|
||||
output.Printf("\nDebug: Message sent successfully\n")
|
||||
}
|
||||
|
||||
// Give the system a moment to process before re-polling
|
||||
@@ -201,129 +203,193 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
|
||||
// promptForInput displays an interactive prompt and waits for user input
|
||||
func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error) {
|
||||
// Add visual separation before the form
|
||||
fmt.Println()
|
||||
|
||||
var message string
|
||||
|
||||
// Get current mode and format title with color
|
||||
currentMode := ih.manager.GetCurrentMode()
|
||||
|
||||
// ANSI color codes
|
||||
yellow := "\033[33m" // Yellow for plan mode
|
||||
blue := "\033[34m" // Blue for act mode
|
||||
indigo := "\033[38;5;99m" // Indigo (huh default title color) - approximation of #7571F9
|
||||
bold := "\033[1m" // Bold
|
||||
reset := "\033[0m" // Reset
|
||||
|
||||
var coloredMode string
|
||||
if currentMode == "plan" {
|
||||
coloredMode = fmt.Sprintf("%s[plan mode]%s", yellow, reset)
|
||||
} else {
|
||||
coloredMode = fmt.Sprintf("%s[act mode]%s", blue, reset)
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("%s %s%sCline is ready for your message%s", coloredMode, bold, indigo, reset)
|
||||
|
||||
// Create multiline text area form using huh
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title(title).
|
||||
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
|
||||
Lines(5).
|
||||
Value(&message),
|
||||
),
|
||||
model := output.NewInputModel(
|
||||
output.InputTypeMessage,
|
||||
"Cline is ready for your message",
|
||||
"/plan or /act to switch modes\ncntrl+e to open editor",
|
||||
currentMode,
|
||||
)
|
||||
|
||||
// Run the form
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
message = strings.TrimSpace(message)
|
||||
|
||||
// If empty, user just wants to keep watching
|
||||
if message == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
return message, true, nil
|
||||
return ih.runInputProgram(ctx, model)
|
||||
}
|
||||
|
||||
// promptForApproval displays an approval prompt for tool/command requests
|
||||
// Returns (approved, message, error)
|
||||
// Note: The approval details are already shown by segment streamer / state stream
|
||||
func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
|
||||
// Add visual separation before the form
|
||||
fmt.Println()
|
||||
|
||||
// Show selection menu (approval details already displayed by other handlers)
|
||||
var choice string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Let Cline use this tool?").
|
||||
Options(
|
||||
huh.NewOption("Yes", "yes"),
|
||||
huh.NewOption("Yes, with feedback", "yes_feedback"),
|
||||
huh.NewOption("No", "no"),
|
||||
huh.NewOption("No, with feedback", "no_feedback"),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
model := output.NewInputModel(
|
||||
output.InputTypeApproval,
|
||||
"Let Cline use this tool?",
|
||||
"",
|
||||
ih.manager.GetCurrentMode(),
|
||||
)
|
||||
|
||||
err := form.Run()
|
||||
message, shouldSend, err := ih.runInputProgram(ctx, model)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
// Check if feedback is needed
|
||||
needsFeedback := choice == "yes_feedback" || choice == "no_feedback"
|
||||
approved := choice == "yes" || choice == "yes_feedback"
|
||||
|
||||
var feedback string
|
||||
if needsFeedback {
|
||||
// Show multiline text area for feedback
|
||||
feedbackForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Your feedback").
|
||||
Placeholder("Type your message... (shift+enter for new line, enter to submit, /plan or /act to switch mode)").
|
||||
Lines(5).
|
||||
Value(&feedback),
|
||||
),
|
||||
)
|
||||
|
||||
err := feedbackForm.Run()
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
|
||||
feedback = strings.TrimSpace(feedback)
|
||||
if !shouldSend {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
return approved, feedback, nil
|
||||
// The approval and feedback are handled via the model state
|
||||
return ih.feedbackApproved, message, nil
|
||||
}
|
||||
|
||||
// runInputProgram runs the bubbletea program and waits for result
|
||||
func (ih *InputHandler) runInputProgram(ctx context.Context, model output.InputModel) (string, bool, error) {
|
||||
ih.mu.Lock()
|
||||
|
||||
// Create the program with custom update wrapper
|
||||
wrappedModel := &inputProgramWrapper{
|
||||
model: &model,
|
||||
resultChan: ih.resultChan,
|
||||
cancelChan: ih.cancelChan,
|
||||
handler: ih,
|
||||
}
|
||||
|
||||
ih.program = tea.NewProgram(wrappedModel)
|
||||
ih.programDoneChan = make(chan struct{})
|
||||
ih.ctx = ctx
|
||||
|
||||
// Set up coordinator references
|
||||
output.SetProgram(ih.program)
|
||||
output.SetInputModel(wrappedModel.model)
|
||||
output.SetRestartCallback(ih.restartProgram)
|
||||
output.SetInputVisible(true)
|
||||
ih.programRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Run program in goroutine
|
||||
programErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
if _, err := ih.program.Run(); err != nil {
|
||||
programErrChan <- err
|
||||
}
|
||||
// Signal that program is done
|
||||
close(ih.programDoneChan)
|
||||
}()
|
||||
|
||||
// Wait for result, cancellation, or context done
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
if ih.program != nil {
|
||||
ih.program.Quit()
|
||||
}
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, ctx.Err()
|
||||
|
||||
case <-ih.cancelChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, context.Canceled
|
||||
|
||||
case err := <-programErrChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
return "", false, err
|
||||
|
||||
case result := <-ih.resultChan:
|
||||
ih.mu.Lock()
|
||||
output.SetInputVisible(false)
|
||||
ih.programRunning = false
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Handle different input types
|
||||
switch result.InputType {
|
||||
case output.InputTypeMessage:
|
||||
if result.Value == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
return result.Value, true, nil
|
||||
|
||||
case output.InputTypeApproval:
|
||||
if result.NeedsFeedback {
|
||||
// Need to collect feedback - will be handled by model state change
|
||||
return "", false, nil
|
||||
}
|
||||
// Store approval state for when feedback comes back
|
||||
ih.feedbackApproval = false
|
||||
ih.feedbackApproved = result.Approved
|
||||
return "", true, nil
|
||||
|
||||
case output.InputTypeFeedback:
|
||||
// This came from approval flow
|
||||
ih.feedbackApproval = true
|
||||
return result.Value, true, nil
|
||||
}
|
||||
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// inputProgramWrapper wraps the InputModel to handle message routing
|
||||
type inputProgramWrapper struct {
|
||||
model *output.InputModel
|
||||
resultChan chan output.InputSubmitMsg
|
||||
cancelChan chan struct{}
|
||||
handler *InputHandler
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) Init() tea.Cmd {
|
||||
return w.model.Init()
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case output.InputSubmitMsg:
|
||||
// Handle input submission - clear the screen before quitting
|
||||
w.resultChan <- msg
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
|
||||
case output.InputCancelMsg:
|
||||
// Handle cancellation - clear the screen before quitting
|
||||
w.cancelChan <- struct{}{}
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
|
||||
case output.ChangeInputTypeMsg:
|
||||
// Change input type (approval -> feedback)
|
||||
_, cmd := w.model.Update(msg)
|
||||
return w, cmd
|
||||
}
|
||||
|
||||
// Forward to wrapped model
|
||||
_, cmd := w.model.Update(msg)
|
||||
return w, cmd
|
||||
}
|
||||
|
||||
func (w *inputProgramWrapper) View() string {
|
||||
return w.model.View()
|
||||
}
|
||||
|
||||
// parseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message
|
||||
// Returns: (newMode, remainingMessage, isModeSwitch)
|
||||
func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "/plan") {
|
||||
// Extract remaining message after /plan
|
||||
remaining := strings.TrimSpace(trimmed[5:]) // Remove "/plan"
|
||||
remaining := strings.TrimSpace(trimmed[5:])
|
||||
return "plan", remaining, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "/act") {
|
||||
// Extract remaining message after /act
|
||||
remaining := strings.TrimSpace(trimmed[4:]) // Remove "/act"
|
||||
remaining := strings.TrimSpace(trimmed[4:])
|
||||
return "act", remaining, true
|
||||
}
|
||||
|
||||
@@ -336,14 +402,13 @@ func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string
|
||||
case "/cancel":
|
||||
ih.manager.GetRenderer().RenderTaskCancelled()
|
||||
if err := ih.manager.CancelTask(ctx); err != nil {
|
||||
fmt.Printf("Error cancelling task: %v\n", err)
|
||||
output.Printf("Error cancelling task: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Task cancelled successfully")
|
||||
output.Println("Task cancelled successfully")
|
||||
}
|
||||
return true
|
||||
case "/exit", "/quit":
|
||||
fmt.Println("\nExiting follow mode...")
|
||||
// This will be handled by context cancellation
|
||||
output.Println("\nExiting follow mode...")
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -357,6 +422,9 @@ func (ih *InputHandler) Stop() {
|
||||
if ih.pollTicker != nil {
|
||||
ih.pollTicker.Stop()
|
||||
}
|
||||
if ih.program != nil && ih.programRunning {
|
||||
ih.program.Quit()
|
||||
}
|
||||
ih.isRunning = false
|
||||
}
|
||||
|
||||
@@ -366,3 +434,48 @@ func (ih *InputHandler) IsRunning() bool {
|
||||
defer ih.mu.RUnlock()
|
||||
return ih.isRunning
|
||||
}
|
||||
|
||||
// restartProgram restarts the Bubble Tea program with preserved state
|
||||
func (ih *InputHandler) restartProgram(savedModel *output.InputModel) {
|
||||
ih.mu.Lock()
|
||||
|
||||
// Wait for old program to actually quit
|
||||
if ih.programDoneChan != nil {
|
||||
select {
|
||||
case <-ih.programDoneChan:
|
||||
// Program quit successfully
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Timeout - continue anyway
|
||||
}
|
||||
}
|
||||
|
||||
// Create new wrapper with the saved model
|
||||
wrappedModel := &inputProgramWrapper{
|
||||
model: savedModel,
|
||||
resultChan: ih.resultChan,
|
||||
cancelChan: ih.cancelChan,
|
||||
handler: ih,
|
||||
}
|
||||
|
||||
// Start new program
|
||||
ih.program = tea.NewProgram(wrappedModel)
|
||||
ih.programDoneChan = make(chan struct{})
|
||||
|
||||
// Update coordinator references
|
||||
output.SetProgram(ih.program)
|
||||
output.SetInputModel(savedModel)
|
||||
output.SetInputVisible(true)
|
||||
ih.programRunning = true
|
||||
ih.mu.Unlock()
|
||||
|
||||
// Run in goroutine
|
||||
go func() {
|
||||
if _, err := ih.program.Run(); err != nil {
|
||||
// Log error if needed
|
||||
if global.Config.Verbose {
|
||||
output.Printf("\nDebug: Program restart error: %v\n", err)
|
||||
}
|
||||
}
|
||||
close(ih.programDoneChan)
|
||||
}()
|
||||
}
|
||||
|
||||
+44
-50
@@ -307,8 +307,18 @@ func (m *Manager) CheckSendEnabled(ctx context.Context) error {
|
||||
return ErrTaskBusy
|
||||
}
|
||||
|
||||
// All ask messages allow sending
|
||||
// All ask messages allow sending, EXCEPT command_output
|
||||
if lastMessage.Type == types.MessageTypeAsk {
|
||||
// Special case: command_output means command is actively streaming
|
||||
// In the CLI, we don't want to show input during streaming output (too messy)
|
||||
// The webview can show "Proceed While Running" button, but CLI should wait
|
||||
if lastMessage.Ask == string(types.AskTypeCommandOutput) {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send disabled: command output is streaming")
|
||||
}
|
||||
return ErrTaskBusy
|
||||
}
|
||||
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Send enabled: ask message")
|
||||
}
|
||||
@@ -855,9 +865,7 @@ func (m *Manager) processStateUpdateJsonMode(stateUpdate *cline.State, coordinat
|
||||
// Display valid messages, exit as soon as we hit a non-valid message
|
||||
if shouldDisplay {
|
||||
coordinator.CompleteTurn(i + 1) // Mark the message as complete as soon as we print it
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
m.displayMessage(msg, false, false, i)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@@ -903,59 +911,52 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
case msg.Say == string(types.SayTypeUserFeedback):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCommand):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCommandOutput):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeBrowserActionLaunch):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeMcpServerRequestStarted):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
case msg.Say == string(types.SayTypeCheckpointCreated):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
@@ -964,10 +965,9 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
apiInfo := types.APIRequestInfo{Cost: -1}
|
||||
if err := json.Unmarshal([]byte(msg.Text), &apiInfo); err == nil && apiInfo.Cost >= 0 {
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println() // adds a separator between cline message and usage message
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
coordinator.CompleteTurn(len(messages))
|
||||
displayedUsage = true
|
||||
@@ -977,9 +977,8 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
case msg.Ask == string(types.AskTypeCommandOutput):
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
|
||||
@@ -992,9 +991,8 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
} else {
|
||||
// Non-streaming mode: render normally when message is complete
|
||||
if !msg.Partial && !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
}
|
||||
@@ -1003,10 +1001,9 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
|
||||
msgKey := fmt.Sprintf("%d", msg.Timestamp)
|
||||
// Only render if not already handled by partial stream
|
||||
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
|
||||
coordinator.WithOutputLock(func() {
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
})
|
||||
fmt.Println()
|
||||
m.displayMessage(msg, false, false, i)
|
||||
|
||||
coordinator.MarkProcessedInCurrentTurn(msgKey)
|
||||
}
|
||||
}
|
||||
@@ -1065,15 +1062,12 @@ func (m *Manager) handleStreamingMessage(msg *types.ClineMessage, coordinator *S
|
||||
m.renderer.RenderDebug("Processing message: timestamp=%d, partial=%v, type=%s, text_preview=%s",
|
||||
msg.Timestamp, msg.Partial, msg.Type, m.truncateText(msg.Text, 50))
|
||||
|
||||
// Lock output to prevent race with input forms
|
||||
coordinator.WithOutputLock(func() {
|
||||
// Use streaming display which handles deduplication internally
|
||||
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
|
||||
// Fallback to regular display
|
||||
m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
})
|
||||
// Use streaming display which handles deduplication internally
|
||||
if err := m.streamingDisplay.HandlePartialMessage(msg); err != nil {
|
||||
m.renderer.RenderDebug("Streaming display failed, using fallback: %v", err)
|
||||
// Fallback to regular display
|
||||
m.displayMessage(msg, true, false, -1)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ type StreamCoordinator struct {
|
||||
processedInCurrentTurn map[string]bool // What we've handled in THIS turn
|
||||
inputAllowed bool // Whether user input is currently allowed
|
||||
mu sync.RWMutex // Protects inputAllowed
|
||||
outputMu sync.Mutex // Protects terminal output (prevents interleaving with input forms)
|
||||
}
|
||||
|
||||
// NewStreamCoordinator creates a new stream coordinator
|
||||
@@ -59,23 +58,3 @@ func (sc *StreamCoordinator) IsInputAllowed() bool {
|
||||
defer sc.mu.RUnlock()
|
||||
return sc.inputAllowed
|
||||
}
|
||||
|
||||
// LockOutput locks the output mutex to prevent interleaved terminal output
|
||||
// Should be called before displaying input forms
|
||||
func (sc *StreamCoordinator) LockOutput() {
|
||||
sc.outputMu.Lock()
|
||||
}
|
||||
|
||||
// UnlockOutput unlocks the output mutex
|
||||
// Should be called after input forms are dismissed
|
||||
func (sc *StreamCoordinator) UnlockOutput() {
|
||||
sc.outputMu.Unlock()
|
||||
}
|
||||
|
||||
// WithOutputLock executes a function while holding the output lock
|
||||
// This is a convenience method for wrapping output operations
|
||||
func (sc *StreamCoordinator) WithOutputLock(fn func()) {
|
||||
sc.outputMu.Lock()
|
||||
defer sc.outputMu.Unlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user