Преглед изворни кода

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
pashpashpash пре 2 месеци
родитељ
комит
aa2cbc39a4

+ 8 - 2
cli/go.mod

@@ -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
cli/go.sum

@@ -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=

+ 13 - 12
cli/pkg/cli/display/renderer.go

@@ -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 {

+ 8 - 7
cli/pkg/cli/display/segment_streamer.go

@@ -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)
 		}
 	}
 }

+ 14 - 13
cli/pkg/cli/handlers/ask_handlers.go

@@ -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
 }

+ 18 - 17
cli/pkg/cli/handlers/say_handlers.go

@@ -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
 }
 

+ 167 - 0
cli/pkg/cli/output/coordinator.go

@@ -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()
+}

+ 470 - 0
cli/pkg/cli/output/input_model.go

@@ -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}
+	})
+}

+ 245 - 132
cli/pkg/cli/task/input_handler.go

@@ -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)
-	}
+	model := output.NewInputModel(
+		output.InputTypeMessage,
+		"Cline is ready for your message",
+		"/plan or /act to switch modes\ncntrl+e to open editor",
+		currentMode,
+	)
 
-	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),
-		),
+	return ih.runInputProgram(ctx, model)
+}
+
+// promptForApproval displays an approval prompt for tool/command requests
+func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineMessage) (bool, string, error) {
+	model := output.NewInputModel(
+		output.InputTypeApproval,
+		"Let Cline use this tool?",
+		"",
+		ih.manager.GetCurrentMode(),
 	)
 
-	// Run the form
-	err := form.Run()
+	message, shouldSend, err := ih.runInputProgram(ctx, model)
 	if err != nil {
-		return "", false, err
+		return false, "", err
 	}
 
-	// Trim whitespace
-	message = strings.TrimSpace(message)
-
-	// If empty, user just wants to keep watching
-	if message == "" {
-		return "", false, nil
+	if !shouldSend {
+		return false, "", nil
 	}
 
-	return message, true, nil
+	// The approval and feedback are handled via the model state
+	return ih.feedbackApproved, message, nil
 }
 
-// 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),
-		),
-	)
+// 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()
 
-	err := form.Run()
-	if err != nil {
-		return false, "", err
+	// 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
 	}
+}
 
-	// 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
+// 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
 
-		feedback = strings.TrimSpace(feedback)
+	case output.ChangeInputTypeMsg:
+		// Change input type (approval -> feedback)
+		_, cmd := w.model.Update(msg)
+		return w, cmd
 	}
 
-	return approved, feedback, nil
+	// 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
cli/pkg/cli/task/manager.go

@@ -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
 }

+ 0 - 21
cli/pkg/cli/task/stream_coordinator.go

@@ -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()
-}

+ 7 - 12
go.work.sum

@@ -8,14 +8,12 @@ github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHl
 github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
 github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
 github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
-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-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
 github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
-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/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
 github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
@@ -25,12 +23,9 @@ golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
 google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
-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=
+modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

+ 24 - 4
scripts/build-cli.sh

@@ -21,8 +21,25 @@ LDFLAGS="-X 'github.com/cline/cli/pkg/cli.Version=${VERSION}' \
 
 cd cli
 
+# Detect current platform
+OS=$(uname -s | tr '[:upper:]' '[:lower:]')
+ARCH=$(uname -m)
+
+# Normalize architecture names
+case "$ARCH" in
+    x86_64)
+        ARCH="amd64"
+        ;;
+    aarch64)
+        ARCH="arm64"
+        ;;
+    arm64)
+        ARCH="arm64"
+        ;;
+esac
+
 # Build for current platform only
-echo "Building for current platform..."
+echo "Building for current platform ($OS-$ARCH)..."
 
 GO111MODULE=on go build -ldflags "$LDFLAGS" -o bin/cline ./cmd/cline
 echo "  ✓ bin/cline built"
@@ -33,8 +50,11 @@ echo "  ✓ bin/cline-host built"
 echo ""
 echo "Build complete for current platform!"
 
-# Copy binaries to dist-standalone/bin
+# Copy binaries to dist-standalone/bin with platform-specific names AND generic names
 cd ..
 mkdir -p dist-standalone/bin
-cp cli/bin/cline-* dist-standalone/bin/
-echo 'Copied all platform binaries to dist-standalone/bin/'
+cp cli/bin/cline dist-standalone/bin/cline
+cp cli/bin/cline dist-standalone/bin/cline-${OS}-${ARCH}
+cp cli/bin/cline-host dist-standalone/bin/cline-host
+cp cli/bin/cline-host dist-standalone/bin/cline-host-${OS}-${ARCH}
+echo "Copied binaries to dist-standalone/bin/ (both generic and platform-specific names)"