From e4811aae71c077b7457cffe35a8255d360005271 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Tue, 4 Aug 2026 14:10:12 -0400 Subject: [PATCH 1/2] REL-15243: add a copy key for wizard code blocks Code blocks are drawn with a left gutter bar, so selecting one by hand copies the gutter characters and the padding lipgloss squares the block off with. The wizard also owns the alternate screen, so the snippet is not in scrollback once it exits. Pressing c writes the raw content to the system clipboard with OSC 52, preferring the snippet over the install command when a screen shows both. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/commands.go | 31 +++++++ cmd/setup/copy_test.go | 185 +++++++++++++++++++++++++++++++++++++++++ cmd/setup/model.go | 14 +++- cmd/setup/update.go | 10 +++ cmd/setup/view.go | 19 ++++- go.mod | 2 +- 6 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 cmd/setup/copy_test.go diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 470600cb..0eac6113 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -1,10 +1,12 @@ package setup import ( + "fmt" "os" "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" "github.com/launchdarkly/ldcli/internal/setup" ) @@ -122,3 +124,32 @@ func (m wizardModel) runVerify() tea.Cmd { return verifyDoneMsg{result: result} } } + +// copyableContent returns the code the current screen is asking the user to copy, +// along with the word the hint uses for it. A screen can show both an install command +// and a snippet; the snippet is the one that has to be pasted verbatim, so it wins. +// Returns false when the screen has nothing to copy. +func (m wizardModel) copyableContent() (content, label string, ok bool) { + if m.step != stepDone { + return "", "", false + } + if m.initResult != nil && !m.initResult.Success && m.initResult.Snippet != "" { + return m.initResult.Snippet, "snippet", true + } + if m.installResult != nil && m.installResult.Failed && m.installResult.Command != "" { + return m.installResult.Command, "command", true + } + return "", "", false +} + +// copyToClipboard writes an OSC 52 sequence, which asks the terminal to put the +// content on the system clipboard. The wizard renders code inside a bordered block +// and runs in the alternate screen, so selecting it with the mouse picks up the +// gutter characters and the snippet is gone from scrollback once the wizard exits. +// Terminals that do not implement OSC 52 ignore the sequence. +func (m wizardModel) copyToClipboard(content string) tea.Cmd { + return func() tea.Msg { + fmt.Fprint(m.clipboard, ansi.SetSystemClipboard(content)) + return nil + } +} diff --git a/cmd/setup/copy_test.go b/cmd/setup/copy_test.go new file mode 100644 index 00000000..06d546a8 --- /dev/null +++ b/cmd/setup/copy_test.go @@ -0,0 +1,185 @@ +package setup + +import ( + "bytes" + "encoding/base64" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/setup" +) + +const snippet = "const LaunchDarkly = require('@launchdarkly/node-server-sdk');\nconst ldClient = LaunchDarkly.init('sdk-key');" + +// copyKey sends "c" and runs whatever command Update returns, returning the updated +// model and everything written to the clipboard writer. +func copyKey(t *testing.T, m wizardModel) (wizardModel, string) { + t.Helper() + var out bytes.Buffer + m.clipboard = &out + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + if cmd != nil { + cmd() + } + return next.(wizardModel), out.String() +} + +// The snippet has to arrive on the clipboard exactly as the user needs to paste it: +// the gutter bar the code block is drawn with, and the padding lipgloss adds to square +// it off, are display only and must not be copied. +func TestWizard_CopySnippet_CopiesRawContent(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + // The rendered block carries the decoration the raw copy must not. + require.Contains(t, m.View(), "│", "the code block is drawn with a gutter bar") + + updated, written := copyKey(t, m) + + require.NotEmpty(t, written, "pressing c must write an OSC 52 sequence") + assert.Equal(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(snippet))+"\x07", written) + assert.True(t, updated.copied) + + decoded := decodeOSC52(t, written) + assert.Equal(t, snippet, decoded) + assert.NotContains(t, decoded, "│", "the gutter bar must not be copied") + assert.NotContains(t, decoded, " \n", "trailing padding must not be copied") +} + +// A screen can show both an install command and a snippet. The snippet is the one +// that has to be pasted verbatim, so that is what c copies. +func TestWizard_CopySnippet_PrefersSnippetOverInstallCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + installResult: &setup.InstallResult{ + Command: "npm install @launchdarkly/node-server-sdk", + Failed: true, + }, + initResult: &setup.InitResult{ + SDKID: "node-server", + FilePath: "/proj/index.js", + Snippet: snippet, + Success: false, + }, + } + + _, written := copyKey(t, m) + assert.Equal(t, snippet, decodeOSC52(t, written)) + assert.Contains(t, m.View(), "Press c to copy the snippet.") +} + +// With no snippet to paste, the thing the user still has to carry out of the wizard +// is the install command. +func TestWizard_CopySnippet_FallsBackToInstallCommand(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + installResult: &setup.InstallResult{ + Command: "npm install @launchdarkly/node-server-sdk", + Failed: true, + }, + initResult: &setup.InitResult{SDKID: "node-server", FilePath: "/proj/index.js", Success: true}, + } + + _, written := copyKey(t, m) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", decodeOSC52(t, written)) + assert.Contains(t, m.View(), "Press c to copy the command.") +} + +// Offering a copy on a screen with nothing to copy, or writing to the terminal on a +// key the screen does not handle, would both be wrong. +func TestWizard_CopySnippet_NothingToCopy(t *testing.T) { + tests := []struct { + name string + m wizardModel + }{ + { + name: "verification succeeded, no manual step left", + m: wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{SDKID: "node-server", Success: true}, + verifyResult: &setup.VerifyResult{Active: true}, + detectResult: &setup.DetectResult{SDKID: "node-server"}, + }, + }, + { + name: "mid-flow screen shows no code", + m: wizardModel{step: stepSelectSDK, width: 80}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + updated, written := copyKey(t, tt.m) + + assert.Empty(t, written, "must not write to the terminal with nothing to copy") + assert.False(t, updated.copied) + assert.NotContains(t, tt.m.View(), "Press c to copy") + }) + } +} + +// The hint has to confirm the copy, otherwise the user has no way to tell whether the +// key did anything. +func TestWizard_CopySnippet_HintConfirmsAfterCopying(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + assert.Contains(t, m.View(), "Press c to copy the snippet.") + + updated, _ := copyKey(t, m) + view := updated.View() + assert.Contains(t, view, "Copied the snippet to your clipboard.") + assert.NotContains(t, view, "Press c to copy") +} + +// 'c' is a legal character in a filter query, so the list has to keep receiving it. +func TestWizard_CopySnippet_DoesNotStealCFromFiltering(t *testing.T) { + m := wizardModel{step: stepDetect, width: 80, height: 30} + next, _ := m.Update(detectDoneMsg{result: &setup.DetectResult{ + SDKID: "node-server", + Language: "JavaScript", + }}) + m2 := next.(wizardModel) + m2.sdkFocus = 1 + + // Open the list filter, then type "c". + filtering, _ := m2.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m3 := filtering.(wizardModel) + require.True(t, m3.isFiltering(), "expected the SDK list to be filtering") + + typed, written := copyKey(t, m3) + assert.Empty(t, written, "c must reach the filter, not the clipboard") + assert.False(t, typed.copied) +} + +func decodeOSC52(t *testing.T, seq string) string { + t.Helper() + require.True(t, len(seq) > len("\x1b]52;c;")+1, "not an OSC 52 sequence: %q", seq) + payload := seq[len("\x1b]52;c;") : len(seq)-1] + decoded, err := base64.StdEncoding.DecodeString(payload) + require.NoError(t, err) + return string(decoded) +} diff --git a/cmd/setup/model.go b/cmd/setup/model.go index 48e02fc2..e828ece9 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -1,6 +1,9 @@ package setup import ( + "io" + "os" + "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" @@ -70,6 +73,12 @@ type wizardModel struct { initResult *setup.InitResult verifyResult *setup.VerifyResult + // clipboard receives the OSC 52 sequence that copies a snippet. It is the + // terminal the TUI is drawing to, kept as a field so tests can read back the + // sequence instead of writing to the real terminal. + clipboard io.Writer + copied bool // whether the visible snippet has been copied, to confirm in the view + quitting bool } @@ -143,8 +152,9 @@ func runSetupWizard( AccessToken: viper.GetString(cliflags.AccessTokenFlag), BaseURI: viper.GetString(cliflags.BaseURIFlag), }, - step: stepSelectProject, - spinner: s, + step: stepSelectProject, + spinner: s, + clipboard: os.Stdout, } p := tea.NewProgram(m, tea.WithAltScreen()) diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 0aa90d5a..2e4ad8d2 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -34,6 +34,16 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break // let the list receive the key as filter input } return m.handleBack() + case "c": + if m.isFiltering() { + break // let the list receive the key as filter input + } + content, _, ok := m.copyableContent() + if !ok { + break + } + m.copied = true + return m, m.copyToClipboard(content) case "enter": return m.handleEnter() } diff --git a/cmd/setup/view.go b/cmd/setup/view.go index b939d181..0d0cb9c7 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -11,6 +11,20 @@ import ( var quitHint = "\n" + mutedStyle.Render("Press q to quit.") + "\n" +// copyHint labels the copy action next to a code block, or confirms the copy once +// it has happened. The block is drawn with a left gutter bar and the wizard owns the +// alternate screen, so selecting the code by hand picks up the gutter characters. +func (m wizardModel) copyHint() string { + _, label, ok := m.copyableContent() + if !ok { + return "" + } + if m.copied { + return mutedStyle.Render(fmt.Sprintf("Copied the %s to your clipboard.", label)) + "\n" + } + return mutedStyle.Render(fmt.Sprintf("Press c to copy the %s.", label)) + "\n" +} + func (m wizardModel) View() string { if m.quitting { return "" @@ -75,7 +89,7 @@ func (m wizardModel) View() string { "\n\n" + code(m.initResult.Snippet) + "\n" } body += "\n" + m.wrap(fmt.Sprintf("Flag %q was created in project %q.", m.flagKey, m.selectedProject)) + "\n" - return body + quitHint + return body + "\n" + m.copyHint() + quitHint } if m.initResult != nil && !m.initResult.Success { body := titleStyle.Render("Manual SDK setup required") + "\n\n" @@ -88,7 +102,8 @@ func (m wizardModel) View() string { return body + fmt.Sprintf("Follow the setup guide at: %s\n\n", m.initResult.DocsURL) + fmt.Sprintf("Flag %q has been created in project %q.\n", m.flagKey, m.selectedProject) + - "Once you've initialized the SDK manually, your flag will be ready to use.\n" + + "Once you've initialized the SDK manually, your flag will be ready to use.\n\n" + + m.copyHint() + quitHint } if m.verifyResult != nil && m.verifyResult.Active && m.detectResult != nil { diff --git a/go.mod b/go.mod index 46834766..6d1cf14a 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.9.3 github.com/getkin/kin-openapi v0.135.0 github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 @@ -42,7 +43,6 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect From 163a677901f7af89c544b98fe0e67ab791a28573 Mon Sep 17 00:00:00 2001 From: Francisco Fantl Date: Wed, 5 Aug 2026 10:46:09 -0400 Subject: [PATCH 2/2] REL-15243: copy through the OS clipboard before the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSC 52 alone left the key unreliable: terminals are not required to implement it, Apple Terminal does not, and support cannot be queried, so the confirmation claimed a copy that may never have happened. The OS clipboard works in any terminal and returns an error, so try it first and keep OSC 52 for when it fails — which is the SSH case, where the OS clipboard belongs to the wrong machine. Word the two outcomes apart, since only the first can be confirmed. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/setup/commands.go | 16 +++++--- cmd/setup/copy_test.go | 88 ++++++++++++++++++++++++++++++++---------- cmd/setup/model.go | 30 ++++++++++---- cmd/setup/update.go | 8 +++- cmd/setup/view.go | 5 ++- go.mod | 2 +- 6 files changed, 112 insertions(+), 37 deletions(-) diff --git a/cmd/setup/commands.go b/cmd/setup/commands.go index 0eac6113..1da5556a 100644 --- a/cmd/setup/commands.go +++ b/cmd/setup/commands.go @@ -142,14 +142,18 @@ func (m wizardModel) copyableContent() (content, label string, ok bool) { return "", "", false } -// copyToClipboard writes an OSC 52 sequence, which asks the terminal to put the -// content on the system clipboard. The wizard renders code inside a bordered block -// and runs in the alternate screen, so selecting it with the mouse picks up the -// gutter characters and the snippet is gone from scrollback once the wizard exits. -// Terminals that do not implement OSC 52 ignore the sequence. +// copyToClipboard puts the content on the clipboard, preferring the operating +// system's own clipboard because it works in every terminal and reports whether it +// succeeded. OSC 52 is the fallback: it asks the terminal to do the copying, which is +// what works over SSH, where the OS clipboard belongs to the wrong machine. Not every +// terminal implements OSC 52 and support cannot be queried, so a copy that goes that +// route is reported as a request rather than a result. func (m wizardModel) copyToClipboard(content string) tea.Cmd { return func() tea.Msg { + if err := m.nativeCopy(content); err == nil { + return copiedMsg{viaTerminal: false} + } fmt.Fprint(m.clipboard, ansi.SetSystemClipboard(content)) - return nil + return copiedMsg{viaTerminal: true} } } diff --git a/cmd/setup/copy_test.go b/cmd/setup/copy_test.go index 06d546a8..641ccc32 100644 --- a/cmd/setup/copy_test.go +++ b/cmd/setup/copy_test.go @@ -3,6 +3,7 @@ package setup import ( "bytes" "encoding/base64" + "errors" "testing" tea "github.com/charmbracelet/bubbletea" @@ -14,18 +15,35 @@ import ( const snippet = "const LaunchDarkly = require('@launchdarkly/node-server-sdk');\nconst ldClient = LaunchDarkly.init('sdk-key');" -// copyKey sends "c" and runs whatever command Update returns, returning the updated -// model and everything written to the clipboard writer. +// copyKey sends "c" with a working OS clipboard, and returns the updated model +// alongside what each path received. func copyKey(t *testing.T, m wizardModel) (wizardModel, string) { + t.Helper() + var native string + updated, terminal := copyKeyWith(t, m, func(s string) error { + native = s + return nil + }) + return updated, native + terminal +} + +// copyKeyWith sends "c" with the given OS clipboard behaviour, and returns the +// updated model and whatever was written to the terminal as an OSC 52 sequence. +func copyKeyWith(t *testing.T, m wizardModel, native func(string) error) (wizardModel, string) { t.Helper() var out bytes.Buffer m.clipboard = &out + m.nativeCopy = native next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + m = next.(wizardModel) if cmd != nil { - cmd() + if msg := cmd(); msg != nil { + next, _ = m.Update(msg) + m = next.(wizardModel) + } } - return next.(wizardModel), out.String() + return m, out.String() } // The snippet has to arrive on the clipboard exactly as the user needs to paste it: @@ -46,16 +64,16 @@ func TestWizard_CopySnippet_CopiesRawContent(t *testing.T) { // The rendered block carries the decoration the raw copy must not. require.Contains(t, m.View(), "│", "the code block is drawn with a gutter bar") - updated, written := copyKey(t, m) + var native string + updated, _ := copyKeyWith(t, m, func(s string) error { + native = s + return nil + }) - require.NotEmpty(t, written, "pressing c must write an OSC 52 sequence") - assert.Equal(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(snippet))+"\x07", written) - assert.True(t, updated.copied) - - decoded := decodeOSC52(t, written) - assert.Equal(t, snippet, decoded) - assert.NotContains(t, decoded, "│", "the gutter bar must not be copied") - assert.NotContains(t, decoded, " \n", "trailing padding must not be copied") + assert.Equal(t, snippet, native) + assert.Equal(t, copyDone, updated.copyState) + assert.NotContains(t, native, "│", "the gutter bar must not be copied") + assert.NotContains(t, native, " \n", "trailing padding must not be copied") } // A screen can show both an install command and a snippet. The snippet is the one @@ -76,8 +94,8 @@ func TestWizard_CopySnippet_PrefersSnippetOverInstallCommand(t *testing.T) { }, } - _, written := copyKey(t, m) - assert.Equal(t, snippet, decodeOSC52(t, written)) + _, copied := copyKey(t, m) + assert.Equal(t, snippet, copied) assert.Contains(t, m.View(), "Press c to copy the snippet.") } @@ -94,8 +112,8 @@ func TestWizard_CopySnippet_FallsBackToInstallCommand(t *testing.T) { initResult: &setup.InitResult{SDKID: "node-server", FilePath: "/proj/index.js", Success: true}, } - _, written := copyKey(t, m) - assert.Equal(t, "npm install @launchdarkly/node-server-sdk", decodeOSC52(t, written)) + _, copied := copyKey(t, m) + assert.Equal(t, "npm install @launchdarkly/node-server-sdk", copied) assert.Contains(t, m.View(), "Press c to copy the command.") } @@ -126,8 +144,8 @@ func TestWizard_CopySnippet_NothingToCopy(t *testing.T) { t.Run(tt.name, func(t *testing.T) { updated, written := copyKey(t, tt.m) - assert.Empty(t, written, "must not write to the terminal with nothing to copy") - assert.False(t, updated.copied) + assert.Empty(t, written, "must not copy anything with nothing to copy") + assert.Equal(t, copyNone, updated.copyState) assert.NotContains(t, tt.m.View(), "Press c to copy") }) } @@ -172,7 +190,37 @@ func TestWizard_CopySnippet_DoesNotStealCFromFiltering(t *testing.T) { typed, written := copyKey(t, m3) assert.Empty(t, written, "c must reach the filter, not the clipboard") - assert.False(t, typed.copied) + assert.Equal(t, copyNone, typed.copyState) +} + +// Over SSH the OS clipboard belongs to the wrong machine, so a failure there falls +// back to asking the terminal. That path cannot be confirmed, so the hint must not +// claim the content is on the clipboard. +func TestWizard_CopySnippet_FallsBackToTerminalWhenOSClipboardFails(t *testing.T) { + m := wizardModel{ + step: stepDone, + width: 80, + initResult: &setup.InitResult{ + SDKID: "go-server-sdk", + FilePath: "/proj/main.go", + Snippet: snippet, + Success: false, + }, + } + + updated, written := copyKeyWith(t, m, func(string) error { + return errors.New("no clipboard on this machine") + }) + + require.NotEmpty(t, written, "a failed OS copy must fall back to OSC 52") + assert.Equal(t, "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte(snippet))+"\x07", written) + assert.Equal(t, snippet, decodeOSC52(t, written)) + assert.Equal(t, copyRequested, updated.copyState) + + view := updated.View() + assert.Contains(t, view, "Asked your terminal to copy the snippet.") + assert.NotContains(t, view, "Copied the snippet to your clipboard.", + "OSC 52 support cannot be detected, so the copy must not be claimed as done") } func decodeOSC52(t *testing.T, seq string) string { diff --git a/cmd/setup/model.go b/cmd/setup/model.go index e828ece9..78c602b6 100644 --- a/cmd/setup/model.go +++ b/cmd/setup/model.go @@ -4,6 +4,7 @@ import ( "io" "os" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" @@ -16,6 +17,16 @@ import ( "github.com/launchdarkly/ldcli/internal/setup" ) +// copyState records how the visible snippet was copied, so the view can confirm a +// clipboard write outright but only claim to have asked when the terminal did it. +type copyState int + +const ( + copyNone copyState = iota + copyDone // written to the OS clipboard + copyRequested // handed to the terminal over OSC 52, which cannot confirm +) + type wizardStep int const ( @@ -73,11 +84,12 @@ type wizardModel struct { initResult *setup.InitResult verifyResult *setup.VerifyResult - // clipboard receives the OSC 52 sequence that copies a snippet. It is the - // terminal the TUI is drawing to, kept as a field so tests can read back the - // sequence instead of writing to the real terminal. - clipboard io.Writer - copied bool // whether the visible snippet has been copied, to confirm in the view + // nativeCopy puts content on the operating system's clipboard, and clipboard + // receives the OSC 52 sequence used when that is not available. Both are fields + // so tests can drive either path without a real clipboard or terminal. + nativeCopy func(string) error + clipboard io.Writer + copyState copyState quitting bool } @@ -128,6 +140,7 @@ type detectFailedMsg struct{} type installDoneMsg struct{ result *setup.InstallResult } type flagCreatedMsg struct{ key string } type initDoneMsg struct{ result *setup.InitResult } +type copiedMsg struct{ viaTerminal bool } type verifyDoneMsg struct{ result *setup.VerifyResult } type wizardErrMsg struct{ err error } @@ -152,9 +165,10 @@ func runSetupWizard( AccessToken: viper.GetString(cliflags.AccessTokenFlag), BaseURI: viper.GetString(cliflags.BaseURIFlag), }, - step: stepSelectProject, - spinner: s, - clipboard: os.Stdout, + step: stepSelectProject, + spinner: s, + clipboard: os.Stdout, + nativeCopy: clipboard.WriteAll, } p := tea.NewProgram(m, tea.WithAltScreen()) diff --git a/cmd/setup/update.go b/cmd/setup/update.go index 2e4ad8d2..79cf18f8 100644 --- a/cmd/setup/update.go +++ b/cmd/setup/update.go @@ -42,12 +42,18 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !ok { break } - m.copied = true return m, m.copyToClipboard(content) case "enter": return m.handleEnter() } + case copiedMsg: + m.copyState = copyDone + if msg.viaTerminal { + m.copyState = copyRequested + } + return m, nil + case projectsFetchedMsg: m.projects = msg.projects items := make([]list.Item, len(msg.projects)) diff --git a/cmd/setup/view.go b/cmd/setup/view.go index 0d0cb9c7..dc6f8344 100644 --- a/cmd/setup/view.go +++ b/cmd/setup/view.go @@ -19,8 +19,11 @@ func (m wizardModel) copyHint() string { if !ok { return "" } - if m.copied { + switch m.copyState { + case copyDone: return mutedStyle.Render(fmt.Sprintf("Copied the %s to your clipboard.", label)) + "\n" + case copyRequested: + return mutedStyle.Render(fmt.Sprintf("Asked your terminal to copy the %s.", label)) + "\n" } return mutedStyle.Render(fmt.Sprintf("Press c to copy the %s.", label)) + "\n" } diff --git a/go.mod b/go.mod index 6d1cf14a..8ca60f0c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.3 require ( github.com/adrg/xdg v0.5.3 + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/glamour v0.10.0 @@ -39,7 +40,6 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect