@caretive/caret-cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.npmrc.tmp +2 -0
  2. package/README.md +72 -0
  3. package/cmd/cline/main.go +348 -0
  4. package/cmd/cline-host/main.go +71 -0
  5. package/e2e/default_update_test.go +154 -0
  6. package/e2e/helpers_test.go +378 -0
  7. package/e2e/main_test.go +47 -0
  8. package/e2e/mixed_stress_test.go +120 -0
  9. package/e2e/sqlite_helper.go +161 -0
  10. package/e2e/start_list_test.go +178 -0
  11. package/go.mod +64 -0
  12. package/go.sum +162 -0
  13. package/man/cline.1 +331 -0
  14. package/man/cline.1.md +332 -0
  15. package/package.json +54 -0
  16. package/pkg/cli/auth/auth_cline_provider.go +285 -0
  17. package/pkg/cli/auth/auth_menu.go +323 -0
  18. package/pkg/cli/auth/auth_subscription.go +130 -0
  19. package/pkg/cli/auth/byo_quick_setup.go +247 -0
  20. package/pkg/cli/auth/models_cline.go +141 -0
  21. package/pkg/cli/auth/models_list_fetch.go +156 -0
  22. package/pkg/cli/auth/models_list_static.go +69 -0
  23. package/pkg/cli/auth/providers_byo.go +184 -0
  24. package/pkg/cli/auth/providers_list.go +517 -0
  25. package/pkg/cli/auth/update_api_configurations.go +647 -0
  26. package/pkg/cli/auth/wizard_byo.go +764 -0
  27. package/pkg/cli/auth/wizard_byo_bedrock.go +193 -0
  28. package/pkg/cli/auth/wizard_byo_oca.go +366 -0
  29. package/pkg/cli/auth.go +43 -0
  30. package/pkg/cli/clerror/cline_error.go +187 -0
  31. package/pkg/cli/config/manager.go +208 -0
  32. package/pkg/cli/config/settings_renderer.go +198 -0
  33. package/pkg/cli/config.go +152 -0
  34. package/pkg/cli/display/ansi.go +27 -0
  35. package/pkg/cli/display/banner.go +211 -0
  36. package/pkg/cli/display/deduplicator.go +95 -0
  37. package/pkg/cli/display/markdown_renderer.go +139 -0
  38. package/pkg/cli/display/renderer.go +304 -0
  39. package/pkg/cli/display/segment_streamer.go +212 -0
  40. package/pkg/cli/display/streaming.go +134 -0
  41. package/pkg/cli/display/system_renderer.go +269 -0
  42. package/pkg/cli/display/tool_renderer.go +455 -0
  43. package/pkg/cli/display/tool_result_parser.go +371 -0
  44. package/pkg/cli/display/typewriter.go +210 -0
  45. package/pkg/cli/doctor.go +65 -0
  46. package/pkg/cli/global/cline-clients.go +501 -0
  47. package/pkg/cli/global/global.go +113 -0
  48. package/pkg/cli/global/registry.go +304 -0
  49. package/pkg/cli/handlers/ask_handlers.go +339 -0
  50. package/pkg/cli/handlers/handler.go +130 -0
  51. package/pkg/cli/handlers/say_handlers.go +521 -0
  52. package/pkg/cli/instances.go +506 -0
  53. package/pkg/cli/logs.go +382 -0
  54. package/pkg/cli/output/coordinator.go +167 -0
  55. package/pkg/cli/output/input_model.go +497 -0
  56. package/pkg/cli/sqlite/locks.go +366 -0
  57. package/pkg/cli/task/history_handler.go +72 -0
  58. package/pkg/cli/task/input_handler.go +577 -0
  59. package/pkg/cli/task/manager.go +1283 -0
  60. package/pkg/cli/task/settings_parser.go +754 -0
  61. package/pkg/cli/task/stream_coordinator.go +60 -0
  62. package/pkg/cli/task.go +675 -0
  63. package/pkg/cli/terminal/keyboard.go +695 -0
  64. package/pkg/cli/tui/HELP_WANTED.md +1 -0
  65. package/pkg/cli/types/history.go +17 -0
  66. package/pkg/cli/types/messages.go +329 -0
  67. package/pkg/cli/types/state.go +59 -0
  68. package/pkg/cli/updater/updater.go +409 -0
  69. package/pkg/cli/version.go +43 -0
  70. package/pkg/common/constants.go +6 -0
  71. package/pkg/common/schema.go +54 -0
  72. package/pkg/common/types.go +54 -0
  73. package/pkg/common/utils.go +185 -0
  74. package/pkg/generated/field_overrides.go +39 -0
  75. package/pkg/generated/providers.go +1584 -0
  76. package/pkg/hostbridge/diff.go +351 -0
  77. package/pkg/hostbridge/disabled/watch.go +39 -0
  78. package/pkg/hostbridge/disabled/window.go +63 -0
  79. package/pkg/hostbridge/disabled/workspace.go +66 -0
  80. package/pkg/hostbridge/env.go +166 -0
  81. package/pkg/hostbridge/grpc_server.go +113 -0
  82. package/pkg/hostbridge/simple.go +43 -0
  83. package/pkg/hostbridge/simple_workspace.go +85 -0
  84. package/pkg/hostbridge/window.go +129 -0
  85. package/scripts/publish-caret-cli.sh +39 -0
@@ -0,0 +1,152 @@
1
+ package cli
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/cline/cli/pkg/cli/config"
8
+ "github.com/cline/cli/pkg/cli/global"
9
+ "github.com/cline/cli/pkg/cli/task"
10
+ "github.com/spf13/cobra"
11
+ )
12
+
13
+ var configManager *config.Manager
14
+
15
+ func ensureConfigManager(ctx context.Context, address string) error {
16
+ if configManager == nil || (address != "" && configManager.GetCurrentInstance() != address) {
17
+ var err error
18
+ var instanceAddress string
19
+
20
+ if address != "" {
21
+ // Ensure instance exists at the specified address
22
+ if err := ensureInstanceAtAddress(ctx, address); err != nil {
23
+ return fmt.Errorf("failed to ensure instance at address %s: %w", address, err)
24
+ }
25
+ configManager, err = config.NewManager(ctx, address)
26
+ instanceAddress = address
27
+ } else {
28
+ // Ensure default instance exists
29
+ if err := global.EnsureDefaultInstance(ctx); err != nil {
30
+ return fmt.Errorf("failed to ensure default instance: %w", err)
31
+ }
32
+ configManager, err = config.NewManager(ctx, "")
33
+ if err == nil {
34
+ instanceAddress = configManager.GetCurrentInstance()
35
+ }
36
+ }
37
+
38
+ if err != nil {
39
+ return fmt.Errorf("failed to create config manager: %w", err)
40
+ }
41
+
42
+ // Always set the instance we're using as the default
43
+ registry := global.Clients.GetRegistry()
44
+ if err := registry.SetDefaultInstance(instanceAddress); err != nil {
45
+ // Log warning but don't fail - this is not critical
46
+ fmt.Printf("Warning: failed to set default instance: %v\n", err)
47
+ }
48
+ }
49
+ return nil
50
+ }
51
+
52
+ func NewConfigCommand() *cobra.Command {
53
+ cmd := &cobra.Command{
54
+ Use: "config",
55
+ Aliases: []string{"c"},
56
+ Short: "Manage Cline configuration",
57
+ Long: `Set and manage global Cline configuration variables.`,
58
+ }
59
+
60
+ cmd.AddCommand(newConfigListCommand())
61
+ cmd.AddCommand(newConfigGetCommand())
62
+ cmd.AddCommand(setCommand())
63
+
64
+ return cmd
65
+ }
66
+
67
+ func newConfigGetCommand() *cobra.Command {
68
+ var address string
69
+
70
+ cmd := &cobra.Command{
71
+ Use: "get <key>",
72
+ Aliases: []string{"g"},
73
+ Short: "Get a specific configuration value",
74
+ Long: `Get the value of a specific configuration setting. Supports nested keys using dot notation (e.g., auto-approval-settings.actions.read-files).`,
75
+ Args: cobra.ExactArgs(1),
76
+ RunE: func(cmd *cobra.Command, args []string) error {
77
+ ctx := cmd.Context()
78
+ key := args[0]
79
+
80
+ // Ensure config manager
81
+ if err := ensureConfigManager(ctx, address); err != nil {
82
+ return err
83
+ }
84
+
85
+ // Get the setting
86
+ return configManager.GetSetting(ctx, key)
87
+ },
88
+ }
89
+
90
+ cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
91
+ return cmd
92
+ }
93
+
94
+ func newConfigListCommand() *cobra.Command {
95
+ var address string
96
+
97
+ cmd := &cobra.Command{
98
+ Use: "list",
99
+ Aliases: []string{"l"},
100
+ Short: "List all configuration settings",
101
+ Long: `List all configuration settings from the Cline instance.`,
102
+ RunE: func(cmd *cobra.Command, args []string) error {
103
+ ctx := cmd.Context()
104
+
105
+ // Ensure config manager
106
+ if err := ensureConfigManager(ctx, address); err != nil {
107
+ return err
108
+ }
109
+
110
+ // List settings
111
+ return configManager.ListSettings(ctx)
112
+ },
113
+ }
114
+
115
+ cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
116
+ return cmd
117
+ }
118
+
119
+ func setCommand() *cobra.Command {
120
+ var address string
121
+
122
+ cmd := &cobra.Command{
123
+ Use: "set <key=value> [key=value...]",
124
+ Aliases: []string{"s"},
125
+ Short: "Set configuration variables",
126
+ Long: `Set one or more global configuration variables using key=value format.
127
+
128
+ This command merges the provided settings with existing values, preserving
129
+ unspecified fields. Only the fields you explicitly set will be updated.`,
130
+ Args: cobra.MinimumNArgs(1),
131
+ RunE: func(cmd *cobra.Command, args []string) error {
132
+ ctx := cmd.Context()
133
+
134
+ // Parse using existing task parser
135
+ settings, secrets, err := task.ParseTaskSettings(args)
136
+ if err != nil {
137
+ return fmt.Errorf("failed to parse settings: %w", err)
138
+ }
139
+
140
+ // Ensure config manager
141
+ if err := ensureConfigManager(ctx, address); err != nil {
142
+ return err
143
+ }
144
+
145
+ // Update settings (server-side merge handles preserving existing values)
146
+ return configManager.UpdateSettings(ctx, settings, secrets)
147
+ },
148
+ }
149
+
150
+ cmd.Flags().StringVar(&address, "address", "", "specific Cline instance address to use")
151
+ return cmd
152
+ }
@@ -0,0 +1,27 @@
1
+ package display
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+
7
+ "golang.org/x/term"
8
+ )
9
+
10
+ func isTTY() bool {
11
+ return term.IsTerminal(int(os.Stdout.Fd()))
12
+ }
13
+
14
+ func ClearLine() {
15
+ if !isTTY() {
16
+ return
17
+ }
18
+ fmt.Print("\r\033[K")
19
+ }
20
+
21
+ // ClearToEnd clears from cursor to end of screen
22
+ func ClearToEnd() {
23
+ if !isTTY() {
24
+ return
25
+ }
26
+ fmt.Print("\033[J")
27
+ }
@@ -0,0 +1,211 @@
1
+ package display
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+
10
+ "github.com/charmbracelet/lipgloss"
11
+ )
12
+
13
+ // BannerInfo contains information to display in the session banner
14
+ type BannerInfo struct {
15
+ Version string
16
+ Provider string
17
+ ModelID string
18
+ Workdir string
19
+ Mode string
20
+ }
21
+
22
+ // RenderSessionBanner renders a nice banner showing version, model, and workspace info
23
+ func RenderSessionBanner(info BannerInfo) string {
24
+ // Bright white for title
25
+ titleStyle := lipgloss.NewStyle().
26
+ Foreground(lipgloss.Color("15")). // Bright white
27
+ Bold(true)
28
+
29
+ // Dim gray for regular text (same as huh placeholder)
30
+ dimStyle := lipgloss.NewStyle().
31
+ Foreground(lipgloss.AdaptiveColor{Light: "248", Dark: "238"})
32
+
33
+ // Border color matches mode
34
+ borderColor := lipgloss.Color("3") // Yellow for plan
35
+ if info.Mode == "act" {
36
+ borderColor = lipgloss.Color("39") // Blue for act
37
+ }
38
+
39
+ boxStyle := lipgloss.NewStyle().
40
+ Border(lipgloss.RoundedBorder()).
41
+ BorderForeground(borderColor).
42
+ Padding(1, 4)
43
+
44
+ var lines []string
45
+
46
+ // Format version with "v" prefix if it starts with a number
47
+ versionStr := info.Version
48
+ if len(versionStr) > 0 && versionStr[0] >= '0' && versionStr[0] <= '9' {
49
+ versionStr = "v" + versionStr
50
+ }
51
+
52
+ // First line: "cline cli vX.X.X" on left, "plan mode" on right
53
+ leftSide := titleStyle.Render("cline cli preview") + " " + dimStyle.Render(versionStr)
54
+
55
+ if info.Mode != "" {
56
+ modeColor := lipgloss.Color("3") // Yellow for plan
57
+ if info.Mode == "act" {
58
+ modeColor = lipgloss.Color("39") // Blue for act
59
+ }
60
+ modeStyle := lipgloss.NewStyle().Foreground(modeColor).Bold(true)
61
+ rightSide := modeStyle.Render(info.Mode + " mode")
62
+
63
+ // Calculate spacing to push mode to the right
64
+ // Assume a reasonable width (we'll adjust based on content)
65
+ lineWidth := 50
66
+ leftWidth := lipgloss.Width(leftSide)
67
+ rightWidth := lipgloss.Width(rightSide)
68
+ spacing := lineWidth - leftWidth - rightWidth
69
+
70
+ if spacing > 0 {
71
+ titleLine := leftSide + strings.Repeat(" ", spacing) + rightSide
72
+ lines = append(lines, titleLine)
73
+ } else {
74
+ // If too narrow, just put them on same line with a space
75
+ lines = append(lines, leftSide+" "+rightSide)
76
+ }
77
+ } else {
78
+ // No mode, just show title
79
+ lines = append(lines, leftSide)
80
+ }
81
+
82
+ // Model line - dim gray
83
+ if info.Provider != "" && info.ModelID != "" {
84
+ lines = append(lines, dimStyle.Render(info.Provider+"/"+shortenPath(info.ModelID, 30)))
85
+ }
86
+
87
+ // Workspace line - dim gray
88
+ if info.Workdir != "" {
89
+ lines = append(lines, dimStyle.Render(shortenPath(info.Workdir, 45)))
90
+ }
91
+
92
+ content := lipgloss.JoinVertical(lipgloss.Left, lines...)
93
+ return boxStyle.Render(content)
94
+ }
95
+
96
+ // shortenPath shortens a filesystem path to fit within maxLen
97
+ func shortenPath(path string, maxLen int) string {
98
+ // Try to replace home directory with ~ (cross-platform)
99
+ if homeDir, err := os.UserHomeDir(); err == nil {
100
+ if strings.HasPrefix(path, homeDir) {
101
+ shortened := "~" + path[len(homeDir):]
102
+ // Always use ~ version if we can
103
+ path = shortened
104
+ }
105
+ }
106
+
107
+ if len(path) <= maxLen {
108
+ return path
109
+ }
110
+
111
+ // If still too long, show last few path components
112
+ if len(path) > maxLen {
113
+ parts := strings.Split(path, string(filepath.Separator))
114
+ if len(parts) > 2 {
115
+ // Show last 2-3 components
116
+ lastParts := parts[len(parts)-2:]
117
+ shortened := "..." + string(filepath.Separator) + strings.Join(lastParts, string(filepath.Separator))
118
+ if len(shortened) <= maxLen {
119
+ return shortened
120
+ }
121
+ }
122
+ }
123
+
124
+ // Last resort: truncate with ellipsis
125
+ if len(path) > maxLen {
126
+ return "..." + path[len(path)-maxLen+3:]
127
+ }
128
+
129
+ return path
130
+ }
131
+
132
+ // ExtractBannerInfoFromState extracts banner info from state JSON
133
+ func ExtractBannerInfoFromState(stateJSON, version string) (BannerInfo, error) {
134
+ var state map[string]interface{}
135
+ if err := json.Unmarshal([]byte(stateJSON), &state); err != nil {
136
+ return BannerInfo{}, fmt.Errorf("failed to parse state JSON: %w", err)
137
+ }
138
+
139
+ info := BannerInfo{
140
+ Version: version,
141
+ }
142
+
143
+ // Extract mode
144
+ if mode, ok := state["mode"].(string); ok {
145
+ info.Mode = mode
146
+ }
147
+
148
+ // Extract workspace roots
149
+ if workspaceRoots, ok := state["workspaceRoots"].([]interface{}); ok && len(workspaceRoots) > 0 {
150
+ if root, ok := workspaceRoots[0].(map[string]interface{}); ok {
151
+ if path, ok := root["path"].(string); ok {
152
+ info.Workdir = path
153
+ }
154
+ }
155
+ }
156
+
157
+ // Extract API configuration to get provider/model
158
+ if apiConfig, ok := state["apiConfiguration"].(map[string]interface{}); ok {
159
+ // Try common keys for provider and model (both camelCase and lowercase variants)
160
+ providerKeys := []string{"apiProvider", "api_provider"}
161
+ modelKeys := []string{"apiModelId", "api_model_id"}
162
+
163
+ // Try to extract provider
164
+ for _, key := range providerKeys {
165
+ if provider, ok := apiConfig[key].(string); ok && provider != "" {
166
+ info.Provider = provider
167
+ break
168
+ }
169
+ }
170
+
171
+ // Try to extract model ID
172
+ for _, key := range modelKeys {
173
+ if modelID, ok := apiConfig[key].(string); ok && modelID != "" {
174
+ info.ModelID = shortenModelID(modelID)
175
+ break
176
+ }
177
+ }
178
+ }
179
+
180
+ return info, nil
181
+ }
182
+
183
+ // shortenModelID shortens long model IDs for display
184
+ func shortenModelID(modelID string) string {
185
+ // Remove date suffixes only if they're at the end (e.g., -20241022)
186
+ // Check if the model ID ends with -YYYYMMDD pattern
187
+ if len(modelID) > 9 {
188
+ suffix := modelID[len(modelID)-9:] // Last 9 chars: -20241022
189
+ if suffix[0] == '-' &&
190
+ (strings.HasPrefix(suffix[1:], "202") || strings.HasPrefix(suffix[1:], "201")) {
191
+ // Verify all remaining chars are digits
192
+ allDigits := true
193
+ for _, c := range suffix[1:] {
194
+ if c < '0' || c > '9' {
195
+ allDigits = false
196
+ break
197
+ }
198
+ }
199
+ if allDigits {
200
+ return modelID[:len(modelID)-9]
201
+ }
202
+ }
203
+ }
204
+
205
+ // If still too long, show first 40 chars
206
+ if len(modelID) > 40 {
207
+ return modelID[:37] + "..."
208
+ }
209
+
210
+ return modelID
211
+ }
@@ -0,0 +1,95 @@
1
+ package display
2
+
3
+ import (
4
+ "crypto/md5"
5
+ "fmt"
6
+ "sync"
7
+ "time"
8
+
9
+ "github.com/cline/cli/pkg/cli/types"
10
+ )
11
+
12
+ // MessageDeduplicator handles message deduplication to prevent duplicate displays
13
+ type MessageDeduplicator struct {
14
+ mu sync.RWMutex
15
+ seenMessages map[string]time.Time
16
+ maxAge time.Duration
17
+ cleanupTicker *time.Ticker
18
+ }
19
+
20
+ // NewMessageDeduplicator creates a new message deduplicator
21
+ func NewMessageDeduplicator() *MessageDeduplicator {
22
+ d := &MessageDeduplicator{
23
+ seenMessages: make(map[string]time.Time),
24
+ maxAge: 5 * time.Minute, // Keep messages for 5 minutes
25
+ cleanupTicker: time.NewTicker(1 * time.Minute), // Cleanup every minute
26
+ }
27
+
28
+ // Start cleanup goroutine
29
+ go d.cleanup()
30
+
31
+ return d
32
+ }
33
+
34
+ // IsDuplicate checks if a message is a duplicate
35
+ func (d *MessageDeduplicator) IsDuplicate(msg *types.ClineMessage) bool {
36
+ d.mu.Lock()
37
+ defer d.mu.Unlock()
38
+
39
+ // Create a hash of the message content
40
+ hash := d.hashMessage(msg)
41
+
42
+ // Check if we've seen this message recently
43
+ if lastSeen, exists := d.seenMessages[hash]; exists {
44
+ // If we've seen it within the last few seconds, it's a duplicate
45
+ if time.Since(lastSeen) < 2*time.Second {
46
+ return true
47
+ }
48
+ }
49
+
50
+ // Mark this message as seen
51
+ d.seenMessages[hash] = time.Now()
52
+ return false
53
+ }
54
+
55
+ // hashMessage creates a hash of the message for deduplication
56
+ func (d *MessageDeduplicator) hashMessage(msg *types.ClineMessage) string {
57
+ // Create a hash based on message content, type, and timestamp
58
+ content := fmt.Sprintf("%s|%s|%s|%d",
59
+ string(msg.Type),
60
+ msg.Say,
61
+ msg.Ask,
62
+ msg.Timestamp)
63
+
64
+ // For partial messages, include the text content in the hash
65
+ if msg.Partial {
66
+ content += "|" + msg.Text
67
+ }
68
+
69
+ hash := md5.Sum([]byte(content))
70
+ return fmt.Sprintf("%x", hash)
71
+ }
72
+
73
+ // cleanup removes old entries from the seen messages map
74
+ func (d *MessageDeduplicator) cleanup() {
75
+ for range d.cleanupTicker.C {
76
+ d.mu.Lock()
77
+ now := time.Now()
78
+
79
+ // Remove entries older than maxAge
80
+ for hash, timestamp := range d.seenMessages {
81
+ if now.Sub(timestamp) > d.maxAge {
82
+ delete(d.seenMessages, hash)
83
+ }
84
+ }
85
+
86
+ d.mu.Unlock()
87
+ }
88
+ }
89
+
90
+ // Stop stops the cleanup goroutine
91
+ func (d *MessageDeduplicator) Stop() {
92
+ if d.cleanupTicker != nil {
93
+ d.cleanupTicker.Stop()
94
+ }
95
+ }
@@ -0,0 +1,139 @@
1
+ package display
2
+
3
+ import (
4
+ "os"
5
+ "strconv"
6
+ "strings"
7
+
8
+ "fmt"
9
+
10
+ "github.com/charmbracelet/glamour"
11
+ "golang.org/x/term"
12
+ )
13
+
14
+ type MarkdownRenderer struct {
15
+ renderer *glamour.TermRenderer
16
+ width int
17
+ }
18
+
19
+ // i went back and forth on whether or not to enable word wrap
20
+ // setting line width to 0 enables the terminal to handle wrapping
21
+ // setting it to a terminal width enables glamour's word wrap
22
+ // the thing is, glamour's nice indentation looks really good, and
23
+ // won't work without glamour's word wrap - if you use the terminal's
24
+ // word wrap, the indentation looks weird so you have to turn it off
25
+ // and everything will be right next to the left margin
26
+ // but if you DO use glamours word wrap, it also means if you resize the terminal,
27
+ // it will scuff everything. but given that this is the case for the input anyway,
28
+ // i figure we just make things as beautiful as possible
29
+ // and if you resize the terminal, you'll learn real quick.
30
+ // anyway, you can set this to true or false to experiment
31
+ const USETERMINALWORDWRAP = true
32
+
33
+
34
+ // seems like a reliable way to check for terminals
35
+ // for now i'm keeping everything as auto
36
+ // eventually we can define a custom glamour style for ghostty / iterm
37
+ // https://github.com/charmbracelet/glamour/blob/master/styles/README.md)
38
+ func detectTerminalTheme() string {
39
+ switch os.Getenv("TERM_PROGRAM") {
40
+ case "iTerm.app", "Ghostty":
41
+ return "dark"
42
+ }
43
+ if os.Getenv("GHOSTTY_VERSION") != "" {
44
+ return "dark"
45
+ }
46
+ return "dark"
47
+ }
48
+
49
+ func glamourStyleJSON(terminalWrap bool) string {
50
+ const tmpl = `{
51
+ "document": {
52
+ "block_prefix": "\n",
53
+ "block_suffix": "\n",
54
+ "color": "252",
55
+ "margin": %s
56
+ },
57
+ "code_block": {
58
+ "margin": 0
59
+ }
60
+ }`
61
+ if terminalWrap {
62
+ return fmt.Sprintf(tmpl, "0")
63
+ }
64
+ return fmt.Sprintf(tmpl, "2")
65
+ }
66
+
67
+
68
+
69
+
70
+ func NewMarkdownRenderer() (*MarkdownRenderer, error) {
71
+ var wordWrap int
72
+ if USETERMINALWORDWRAP {
73
+ // terminal handles wrapping -> disable glamour wrap
74
+ wordWrap = 0
75
+ } else {
76
+ // glamour handles wrapping -> set to current width
77
+ wordWrap = terminalWidthOr(0)
78
+ }
79
+
80
+ r, err := glamour.NewTermRenderer(
81
+ glamour.WithStandardStyle(detectTerminalTheme()), // Load full auto style first
82
+ glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(USETERMINALWORDWRAP))), // Then override just margins
83
+ glamour.WithWordWrap(wordWrap),
84
+ glamour.WithPreservedNewLines(),
85
+ )
86
+ if err != nil {
87
+ return nil, err
88
+ }
89
+
90
+ return &MarkdownRenderer{
91
+ renderer: r,
92
+ width: 0, // Unlimited width
93
+ }, nil
94
+ }
95
+
96
+ // terminalWidthOr returns the terminal width or the provided fallback.
97
+ // It first tries term.GetSize, then falls back to $COLUMNS if set.
98
+ func terminalWidthOr(fallback int) int {
99
+ if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
100
+ return w
101
+ }
102
+ if cols := os.Getenv("COLUMNS"); cols != "" {
103
+ if n, err := strconv.Atoi(cols); err == nil && n > 0 {
104
+ return n
105
+ }
106
+ }
107
+ return fallback
108
+ }
109
+
110
+ // NewMarkdownRendererWithWidth creates a markdown renderer with a specific width.
111
+ // Useful for tables and other content that should fit within terminal bounds.
112
+ func NewMarkdownRendererWithWidth(width int) (*MarkdownRenderer, error) {
113
+ r, err := glamour.NewTermRenderer(
114
+ glamour.WithStandardStyle(detectTerminalTheme()),
115
+ glamour.WithStylesFromJSONBytes([]byte(glamourStyleJSON(false))),
116
+ glamour.WithWordWrap(width),
117
+ glamour.WithPreservedNewLines(),
118
+ )
119
+ if err != nil {
120
+ return nil, err
121
+ }
122
+ return &MarkdownRenderer{renderer: r, width: width}, nil
123
+ }
124
+
125
+
126
+ // NewMarkdownRendererForTerminal creates a markdown renderer using the actual terminal width.
127
+ // Falls back to 120 if terminal width cannot be determined.
128
+ func NewMarkdownRendererForTerminal() (*MarkdownRenderer, error) {
129
+ width := terminalWidthOr(120)
130
+ return NewMarkdownRendererWithWidth(width)
131
+ }
132
+
133
+ func (mr *MarkdownRenderer) Render(markdown string) (string, error) {
134
+ rendered, err := mr.renderer.Render(markdown)
135
+ if err != nil {
136
+ return "", err
137
+ }
138
+ return strings.TrimLeft(strings.TrimRight(rendered, "\n"), "\n"), nil
139
+ }