@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.
- package/.npmrc.tmp +2 -0
- package/README.md +72 -0
- package/cmd/cline/main.go +348 -0
- package/cmd/cline-host/main.go +71 -0
- package/e2e/default_update_test.go +154 -0
- package/e2e/helpers_test.go +378 -0
- package/e2e/main_test.go +47 -0
- package/e2e/mixed_stress_test.go +120 -0
- package/e2e/sqlite_helper.go +161 -0
- package/e2e/start_list_test.go +178 -0
- package/go.mod +64 -0
- package/go.sum +162 -0
- package/man/cline.1 +331 -0
- package/man/cline.1.md +332 -0
- package/package.json +54 -0
- package/pkg/cli/auth/auth_cline_provider.go +285 -0
- package/pkg/cli/auth/auth_menu.go +323 -0
- package/pkg/cli/auth/auth_subscription.go +130 -0
- package/pkg/cli/auth/byo_quick_setup.go +247 -0
- package/pkg/cli/auth/models_cline.go +141 -0
- package/pkg/cli/auth/models_list_fetch.go +156 -0
- package/pkg/cli/auth/models_list_static.go +69 -0
- package/pkg/cli/auth/providers_byo.go +184 -0
- package/pkg/cli/auth/providers_list.go +517 -0
- package/pkg/cli/auth/update_api_configurations.go +647 -0
- package/pkg/cli/auth/wizard_byo.go +764 -0
- package/pkg/cli/auth/wizard_byo_bedrock.go +193 -0
- package/pkg/cli/auth/wizard_byo_oca.go +366 -0
- package/pkg/cli/auth.go +43 -0
- package/pkg/cli/clerror/cline_error.go +187 -0
- package/pkg/cli/config/manager.go +208 -0
- package/pkg/cli/config/settings_renderer.go +198 -0
- package/pkg/cli/config.go +152 -0
- package/pkg/cli/display/ansi.go +27 -0
- package/pkg/cli/display/banner.go +211 -0
- package/pkg/cli/display/deduplicator.go +95 -0
- package/pkg/cli/display/markdown_renderer.go +139 -0
- package/pkg/cli/display/renderer.go +304 -0
- package/pkg/cli/display/segment_streamer.go +212 -0
- package/pkg/cli/display/streaming.go +134 -0
- package/pkg/cli/display/system_renderer.go +269 -0
- package/pkg/cli/display/tool_renderer.go +455 -0
- package/pkg/cli/display/tool_result_parser.go +371 -0
- package/pkg/cli/display/typewriter.go +210 -0
- package/pkg/cli/doctor.go +65 -0
- package/pkg/cli/global/cline-clients.go +501 -0
- package/pkg/cli/global/global.go +113 -0
- package/pkg/cli/global/registry.go +304 -0
- package/pkg/cli/handlers/ask_handlers.go +339 -0
- package/pkg/cli/handlers/handler.go +130 -0
- package/pkg/cli/handlers/say_handlers.go +521 -0
- package/pkg/cli/instances.go +506 -0
- package/pkg/cli/logs.go +382 -0
- package/pkg/cli/output/coordinator.go +167 -0
- package/pkg/cli/output/input_model.go +497 -0
- package/pkg/cli/sqlite/locks.go +366 -0
- package/pkg/cli/task/history_handler.go +72 -0
- package/pkg/cli/task/input_handler.go +577 -0
- package/pkg/cli/task/manager.go +1283 -0
- package/pkg/cli/task/settings_parser.go +754 -0
- package/pkg/cli/task/stream_coordinator.go +60 -0
- package/pkg/cli/task.go +675 -0
- package/pkg/cli/terminal/keyboard.go +695 -0
- package/pkg/cli/tui/HELP_WANTED.md +1 -0
- package/pkg/cli/types/history.go +17 -0
- package/pkg/cli/types/messages.go +329 -0
- package/pkg/cli/types/state.go +59 -0
- package/pkg/cli/updater/updater.go +409 -0
- package/pkg/cli/version.go +43 -0
- package/pkg/common/constants.go +6 -0
- package/pkg/common/schema.go +54 -0
- package/pkg/common/types.go +54 -0
- package/pkg/common/utils.go +185 -0
- package/pkg/generated/field_overrides.go +39 -0
- package/pkg/generated/providers.go +1584 -0
- package/pkg/hostbridge/diff.go +351 -0
- package/pkg/hostbridge/disabled/watch.go +39 -0
- package/pkg/hostbridge/disabled/window.go +63 -0
- package/pkg/hostbridge/disabled/workspace.go +66 -0
- package/pkg/hostbridge/env.go +166 -0
- package/pkg/hostbridge/grpc_server.go +113 -0
- package/pkg/hostbridge/simple.go +43 -0
- package/pkg/hostbridge/simple_workspace.go +85 -0
- package/pkg/hostbridge/window.go +129 -0
- 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
|
+
}
|