From cc8e04777077196ac7eafaa73f0f6d3a0b18d4a0 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Fri, 22 May 2026 19:32:49 +0900 Subject: [PATCH 1/2] feat: add slash command picker and merge worktree input fixes Show available slash commands when typing / in compose, execute them through Slack's command endpoint, and merge the pending worktree input improvements for tmux keyboard reporting and clipboard paste handling. --- cmd/slk/main.go | 92 ++++++++--- internal/slack/client.go | 109 +++++++++++++ internal/slack/client_test.go | 52 ++++++ internal/ui/app.go | 238 +++++++++++++++++++++------- internal/ui/app_test.go | 103 +++++++++++- internal/ui/clipboard_darwin.go | 9 ++ internal/ui/clipboard_darwin_cgo.go | 91 +++++++++++ internal/ui/clipboard_default.go | 9 ++ internal/ui/compose/model.go | 143 +++++++++++++++++ internal/ui/compose/model_test.go | 50 ++++++ internal/ui/slashpicker/model.go | 144 +++++++++++++++++ 11 files changed, 964 insertions(+), 76 deletions(-) create mode 100644 internal/ui/clipboard_darwin.go create mode 100644 internal/ui/clipboard_darwin_cgo.go create mode 100644 internal/ui/clipboard_default.go create mode 100644 internal/ui/slashpicker/model.go diff --git a/cmd/slk/main.go b/cmd/slk/main.go index a2eebf2..056192c 100644 --- a/cmd/slk/main.go +++ b/cmd/slk/main.go @@ -37,6 +37,7 @@ import ( "github.com/gammons/slk/internal/ui/presencemenu" "github.com/gammons/slk/internal/ui/reactionpicker" "github.com/gammons/slk/internal/ui/sidebar" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/statusbar" "github.com/gammons/slk/internal/ui/styles" "github.com/gammons/slk/internal/ui/themeswitcher" @@ -93,10 +94,10 @@ func (a sectionsProviderAdapter) OrderedSlackSections() []sidebar.SectionMeta { // WorkspaceContext holds all state for a single connected workspace. type WorkspaceContext struct { - Client *slackclient.Client - ConnMgr *slackclient.ConnectionManager - RTMHandler *rtmEventHandler - UserNames map[string]string + Client *slackclient.Client + ConnMgr *slackclient.ConnectionManager + RTMHandler *rtmEventHandler + UserNames map[string]string // AvatarURLs maps userID -> avatar image URL. Populated from the // local users cache at connect time (synchronous, before any // goroutines spin up) and refreshed from the background @@ -120,7 +121,7 @@ type WorkspaceContext struct { // background users.list fetch and any on-demand resolveUser calls. // Used during channel construction to bucket app DMs into a separate // "Apps" sidebar section. - BotUserIDs map[string]bool + BotUserIDs map[string]bool // SectionStore holds the user's Slack-native sidebar sections for // this workspace. Nil when use_slack_sections is disabled, the // REST bootstrap failed, or this workspace hasn't connected yet. @@ -149,7 +150,7 @@ type WorkspaceContext struct { // after a failed one. The UI uses it to decide whether to draw // the "Threads list unavailable" banner. SubscriptionsAvailable bool - Channels []sidebar.ChannelItem + Channels []sidebar.ChannelItem // FinderItems is the merged list shown in the Ctrl+T finder. Initially // contains only joined channels; the BrowseableChannelsLoadedMsg pipeline // extends it with non-joined public channels in the background. @@ -159,6 +160,7 @@ type WorkspaceContext struct { UserID string UnresolvedDMs []UnresolvedDM CustomEmoji map[string]string // emoji name -> URL or "alias:target" + SlashCommands []slashpicker.Command // Self presence and DND state for this workspace. Populated on connect // and updated by manual_presence_change / dnd_updated WS events plus // optimistic writes from the presence menu. @@ -1011,6 +1013,20 @@ func run() error { } }) + app.SetSlashCommandRunner(func(channelID, text string) tea.Msg { + wctx := router.Active() + if wctx == nil { + return ui.ToastMsg{Text: "Command failed: no active workspace"} + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := wctx.Client.ExecuteSlashCommand(ctx, channelID, text); err != nil { + log.Printf("Warning: failed to execute slash command: %v", err) + return ui.ToastMsg{Text: "Command failed: " + err.Error()} + } + return ui.ToastMsg{Text: "Command sent"} + }) + app.SetMessageEditor(func(channelID, ts, text string) tea.Msg { wctx := router.Active() if wctx == nil { @@ -1313,6 +1329,7 @@ func run() error { ExternalUsers: external, UserID: wctx.UserID, CustomEmoji: wctx.CustomEmoji, + SlashCommands: wctx.SlashCommands, SectionsProvider: sectionsProviderAdapter{store: wctx.SectionStore}, } }) @@ -1457,6 +1474,7 @@ func run() error { ExternalUsers: external, UserID: wctx.UserID, CustomEmoji: wctx.CustomEmoji, // empty at this point; filled by the goroutine below + SlashCommands: wctx.SlashCommands, SectionsProvider: sectionsProviderAdapter{store: wctx.SectionStore}, InitialActive: isInitial, }) @@ -1485,19 +1503,19 @@ func run() error { // Resolve unknown DM user names in background if len(wctx.UnresolvedDMs) > 0 { go func() { - for _, dm := range wctx.UnresolvedDMs { - resolved, isBot := resolveUser(wctx.Client, dm.UserID, wctx.UserNames, db, avatarCache) - if isBot { - wctx.BotUserIDs[dm.UserID] = true - } - if resolved != dm.UserID { - p.Send(ui.DMNameResolvedMsg{ - ChannelID: dm.ChannelID, - DisplayName: resolved, - IsBot: isBot, - }) + for _, dm := range wctx.UnresolvedDMs { + resolved, isBot := resolveUser(wctx.Client, dm.UserID, wctx.UserNames, db, avatarCache) + if isBot { + wctx.BotUserIDs[dm.UserID] = true + } + if resolved != dm.UserID { + p.Send(ui.DMNameResolvedMsg{ + ChannelID: dm.ChannelID, + DisplayName: resolved, + IsBot: isBot, + }) + } } - } }() } }(ot.Token) @@ -1532,6 +1550,38 @@ func run() error { return err } +func buildSlashPickerCommands(commands []slackclient.SlashCommand) []slashpicker.Command { + out := make([]slashpicker.Command, 0, len(commands)) + for _, cmd := range commands { + if cmd.Command == "" { + continue + } + out = append(out, slashpicker.Command{ + Name: cmd.Command, + Description: cmd.Description, + UsageHint: cmd.UsageHint, + }) + } + return out +} + +func defaultSlashPickerCommands() []slashpicker.Command { + return []slashpicker.Command{ + {Name: "/away", Description: "Set yourself away"}, + {Name: "/active", Description: "Set yourself active"}, + {Name: "/dnd", Description: "Manage do not disturb", UsageHint: "[minutes]"}, + {Name: "/invite", Description: "Invite people to this channel", UsageHint: "@user"}, + {Name: "/leave", Description: "Leave the current channel"}, + {Name: "/me", Description: "Post an emote message", UsageHint: "action"}, + {Name: "/msg", Description: "Open a direct message", UsageHint: "@user message"}, + {Name: "/remind", Description: "Create a reminder", UsageHint: "who what when"}, + {Name: "/shrug", Description: "Append a shrug", UsageHint: "message"}, + {Name: "/status", Description: "Set your status", UsageHint: "text"}, + {Name: "/topic", Description: "Set the channel topic", UsageHint: "text"}, + {Name: "/who", Description: "See who is in the channel"}, + } +} + func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB, cfg config.Config, avatarCache *avatar.Cache, p *tea.Program) (*WorkspaceContext, error) { client := slackclient.NewClient(token.AccessToken, token.Cookie) if err := client.Connect(ctx); err != nil { @@ -1548,6 +1598,7 @@ func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB UserNamesByHandle: make(map[string]string), BotUserIDs: make(map[string]bool), CustomEmoji: make(map[string]string), + SlashCommands: defaultSlashPickerCommands(), LastVisitedByChannel: make(map[string]int64), } wctx.SubscriptionsAvailable = true @@ -1632,6 +1683,11 @@ func connectWorkspace(ctx context.Context, token slackclient.Token, db *cache.DB } else { wctx.LastVisitedByChannel = visits } + if commands, err := client.ListSlashCommands(ctx); err == nil { + if pickerCommands := buildSlashPickerCommands(commands); len(pickerCommands) > 0 { + wctx.SlashCommands = pickerCommands + } + } // Initialize Slack-native section store if enabled. Bootstrap is // best-effort: failure is logged, the field stays nil, and the diff --git a/internal/slack/client.go b/internal/slack/client.go index d170478..d59afd7 100644 --- a/internal/slack/client.go +++ b/internal/slack/client.go @@ -525,6 +525,12 @@ type HistorySinceResult struct { Capped bool } +type SlashCommand struct { + Command string + Description string + UsageHint string +} + // GetHistorySince fetches all messages newer than `oldest` for the // given channel, paginating through next_cursor up to a hard ceiling // of maxTotal messages. Slack returns messages newest-first per page; @@ -639,6 +645,109 @@ func (c *Client) SendMessage(ctx context.Context, channelID, text string) (strin return ts, mr, nil } +func (c *Client) ExecuteSlashCommand(ctx context.Context, channelID, text string) error { + command, args, ok := splitSlashCommand(text) + if channelID == "" { + return fmt.Errorf("executing slash command: missing channel") + } + if !ok { + return fmt.Errorf("executing slash command: invalid command") + } + + body, err := c.postForm(ctx, "chat.command", url.Values{ + "channel": {channelID}, + "command": {command}, + "text": {args}, + }) + if err != nil { + return fmt.Errorf("executing slash command: %w", err) + } + + var resp struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return fmt.Errorf("parsing chat.command response: %w (body=%q)", err, truncateForLog(body)) + } + if !resp.OK { + if resp.Error == "" { + resp.Error = "unknown_error" + } + return fmt.Errorf("chat.command: %s", resp.Error) + } + return nil +} + +func splitSlashCommand(text string) (command, args string, ok bool) { + trimmed := strings.TrimSpace(text) + if !strings.HasPrefix(trimmed, "/") || len(trimmed) == 1 { + return "", "", false + } + for i, r := range trimmed { + if i == 0 { + continue + } + if r == ' ' || r == '\t' || r == '\n' { + return trimmed[:i], strings.TrimSpace(trimmed[i:]), true + } + } + return trimmed, "", true +} + +func (c *Client) ListSlashCommands(ctx context.Context) ([]SlashCommand, error) { + body, err := c.postForm(ctx, "commands.list", nil) + if err != nil { + return nil, fmt.Errorf("listing slash commands: %w", err) + } + + var resp struct { + OK bool `json:"ok"` + Error string `json:"error"` + Commands []struct { + Command string `json:"command"` + Name string `json:"name"` + Description string `json:"description"` + UsageHint string `json:"usage_hint"` + ArgHint string `json:"arg_hint"` + Hint string `json:"hint"` + } `json:"commands"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parsing commands.list response: %w (body=%q)", err, truncateForLog(body)) + } + if !resp.OK { + if resp.Error == "" { + resp.Error = "unknown_error" + } + return nil, fmt.Errorf("commands.list: %s", resp.Error) + } + + commands := make([]SlashCommand, 0, len(resp.Commands)) + for _, cmd := range resp.Commands { + name := cmd.Command + if name == "" { + name = cmd.Name + } + if name == "" { + continue + } + hint := cmd.UsageHint + if hint == "" { + hint = cmd.ArgHint + } + if hint == "" { + hint = cmd.Hint + } + commands = append(commands, SlashCommand{ + Command: name, + Description: cmd.Description, + UsageHint: hint, + }) + } + return commands, nil +} + // UploadFile uploads a single file to a channel (and optional thread) // using Slack's V2 external-upload flow. The slack-go library's // UploadFileContext (named for the underlying file.upload.v2 API) diff --git a/internal/slack/client_test.go b/internal/slack/client_test.go index e816778..5fa9f4b 100644 --- a/internal/slack/client_test.go +++ b/internal/slack/client_test.go @@ -130,6 +130,58 @@ func TestSendMessage_EmptyTextSendsNoBlocks(t *testing.T) { } } +func TestExecuteSlashCommand_PostsChatCommandForm(t *testing.T) { + var gotPath string + var gotAuth string + var gotForm url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + gotForm = r.Form + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + c := &Client{token: "xoxc-test", cookie: "d-cookie", apiBaseURL: srv.URL + "/api/"} + if err := c.ExecuteSlashCommand(context.Background(), "C1", "/invite @alice"); err != nil { + t.Fatalf("ExecuteSlashCommand: %v", err) + } + if gotPath != "/api/chat.command" { + t.Errorf("path = %q, want /api/chat.command", gotPath) + } + if gotAuth != "Bearer xoxc-test" { + t.Errorf("Authorization = %q, want bearer token", gotAuth) + } + if gotForm.Get("channel") != "C1" { + t.Errorf("channel = %q, want C1", gotForm.Get("channel")) + } + if gotForm.Get("command") != "/invite" { + t.Errorf("command = %q, want /invite", gotForm.Get("command")) + } + if gotForm.Get("text") != "@alice" { + t.Errorf("text = %q, want @alice", gotForm.Get("text")) + } +} + +func TestExecuteSlashCommand_ReturnsSlackError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"unknown_command"}`)) + })) + defer srv.Close() + + c := &Client{token: "xoxc-test", cookie: "d-cookie", apiBaseURL: srv.URL + "/api/"} + if err := c.ExecuteSlashCommand(context.Background(), "C1", "/doesnotexist"); err == nil || !strings.Contains(err.Error(), "unknown_command") { + t.Fatalf("expected unknown_command error, got %v", err) + } +} + // mockSlackAPI implements SlackAPI for testing. // Function fields allow tests to override default behavior. type mockSlackAPI struct { diff --git a/internal/ui/app.go b/internal/ui/app.go index 27dfeb3..eafa8e8 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -19,7 +19,6 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" - "golang.design/x/clipboard" "github.com/gammons/slk/internal/cache" "github.com/gammons/slk/internal/config" "github.com/gammons/slk/internal/debuglog" @@ -37,6 +36,7 @@ import ( "github.com/gammons/slk/internal/ui/presencemenu" "github.com/gammons/slk/internal/ui/reactionpicker" "github.com/gammons/slk/internal/ui/sidebar" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/statusbar" "github.com/gammons/slk/internal/ui/styles" "github.com/gammons/slk/internal/ui/themeswitcher" @@ -44,6 +44,7 @@ import ( "github.com/gammons/slk/internal/ui/threadsview" "github.com/gammons/slk/internal/ui/workspace" "github.com/gammons/slk/internal/ui/workspacefinder" + "golang.design/x/clipboard" ) type Panel int @@ -134,6 +135,10 @@ type ( ChannelID string Text string } + SlashCommandMsg struct { + ChannelID string + Text string + } ThreadOpenedMsg struct { ChannelID string ThreadTS string @@ -251,6 +256,7 @@ type ( ExternalUsers map[string]bool UserID string CustomEmoji map[string]string + SlashCommands []slashpicker.Command // SectionsProvider supplies Slack-native sidebar sections for this // workspace. Nil means "use config-glob behavior" (the App's // sidebar reverts to its existing name-keyed buckets). @@ -310,6 +316,7 @@ type ( ExternalUsers map[string]bool UserID string CustomEmoji map[string]string + SlashCommands []slashpicker.Command // SectionsProvider supplies Slack-native sidebar sections for this // workspace. Nil means "use config-glob behavior" (the App's // sidebar reverts to its existing name-keyed buckets). @@ -349,7 +356,7 @@ type ( UserID string WorkspaceID string } - TypingExpiredMsg struct{} + TypingExpiredMsg struct{} PresenceChangeMsg struct { UserID string Presence string @@ -480,6 +487,8 @@ type OlderMessagesFetchFunc func(channelID, oldestTS string) tea.Msg // MessageSendFunc is called when the user sends a message. Returns a tea.Msg with the result. type MessageSendFunc func(channelID, text string) tea.Msg +type SlashCommandFunc func(channelID, text string) tea.Msg + // MessageSentMsg is returned after a message is successfully sent. // LocalTS, if non-empty, identifies the optimistic placeholder added // when the user pressed Enter. The handler uses it to swap the @@ -694,9 +703,9 @@ type ChannelJoinFailedMsg struct { // clipboard contents. Production code uses the real clipboard.Read. type clipboardReader func(format clipboard.Format) []byte -// defaultClipboardReader is the real clipboard read function. It's +// defaultClipboardReader is the platform clipboard reader. It's // overridable per-App via SetClipboardReader for tests. -var defaultClipboardReader clipboardReader = clipboard.Read +var defaultClipboardReader clipboardReader = platformClipboardReader() type App struct { // Sub-models @@ -725,10 +734,10 @@ type App struct { keys KeyMap // Cached layout widths for mouse hit-testing - layoutRailWidth int - layoutSidebarEnd int // railWidth + sidebarWidth + sidebarBorder - layoutMsgEnd int // layoutSidebarEnd + msgWidth + msgBorder - layoutThreadEnd int // layoutMsgEnd + threadWidth + threadBorder + layoutRailWidth int + layoutSidebarEnd int // railWidth + sidebarWidth + sidebarBorder + layoutMsgEnd int // layoutSidebarEnd + msgWidth + msgBorder + layoutThreadEnd int // layoutMsgEnd + threadWidth + threadBorder // Cached pane content heights, used for page-up/down distance calculations. layoutMsgHeight int layoutSidebarHeight int @@ -750,6 +759,7 @@ type App struct { // Current context activeChannelID string activeTeamID string // workspace whose data is currently loaded into the side panels + pendingTopKey bool // bootstrapActiveClaimed flips on the first WorkspaceReadyMsg whose // InitialActive=true is observed. Subsequent InitialActive=true @@ -774,11 +784,12 @@ type App struct { // always). channelSyncedAtReader func(channelID string) int64 olderMessagesFetcher OlderMessagesFetchFunc - messageSender MessageSendFunc - messageEditor MessageEditFunc - messageDeleter MessageDeleteFunc - messageMarkUnreader MarkUnreadFunc - uploader UploadFunc + messageSender MessageSendFunc + slashCommandRunner SlashCommandFunc + messageEditor MessageEditFunc + messageDeleter MessageDeleteFunc + messageMarkUnreader MarkUnreadFunc + uploader UploadFunc // clipboardAvailable is set at startup based on the result of // clipboard.Init(). When false, Ctrl+V smart-paste is a no-op. @@ -788,18 +799,18 @@ type App struct { // clipboard contents. Tests inject fakes via SetClipboardReader. clipboardRead clipboardReader - threadFetcher ThreadFetchFunc - threadCacheReader ThreadCacheReadFunc - threadMarker ThreadMarkFunc - threadReplySender ThreadReplySendFunc - channelJoiner JoinChannelFunc - threadsListFetcher ThreadsListFetchFunc + threadFetcher ThreadFetchFunc + threadCacheReader ThreadCacheReadFunc + threadMarker ThreadMarkFunc + threadReplySender ThreadReplySendFunc + channelJoiner JoinChannelFunc + threadsListFetcher ThreadsListFetchFunc // channelLastReadFetcher returns the parent channel's last_read_ts // so the thread panel can render a "── new ──" boundary. Optional — // when nil, the thread panel renders without an unread boundary. channelLastReadFetcher func(channelID string) string - threadsDirtyDebounce time.Duration - fetchingOlder bool + threadsDirtyDebounce time.Duration + fetchingOlder bool // Cached user-id -> display-name map (mirror of what SetUserNames // last received). Used by openSelectedThreadCmd to populate the @@ -1007,36 +1018,36 @@ func previewSpinnerTickCmd() tea.Cmd { func NewApp() *App { app := &App{ - workspaceRail: workspace.New(nil, 0), - sidebar: sidebar.New(nil), - messagepane: messages.New(nil, ""), - compose: compose.New(""), - statusbar: statusbar.New(), - channelFinder: channelfinder.New(), - workspaceFinder: workspacefinder.New(), - themeSwitcher: themeswitcher.New(), - presenceMenu: presencemenu.New(), - help: help.New(), - threadPanel: thread.New(), - threadCompose: compose.New("thread"), - threadsView: threadsview.New(nil, ""), - reactionPicker: reactionpicker.New(), - confirmPrompt: confirmprompt.New(), - mode: ModeNormal, - focusedPanel: PanelSidebar, - sidebarVisible: true, - view: ViewChannels, - keys: DefaultKeyMap(), - typingUsers: make(map[string]map[string]time.Time), - selfSentTSes: make(map[string]time.Time), + workspaceRail: workspace.New(nil, 0), + sidebar: sidebar.New(nil), + messagepane: messages.New(nil, ""), + compose: compose.New(""), + statusbar: statusbar.New(), + channelFinder: channelfinder.New(), + workspaceFinder: workspacefinder.New(), + themeSwitcher: themeswitcher.New(), + presenceMenu: presencemenu.New(), + help: help.New(), + threadPanel: thread.New(), + threadCompose: compose.New("thread"), + threadsView: threadsview.New(nil, ""), + reactionPicker: reactionpicker.New(), + confirmPrompt: confirmprompt.New(), + mode: ModeNormal, + focusedPanel: PanelSidebar, + sidebarVisible: true, + view: ViewChannels, + keys: DefaultKeyMap(), + typingUsers: make(map[string]map[string]time.Time), + selfSentTSes: make(map[string]time.Time), lastSelfSendByChannel: make(map[string]time.Time), - threadsDirtyDebounce: 150 * time.Millisecond, - userNames: map[string]string{}, - externalUsers: map[string]bool{}, - statusByTeam: map[string]workspaceStatus{}, - lastChannelByTeam: map[string]string{}, - navHistory: make(map[string]*navStack), - clipboardRead: defaultClipboardReader, + threadsDirtyDebounce: 150 * time.Millisecond, + userNames: map[string]string{}, + externalUsers: map[string]bool{}, + statusByTeam: map[string]workspaceStatus{}, + lastChannelByTeam: map[string]string{}, + navHistory: make(map[string]*navStack), + clipboardRead: defaultClipboardReader, } // Seed the picker with built-in emojis so the autocomplete works even // before the first workspace finishes loading customs. @@ -1933,6 +1944,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + case SlashCommandMsg: + if a.slashCommandRunner != nil { + runner := a.slashCommandRunner + chID, text := msg.ChannelID, msg.Text + cmds = append(cmds, func() tea.Msg { return runner(chID, text) }) + } + case SendMessageMsg: // Mark in-flight regardless of whether a sender is wired — // the user's send intent is what controls WS-echo suppression @@ -2407,6 +2425,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.SetExternalUsers(msg.ExternalUsers) a.SetUserNames(msg.UserNames) a.SetCustomEmoji(msg.CustomEmoji) + a.SetSlashCommands(msg.SlashCommands) a.currentUserID = msg.UserID a.activeTeamID = msg.TeamID if st, ok := a.statusByTeam[a.activeTeamID]; ok { @@ -2879,6 +2898,24 @@ func (a *App) dropStaleStackEntries(stack *navStack, stale []int) { } func (a *App) handleNormalMode(msg tea.KeyMsg) tea.Cmd { + if key.Matches(msg, a.keys.Top) { + if a.pendingTopKey { + a.pendingTopKey = false + if cmd := a.handleGoToTop(); cmd != nil { + return cmd + } + } else { + a.pendingTopKey = true + } + return nil + } + if msg.String() == ":" || (msg.Key().Code == ':' && msg.Key().Text == ":") { + a.SetMode(ModeCommand) + return nil + } + if a.pendingTopKey { + a.pendingTopKey = false + } // Reaction-nav sub-state (intercept before normal keys) if a.focusedPanel == PanelMessages && a.messagepane.ReactionNavActive() { return a.handleReactionNav(msg) @@ -3090,6 +3127,10 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { a.threadCompose.CloseChannel() return nil } + if a.threadCompose.IsSlashActive() { + a.threadCompose.CloseSlash() + return nil + } } else { if a.compose.IsEmojiActive() { a.compose.CloseEmoji() @@ -3103,6 +3144,10 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { a.compose.CloseChannel() return nil } + if a.compose.IsSlashActive() { + a.compose.CloseSlash() + return nil + } } a.cancelEdit() return nil @@ -3122,6 +3167,10 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { a.threadCompose.CloseChannel() return nil } + if a.threadCompose.IsSlashActive() { + a.threadCompose.CloseSlash() + return nil + } } else { if a.compose.IsEmojiActive() { a.compose.CloseEmoji() @@ -3135,6 +3184,10 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { a.compose.CloseChannel() return nil } + if a.compose.IsSlashActive() { + a.compose.CloseSlash() + return nil + } } a.SetMode(ModeNormal) a.compose.Blur() @@ -3144,8 +3197,7 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { code := msg.Key().Code mod := msg.Key().Mod - isPaste := code == 'v' && mod == tea.ModCtrl - if isPaste { + if isCtrlV(msg) { return a.smartPaste() } @@ -3165,7 +3217,7 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { // let it own Up/Down so users can navigate the suggestion list. Without // this guard, the jump-to-start/end shortcuts below swallow the arrow // keys before the picker ever sees them. - pickerActive := target.IsEmojiActive() || target.IsMentionActive() || target.IsChannelActive() + pickerActive := target.IsEmojiActive() || target.IsMentionActive() || target.IsChannelActive() || target.IsSlashActive() if !pickerActive { if code == tea.KeyUp && mod == 0 && target.CursorAtFirstLine() { target.MoveCursorToStart() @@ -3185,7 +3237,7 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { // Determine which compose box is active based on focused panel if a.focusedPanel == PanelThread && a.threadVisible { // If a picker is active, forward all keys to compose (including Enter). - if a.threadCompose.IsEmojiActive() || a.threadCompose.IsMentionActive() || a.threadCompose.IsChannelActive() { + if a.threadCompose.IsEmojiActive() || a.threadCompose.IsMentionActive() || a.threadCompose.IsChannelActive() || a.threadCompose.IsSlashActive() { var cmd tea.Cmd a.threadCompose, cmd = a.threadCompose.Update(msg) return cmd @@ -3233,7 +3285,7 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { // Channel message compose // If a picker is active, forward all keys to compose (including Enter). - if a.compose.IsEmojiActive() || a.compose.IsMentionActive() || a.compose.IsChannelActive() { + if a.compose.IsEmojiActive() || a.compose.IsMentionActive() || a.compose.IsChannelActive() || a.compose.IsSlashActive() { var cmd tea.Cmd a.compose, cmd = a.compose.Update(msg) return cmd @@ -3260,6 +3312,14 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { text = a.compose.TranslateMentionsForSend(text) a.compose.Reset() a.exitInsertAfterSend() + if isSlashCommandText(text) { + return func() tea.Msg { + return SlashCommandMsg{ + ChannelID: a.activeChannelID, + Text: text, + } + } + } return func() tea.Msg { return SendMessageMsg{ ChannelID: a.activeChannelID, @@ -3887,6 +3947,37 @@ func (a *App) handleUp() tea.Cmd { return nil } +func (a *App) handleGoToTop() tea.Cmd { + switch a.focusedPanel { + case PanelSidebar: + a.sidebar.GoToTop() + case PanelMessages: + if a.view == ViewThreads { + a.threadsView.GoToTop() + return a.openSelectedThreadCmd(false) + } + a.messagepane.GoToTop() + if a.messagepane.AtTop() && !a.fetchingOlder && a.olderMessagesFetcher != nil { + a.fetchingOlder = true + a.messagepane.SetLoading(true) + chID := a.activeChannelID + oldestTS := a.messagepane.OldestTS() + fetcher := a.olderMessagesFetcher + return tea.Batch( + tea.Tick(100*time.Millisecond, func(time.Time) tea.Msg { + return SpinnerTickMsg{} + }), + func() tea.Msg { + return fetcher(chID, oldestTS) + }, + ) + } + case PanelThread: + a.threadPanel.GoToTop() + } + return nil +} + func (a *App) handleGoToBottom() tea.Cmd { switch a.focusedPanel { case PanelSidebar: @@ -4515,6 +4606,10 @@ func (a *App) SetMessageSender(fn MessageSendFunc) { a.messageSender = fn } +func (a *App) SetSlashCommandRunner(fn SlashCommandFunc) { + a.slashCommandRunner = fn +} + // SetMessageEditor wires the chat.update callback used by edit submit. func (a *App) SetMessageEditor(fn MessageEditFunc) { a.messageEditor = fn @@ -4922,6 +5017,11 @@ func (a *App) SetCustomEmoji(customs map[string]string) { } } +func (a *App) SetSlashCommands(commands []slashpicker.Command) { + a.compose.SetSlashCommands(commands) + a.threadCompose.SetSlashCommands(commands) +} + // SetInitialChannel sets the active channel and its messages before the TUI starts. func (a *App) SetInitialChannel(channelID, channelName string, msgs []messages.MessageItem) { a.activeChannelID = channelID @@ -5249,6 +5349,13 @@ func (a *App) typingIndicatorText(names []string) string { } } +func withKeyboardEnhancements(v tea.View) tea.View { + v.KeyboardEnhancements.ReportAlternateKeys = true + v.KeyboardEnhancements.ReportAllKeysAsEscapeCodes = true + v.KeyboardEnhancements.ReportAssociatedText = true + return v +} + func (a *App) View() tea.View { // Before the terminal reports its size, we can't lay out the // real three-panel UI. Render the loading overlay (or a minimal @@ -5268,7 +5375,7 @@ func (a *App) View() tea.View { } v := tea.NewView(screen) v.AltScreen = true - return v + return withKeyboardEnhancements(v) } statusHeight := 1 @@ -5472,6 +5579,8 @@ func (a *App) View() tea.View { composeView = mentionView + "\n" + composeView } else if channelView := a.compose.ChannelPickerView(msgWidth - 2); channelView != "" { composeView = channelView + "\n" + composeView + } else if slashView := a.compose.SlashPickerView(msgWidth - 2); slashView != "" { + composeView = slashView + "\n" + composeView } // Add a background-colored spacer line above the compose box // (replaces MarginTop which produced unstyled/black margin cells) @@ -5567,6 +5676,8 @@ func (a *App) View() tea.View { threadComposeView = mentionView + "\n" + threadComposeView } else if channelView := a.threadCompose.ChannelPickerView(threadWidth - 2); channelView != "" { threadComposeView = channelView + "\n" + threadComposeView + } else if slashView := a.threadCompose.SlashPickerView(threadWidth - 2); slashView != "" { + threadComposeView = slashView + "\n" + threadComposeView } threadComposeSpacer := lipgloss.NewStyle().Background(styles.Background).Width(threadWidth - 2).Render("") threadComposeView = threadComposeSpacer + "\n" + threadComposeView @@ -5711,7 +5822,7 @@ func (a *App) View() tea.View { v := tea.NewView(finalScreen) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion - return v + return withKeyboardEnhancements(v) } // cancelEdit exits edit mode, restoring the stashed draft to its @@ -6201,6 +6312,19 @@ func resolveFilePath(text string) (string, bool) { return filepath.Clean(s), true } +func isSlashCommandText(text string) bool { + trimmed := strings.TrimSpace(text) + return strings.HasPrefix(trimmed, "/") && len(trimmed) > 1 +} + +func isCtrlV(msg tea.KeyMsg) bool { + keyEvent := msg.Key() + if (keyEvent.Code == 'v' || keyEvent.Code == 'V' || keyEvent.BaseCode == 'v' || keyEvent.BaseCode == 'V') && keyEvent.Mod.Contains(tea.ModCtrl) { + return true + } + return msg.String() == "ctrl+v" || keyEvent.Keystroke() == "ctrl+v" +} + // uploadToastCmd builds a tea.Cmd that sets the status bar to the // given message and schedules a CopiedClearMsg after dur. func (a *App) uploadToastCmd(text string, dur time.Duration) tea.Cmd { diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 8037165..7dd470b 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -14,7 +14,6 @@ import ( "time" tea "charm.land/bubbletea/v2" - "golang.design/x/clipboard" "github.com/gammons/slk/internal/cache" imgpkg "github.com/gammons/slk/internal/image" "github.com/gammons/slk/internal/ui/compose" @@ -22,6 +21,7 @@ import ( "github.com/gammons/slk/internal/ui/sidebar" "github.com/gammons/slk/internal/ui/statusbar" "github.com/gammons/slk/internal/ui/styles" + "golang.design/x/clipboard" ) func TestAppFocusCycle(t *testing.T) { @@ -73,6 +73,54 @@ func TestAppToggleSidebar(t *testing.T) { } } +func TestHandleNormalMode_ColonEntersCommandMode(t *testing.T) { + app := NewApp() + + app.handleNormalMode(tea.KeyPressMsg{Code: ':', Text: ":"}) + + if app.mode != ModeCommand { + t.Fatalf("expected command mode, got %v", app.mode) + } +} + +func TestHandleNormalMode_GGJumpsToTop(t *testing.T) { + app := NewApp() + app.focusedPanel = PanelMessages + app.messagepane.SetMessages([]messages.MessageItem{ + {TS: "1", Text: "one"}, + {TS: "2", Text: "two"}, + {TS: "3", Text: "three"}, + }) + if app.messagepane.SelectedIndex() != 2 { + t.Fatalf("test setup: expected selection at bottom, got %d", app.messagepane.SelectedIndex()) + } + + app.handleNormalMode(tea.KeyPressMsg{Code: 'g', Text: "g"}) + if app.messagepane.SelectedIndex() != 2 { + t.Fatalf("single g should wait for second g, selected=%d", app.messagepane.SelectedIndex()) + } + app.handleNormalMode(tea.KeyPressMsg{Code: 'g', Text: "g"}) + + if app.messagepane.SelectedIndex() != 0 { + t.Fatalf("expected gg to jump to first message, got %d", app.messagepane.SelectedIndex()) + } +} + +func TestAppViewRequestsKeyboardDisambiguation(t *testing.T) { + app := NewApp() + view := app.View() + + if !view.KeyboardEnhancements.ReportAlternateKeys { + t.Fatal("expected alternate key reporting for shifted printable keys") + } + if !view.KeyboardEnhancements.ReportAllKeysAsEscapeCodes { + t.Fatal("expected all keys as escape codes so tmux can pass modified keys") + } + if !view.KeyboardEnhancements.ReportAssociatedText { + t.Fatal("expected associated text for normal text input while all keys are escape codes") + } +} + func TestTypingStateAddAndExpire(t *testing.T) { app := NewApp() app.activeChannelID = "C1" @@ -302,6 +350,32 @@ func TestHandleInsertMode_PlainEnterSends(t *testing.T) { } } +func TestHandleInsertMode_PlainEnterRunsSlashCommand(t *testing.T) { + app := NewApp() + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + app.compose.SetValue("/invite @alice") + + cmd := app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("plain Enter with slash command should return a command cmd") + } + msg, ok := cmd().(SlashCommandMsg) + if !ok { + t.Fatalf("expected SlashCommandMsg, got %T", cmd()) + } + if msg.ChannelID != "C1" || msg.Text != "/invite @alice" { + t.Fatalf("unexpected slash command msg: %+v", msg) + } + if app.compose.Value() != "" { + t.Fatalf("expected compose to be reset after slash command, got %q", app.compose.Value()) + } + if app.mode != ModeNormal { + t.Fatalf("after slash command, mode = %v, want ModeNormal", app.mode) + } +} + // TestHandleInsertMode_PlainEnterReturnsToNormalMode locks in the // vim-style UX: hitting Enter to submit a channel message drops the // user back to ModeNormal instead of leaving them in insert mode. @@ -1951,6 +2025,33 @@ func TestSmartPaste_ImagePresent_AttachesToCompose(t *testing.T) { } } +func TestInsertModeCtrlVKey_AttachesClipboardImage(t *testing.T) { + app := NewApp() + app.SetClipboardAvailable(true) + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + pngBytes := []byte("\x89PNG\r\n\x1a\nfake") + app.SetClipboardReader(fakeClipboard(pngBytes, nil)) + + _, _ = app.Update(tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl}) + + atts := app.compose.Attachments() + if len(atts) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(atts)) + } + if string(atts[0].Bytes) != string(pngBytes) { + t.Errorf("attachment bytes did not come from clipboard image") + } +} + +func TestIsCtrlVMatchesBaseCode(t *testing.T) { + msg := tea.KeyPressMsg{Code: 'ㅍ', BaseCode: 'v', Mod: tea.ModCtrl} + if !isCtrlV(msg) { + t.Fatal("expected Ctrl+V to match by BaseCode") + } +} + func TestSmartPaste_ImageTooLarge_Refuses(t *testing.T) { app := NewApp() app.SetClipboardAvailable(true) diff --git a/internal/ui/clipboard_darwin.go b/internal/ui/clipboard_darwin.go new file mode 100644 index 0000000..0b6575d --- /dev/null +++ b/internal/ui/clipboard_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin && !cgo + +package ui + +import "golang.design/x/clipboard" + +func platformClipboardReader() clipboardReader { + return clipboard.Read +} diff --git a/internal/ui/clipboard_darwin_cgo.go b/internal/ui/clipboard_darwin_cgo.go new file mode 100644 index 0000000..a7a8327 --- /dev/null +++ b/internal/ui/clipboard_darwin_cgo.go @@ -0,0 +1,91 @@ +//go:build darwin && cgo + +package ui + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa +#import +#import +#include + +static unsigned int slk_copy_nsdata(NSData *data, void **out) { + if (data == nil || [data length] == 0) { + *out = NULL; + return 0; + } + NSUInteger size = [data length]; + void *buf = malloc(size); + if (buf == NULL) { + *out = NULL; + return 0; + } + [data getBytes:buf length:size]; + *out = buf; + return (unsigned int)size; +} + +unsigned int slk_clipboard_read_image_png(void **out) { + @autoreleasepool { + *out = NULL; + NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; + + NSData *png = [pasteboard dataForType:NSPasteboardTypePNG]; + if (png != nil) { + return slk_copy_nsdata(png, out); + } + + NSData *tiff = [pasteboard dataForType:NSPasteboardTypeTIFF]; + if (tiff != nil) { + NSBitmapImageRep *rep = [NSBitmapImageRep imageRepWithData:tiff]; + NSData *converted = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + if (converted != nil) { + return slk_copy_nsdata(converted, out); + } + } + + NSImage *image = [[NSImage alloc] initWithPasteboard:pasteboard]; + if (image == nil) { + return 0; + } + CGImageRef cgImage = [image CGImageForProposedRect:NULL context:nil hints:nil]; + if (cgImage == NULL) { + [image release]; + return 0; + } + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage]; + NSData *converted = [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + unsigned int n = slk_copy_nsdata(converted, out); + [rep release]; + [image release]; + return n; + } +} +*/ +import "C" + +import ( + "unsafe" + + "golang.design/x/clipboard" +) + +func platformClipboardReader() clipboardReader { + return func(format clipboard.Format) []byte { + b := clipboard.Read(format) + if format != clipboard.FmtImage || len(b) > 0 { + return b + } + return readDarwinClipboardImagePNG() + } +} + +func readDarwinClipboardImagePNG() []byte { + var data unsafe.Pointer + n := C.slk_clipboard_read_image_png(&data) + if data == nil || n == 0 { + return nil + } + defer C.free(data) + return C.GoBytes(data, C.int(n)) +} diff --git a/internal/ui/clipboard_default.go b/internal/ui/clipboard_default.go new file mode 100644 index 0000000..4cb80f2 --- /dev/null +++ b/internal/ui/clipboard_default.go @@ -0,0 +1,9 @@ +//go:build !darwin + +package ui + +import "golang.design/x/clipboard" + +func platformClipboardReader() clipboardReader { + return clipboard.Read +} diff --git a/internal/ui/compose/model.go b/internal/ui/compose/model.go index 27a92f5..b79ff05 100644 --- a/internal/ui/compose/model.go +++ b/internal/ui/compose/model.go @@ -14,6 +14,7 @@ import ( "github.com/gammons/slk/internal/ui/channelpicker" "github.com/gammons/slk/internal/ui/emojipicker" "github.com/gammons/slk/internal/ui/mentionpicker" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/styles" ) @@ -74,6 +75,14 @@ type Model struct { emojiActive bool emojiStartCol int + // Slash-command picker state. slashStartCol is the byte offset of the + // first character after '/' within input.Value(); the trigger '/' sits + // at slashStartCol-1. + slashPicker slashpicker.Model + slashActive bool + slashStartCol int + commands []slashpicker.Command + // placeholderOverride, when non-empty, replaces the default // "Message #channel..." placeholder. Used by edit mode to display // "Editing message — Enter to save, Esc to cancel". @@ -316,6 +325,8 @@ func (m *Model) Reset() { m.channelPicker.Close() m.emojiActive = false m.emojiPicker.Close() + m.slashActive = false + m.slashPicker.Close() m.pending = nil m.uploading = false m.dirty() @@ -432,6 +443,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m2.dirty() return m2, cmd } + if m.slashActive && isKey { + m2, cmd := m.handleSlashKey(keyMsg) + m2.dirty() + return m2, cmd + } // Normal textarea update var cmd tea.Cmd @@ -469,6 +485,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } } } + if isKey && keyMsg.Key().Text == "/" { + val := m.input.Value() + cursorAbsPos := m.cursorPosition() + slashPos := cursorAbsPos - 1 + if slashPos >= 0 && slashPos < len(val) && val[slashPos] == '/' { + if slashPos == 0 || val[slashPos-1] == ' ' || val[slashPos-1] == '\n' { + m.slashActive = true + m.slashStartCol = cursorAbsPos + m.slashPicker.Open() + } + } + } // Emoji trigger: ':' at word boundary, plus 2 query chars before the // popup opens. We re-check on every keystroke (cheap) so the popup @@ -684,6 +712,55 @@ func (m Model) handleChannelKey(msg tea.KeyMsg) (Model, tea.Cmd) { } } +func (m Model) handleSlashKey(msg tea.KeyMsg) (Model, tea.Cmd) { + k := msg.Key() + switch { + case k.Code == tea.KeyUp || (k.Code == 'p' && k.Mod == tea.ModCtrl): + m.slashPicker.MoveUp() + return m, nil + case k.Code == tea.KeyDown || (k.Code == 'n' && k.Mod == tea.ModCtrl): + m.slashPicker.MoveDown() + return m, nil + case k.Code == tea.KeyEnter || k.Code == tea.KeyTab: + result := m.slashPicker.Select() + if result != nil { + m.insertSlash(result) + } + m.slashActive = false + m.slashPicker.Close() + return m, nil + case k.Code == tea.KeyEscape: + m.slashActive = false + m.slashPicker.Close() + return m, nil + case k.Code == tea.KeyBackspace: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + pos := m.cursorPosition() + if pos < m.slashStartCol { + m.slashActive = false + m.slashPicker.Close() + } else { + m.updateSlashQuery() + } + m.autoGrow() + return m, cmd + case len(k.Text) > 0: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.updateSlashQuery() + m.autoGrow() + return m, cmd + default: + m.slashActive = false + m.slashPicker.Close() + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.autoGrow() + return m, cmd + } +} + // updateChannelQuery extracts the text between the # trigger and the // cursor and updates the channel picker's filter query. func (m *Model) updateChannelQuery() { @@ -946,6 +1023,20 @@ func (m *Model) SetEmojiEntries(entries []emoji.EmojiEntry) { m.dirty() } +func (m *Model) SetSlashCommands(commands []slashpicker.Command) { + m.commands = commands + m.slashPicker.SetCommands(commands) + m.dirty() +} + +func (m Model) IsSlashActive() bool { return m.slashActive } + +func (m *Model) CloseSlash() { + m.slashActive = false + m.slashPicker.Close() + m.dirty() +} + // IsEmojiActive returns whether the emoji picker is currently showing. func (m Model) IsEmojiActive() bool { return m.emojiActive } @@ -964,6 +1055,13 @@ func (m Model) EmojiPickerView(width int) string { return m.emojiPicker.View(width) } +func (m Model) SlashPickerView(width int) string { + if !m.slashActive { + return "" + } + return m.slashPicker.View(width) +} + // emojiQueryChar reports whether r is a valid character inside an emoji // shortcode query (the run of chars after ':' the user is currently typing). // Mirrors the character set kyokomi recognizes in shortcodes. @@ -1036,6 +1134,51 @@ func (m *Model) maybeOpenEmojiPicker() { } } +func slashQueryChar(r byte) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + case r == '_' || r == '-': + return true + } + return false +} + +func (m *Model) updateSlashQuery() { + val := m.input.Value() + pos := m.cursorPosition() + if pos > len(val) { + pos = len(val) + } + if m.slashStartCol > pos { + m.slashActive = false + m.slashPicker.Close() + return + } + query := val[m.slashStartCol:pos] + m.slashPicker.SetQuery(query) +} + +func (m *Model) insertSlash(result *slashpicker.Result) { + val := m.input.Value() + pos := m.cursorPosition() + slashPos := m.slashStartCol - 1 + if slashPos < 0 { + slashPos = 0 + } + before := val[:slashPos] + after := "" + if pos < len(val) { + after = val[pos:] + } + newText := before + slashpicker.FormatCommandInsert(result.Name) + after + m.input.SetValue(newText) +} + // handleEmojiKey processes key events when the emoji picker is active. // Mirrors handleMentionKey. func (m Model) handleEmojiKey(msg tea.KeyMsg) (Model, tea.Cmd) { diff --git a/internal/ui/compose/model_test.go b/internal/ui/compose/model_test.go index a1e5fcd..79fd1b0 100644 --- a/internal/ui/compose/model_test.go +++ b/internal/ui/compose/model_test.go @@ -9,6 +9,7 @@ import ( "github.com/gammons/slk/internal/config" "github.com/gammons/slk/internal/emoji" "github.com/gammons/slk/internal/ui/mentionpicker" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/styles" ) @@ -392,6 +393,55 @@ func TestCloseMention(t *testing.T) { } } +func TestSlashTrigger_OpensAtWordBoundary(t *testing.T) { + m := New("general") + m.SetSlashCommands([]slashpicker.Command{{Name: "/invite"}, {Name: "/topic"}}) + m.SetWidth(80) + m.Focus() + + m, _ = m.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + if !m.IsSlashActive() { + t.Fatal("expected slash picker to open on /") + } + if got := m.SlashPickerView(80); got == "" { + t.Fatal("expected slash picker view when active") + } +} + +func TestSlashTrigger_SelectInsertsCommand(t *testing.T) { + m := New("general") + m.SetSlashCommands([]slashpicker.Command{{Name: "/invite"}, {Name: "/topic"}}) + m.SetWidth(80) + m.Focus() + + m, _ = m.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + m, _ = m.Update(tea.KeyPressMsg{Code: 'i', Text: "i"}) + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if m.IsSlashActive() { + t.Fatal("expected slash picker to close after selection") + } + if got := m.Value(); got != "/invite " { + t.Fatalf("expected slash command inserted, got %q", got) + } +} + +func TestSlashTrigger_BackspaceClosesWhenTriggerDeleted(t *testing.T) { + m := New("general") + m.SetSlashCommands([]slashpicker.Command{{Name: "/invite"}}) + m.SetWidth(80) + m.Focus() + + m, _ = m.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + if !m.IsSlashActive() { + t.Fatal("expected slash picker to open") + } + m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyBackspace}) + if m.IsSlashActive() { + t.Fatal("expected slash picker to close after deleting /") + } +} + func sampleEmojiEntries() []emoji.EmojiEntry { return []emoji.EmojiEntry{ {Name: "rock", Display: "🪨"}, diff --git a/internal/ui/slashpicker/model.go b/internal/ui/slashpicker/model.go new file mode 100644 index 0000000..fdab887 --- /dev/null +++ b/internal/ui/slashpicker/model.go @@ -0,0 +1,144 @@ +package slashpicker + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/gammons/slk/internal/text" + "github.com/gammons/slk/internal/ui/styles" +) + +const MaxVisible = 7 + +type Command struct { + Name string + Description string + UsageHint string +} + +type Result struct { + Name string +} + +type Model struct { + commands []Command + filtered []Command + query string + selected int + visible bool +} + +func New() Model { + return Model{} +} + +func (m *Model) SetCommands(commands []Command) { + m.commands = commands + if m.visible { + m.filter() + } +} + +func (m *Model) Open() { + m.visible = true + m.query = "" + m.selected = 0 + m.filter() +} + +func (m *Model) Close() { + m.visible = false + m.query = "" + m.selected = 0 + m.filtered = nil +} + +func (m *Model) IsVisible() bool { return m.visible } + +func (m *Model) SetQuery(q string) { + m.query = q + m.selected = 0 + m.filter() +} + +func (m *Model) Query() string { return m.query } + +func (m *Model) Filtered() []Command { return m.filtered } + +func (m *Model) Selected() int { return m.selected } + +func (m *Model) MoveUp() { + if m.selected > 0 { + m.selected-- + } +} + +func (m *Model) MoveDown() { + if m.selected < len(m.filtered)-1 { + m.selected++ + } +} + +func (m *Model) Select() *Result { + if len(m.filtered) == 0 || m.selected < 0 || m.selected >= len(m.filtered) { + return nil + } + return &Result{Name: m.filtered[m.selected].Name} +} + +func (m *Model) filter() { + q := text.Fold(m.query) + results := make([]Command, 0, len(m.commands)) + for _, c := range m.commands { + name := strings.TrimPrefix(c.Name, "/") + if q == "" || strings.HasPrefix(text.Fold(name), q) || strings.HasPrefix(text.Fold(c.Name), q) { + results = append(results, c) + } + } + if len(results) > MaxVisible { + results = results[:MaxVisible] + } + m.filtered = results +} + +func (m *Model) View(width int) string { + if !m.visible || len(m.filtered) == 0 { + return "" + } + + var rows []string + for i, c := range m.filtered { + indicator := " " + nameStyle := lipgloss.NewStyle().Foreground(styles.TextPrimary) + metaStyle := lipgloss.NewStyle().Foreground(styles.TextMuted) + if i == m.selected { + indicator = lipgloss.NewStyle().Foreground(styles.Accent).Render("▌ ") + nameStyle = nameStyle.Bold(true) + } + + label := indicator + nameStyle.Render(c.Name) + if c.UsageHint != "" { + label += metaStyle.Render(" " + c.UsageHint) + } + rows = append(rows, label) + if c.Description != "" { + rows = append(rows, " "+metaStyle.Render(c.Description)) + } + } + + content := strings.Join(rows, "\n") + return lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(styles.Primary). + Background(styles.SurfaceDark). + Width(width - 2). + Render(content) +} + +func FormatCommandInsert(name string) string { + if strings.HasPrefix(name, "/") { + return fmt.Sprintf("%s ", name) + } + return fmt.Sprintf("/%s ", name) +} From f4e36e41b7a5636163485f3c8884482a42ae6bda Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Sat, 23 May 2026 03:17:47 +0900 Subject: [PATCH 2/2] feat: refine slash command execution and suggestions Improve slash command metadata parsing, support slash command execution from thread compose, and learn successfully executed slash commands into the picker list. --- cmd/slk/main.go | 36 ++++++++++-- internal/slack/client.go | 93 ++++++++++++++++++++++++------- internal/slack/client_test.go | 42 ++++++++++++++ internal/ui/app.go | 56 ++++++++++++++++++- internal/ui/app_test.go | 101 ++++++++++++++++++++++++++++++++++ internal/ui/compose/model.go | 4 ++ 6 files changed, 308 insertions(+), 24 deletions(-) diff --git a/cmd/slk/main.go b/cmd/slk/main.go index 056192c..6d683ff 100644 --- a/cmd/slk/main.go +++ b/cmd/slk/main.go @@ -843,6 +843,13 @@ func run() error { if err != nil && p != nil { p.Send(ui.ToastMsg{Text: "Status change failed: " + err.Error()}) } + if p != nil { + fresh := make(map[string]string, len(wctx.UserNames)) + for id, name := range wctx.UserNames { + fresh[id] = name + } + p.Send(ui.WorkspaceUserNamesUpdatedMsg{TeamID: wctx.TeamID, UserNames: fresh}) + } }() }) @@ -1024,7 +1031,7 @@ func run() error { log.Printf("Warning: failed to execute slash command: %v", err) return ui.ToastMsg{Text: "Command failed: " + err.Error()} } - return ui.ToastMsg{Text: "Command sent"} + return ui.SlashCommandExecutedMsg{Text: text} }) app.SetMessageEditor(func(channelID, ts, text string) tea.Msg { @@ -1551,20 +1558,40 @@ func run() error { } func buildSlashPickerCommands(commands []slackclient.SlashCommand) []slashpicker.Command { - out := make([]slashpicker.Command, 0, len(commands)) + out := defaultSlashPickerCommands() + seen := make(map[string]int, len(out)+len(commands)) + for i, cmd := range out { + seen[cmd.Name] = i + } for _, cmd := range commands { if cmd.Command == "" { continue } - out = append(out, slashpicker.Command{ + candidate := slashpicker.Command{ Name: cmd.Command, Description: cmd.Description, UsageHint: cmd.UsageHint, - }) + } + if idx, ok := seen[candidate.Name]; ok { + out[idx] = mergeSlashCommand(out[idx], candidate) + continue + } + seen[candidate.Name] = len(out) + out = append(out, candidate) } return out } +func mergeSlashCommand(base, candidate slashpicker.Command) slashpicker.Command { + if candidate.Description != "" { + base.Description = candidate.Description + } + if candidate.UsageHint != "" { + base.UsageHint = candidate.UsageHint + } + return base +} + func defaultSlashPickerCommands() []slashpicker.Command { return []slashpicker.Command{ {Name: "/away", Description: "Set yourself away"}, @@ -1579,6 +1606,7 @@ func defaultSlashPickerCommands() []slashpicker.Command { {Name: "/status", Description: "Set your status", UsageHint: "text"}, {Name: "/topic", Description: "Set the channel topic", UsageHint: "text"}, {Name: "/who", Description: "See who is in the channel"}, + {Name: "/zoom", Description: "Start or share a Zoom meeting", UsageHint: "start | meeting topic"}, } } diff --git a/internal/slack/client.go b/internal/slack/client.go index d59afd7..fd5d68b 100644 --- a/internal/slack/client.go +++ b/internal/slack/client.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "sort" "strings" "sync" "time" @@ -529,6 +530,9 @@ type SlashCommand struct { Command string Description string UsageHint string + Type string + AppID string + AppName string } // GetHistorySince fetches all messages newer than `oldest` for the @@ -654,11 +658,21 @@ func (c *Client) ExecuteSlashCommand(ctx context.Context, channelID, text string return fmt.Errorf("executing slash command: invalid command") } - body, err := c.postForm(ctx, "chat.command", url.Values{ + form := url.Values{ "channel": {channelID}, "command": {command}, "text": {args}, - }) + } + if meta, err := c.lookupSlashCommand(ctx, command); err == nil { + if meta.AppID != "" { + form.Set("app", meta.AppID) + } + if meta.Type != "" { + form.Set("type", meta.Type) + } + } + + body, err := c.postForm(ctx, "chat.command", form) if err != nil { return fmt.Errorf("executing slash command: %w", err) } @@ -679,6 +693,19 @@ func (c *Client) ExecuteSlashCommand(ctx context.Context, channelID, text string return nil } +func (c *Client) lookupSlashCommand(ctx context.Context, command string) (SlashCommand, error) { + commands, err := c.ListSlashCommands(ctx) + if err != nil { + return SlashCommand{}, err + } + for _, item := range commands { + if item.Command == command { + return item, nil + } + } + return SlashCommand{}, fmt.Errorf("command not found") +} + func splitSlashCommand(text string) (command, args string, ok bool) { trimmed := strings.TrimSpace(text) if !strings.HasPrefix(trimmed, "/") || len(trimmed) == 1 { @@ -702,16 +729,10 @@ func (c *Client) ListSlashCommands(ctx context.Context) ([]SlashCommand, error) } var resp struct { - OK bool `json:"ok"` - Error string `json:"error"` - Commands []struct { - Command string `json:"command"` - Name string `json:"name"` - Description string `json:"description"` - UsageHint string `json:"usage_hint"` - ArgHint string `json:"arg_hint"` - Hint string `json:"hint"` - } `json:"commands"` + OK bool `json:"ok"` + Error string `json:"error"` + Commands map[string]json.RawMessage `json:"commands"` + CacheTS json.RawMessage `json:"cache_ts"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("parsing commands.list response: %w (body=%q)", err, truncateForLog(body)) @@ -723,28 +744,62 @@ func (c *Client) ListSlashCommands(ctx context.Context) ([]SlashCommand, error) return nil, fmt.Errorf("commands.list: %s", resp.Error) } + type commandEntry struct { + Command string `json:"command"` + Name string `json:"name"` + Description string `json:"description"` + UsageHint string `json:"usage_hint"` + ArgHint string `json:"arg_hint"` + Hint string `json:"hint"` + Desc string `json:"desc"` + Usage string `json:"usage"` + Type string `json:"type"` + AppID string `json:"app"` + AppName string `json:"app_name"` + } + commands := make([]SlashCommand, 0, len(resp.Commands)) - for _, cmd := range resp.Commands { - name := cmd.Command + for key, raw := range resp.Commands { + entry := commandEntry{} + if len(raw) > 0 && string(raw) != "null" { + if err := json.Unmarshal(raw, &entry); err != nil { + entry.Command = key + } + } + name := entry.Command if name == "" { - name = cmd.Name + name = entry.Name + } + if name == "" { + name = key } if name == "" { continue } - hint := cmd.UsageHint + hint := entry.UsageHint if hint == "" { - hint = cmd.ArgHint + hint = entry.ArgHint } if hint == "" { - hint = cmd.Hint + hint = entry.Hint + } + if hint == "" { + hint = entry.Usage + } + description := entry.Description + if description == "" { + description = entry.Desc } commands = append(commands, SlashCommand{ Command: name, - Description: cmd.Description, + Description: description, UsageHint: hint, + Type: entry.Type, + AppID: entry.AppID, + AppName: entry.AppName, }) } + sort.Slice(commands, func(i, j int) bool { return commands[i].Command < commands[j].Command }) return commands, nil } diff --git a/internal/slack/client_test.go b/internal/slack/client_test.go index 5fa9f4b..3e314a2 100644 --- a/internal/slack/client_test.go +++ b/internal/slack/client_test.go @@ -144,6 +144,10 @@ func TestExecuteSlashCommand_PostsChatCommandForm(t *testing.T) { } gotForm = r.Form w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/api/commands.list" { + _, _ = w.Write([]byte(`{"ok":true,"commands":{"/invite":{"name":"/invite","type":"app","app":"A123"}}}`)) + return + } _, _ = w.Write([]byte(`{"ok":true}`)) })) defer srv.Close() @@ -167,6 +171,12 @@ func TestExecuteSlashCommand_PostsChatCommandForm(t *testing.T) { if gotForm.Get("text") != "@alice" { t.Errorf("text = %q, want @alice", gotForm.Get("text")) } + if gotForm.Get("app") != "A123" { + t.Errorf("app = %q, want A123", gotForm.Get("app")) + } + if gotForm.Get("type") != "app" { + t.Errorf("type = %q, want app", gotForm.Get("type")) + } } func TestExecuteSlashCommand_ReturnsSlackError(t *testing.T) { @@ -182,6 +192,38 @@ func TestExecuteSlashCommand_ReturnsSlackError(t *testing.T) { } } +func TestListSlashCommands_ParsesMapResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"commands":{` + + `"/zoom":{"description":"Start a Zoom meeting","usage_hint":"meeting topic"},` + + `"/incops":{"description":"Incident ops","hint":"declare"},` + + `"/bare":null` + + `},"cache_ts":"123"}`)) + })) + defer srv.Close() + + c := &Client{token: "xoxc-test", cookie: "d-cookie", apiBaseURL: srv.URL + "/api/"} + commands, err := c.ListSlashCommands(context.Background()) + if err != nil { + t.Fatalf("ListSlashCommands: %v", err) + } + + byName := map[string]SlashCommand{} + for _, cmd := range commands { + byName[cmd.Command] = cmd + } + if byName["/zoom"].Description != "Start a Zoom meeting" { + t.Fatalf("/zoom description = %q", byName["/zoom"].Description) + } + if byName["/incops"].UsageHint != "declare" { + t.Fatalf("/incops hint = %q", byName["/incops"].UsageHint) + } + if _, ok := byName["/bare"]; !ok { + t.Fatal("expected null-valued command key to still be included") + } +} + // mockSlackAPI implements SlackAPI for testing. // Function fields allow tests to override default behavior. type mockSlackAPI struct { diff --git a/internal/ui/app.go b/internal/ui/app.go index eafa8e8..6540263 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -232,6 +232,10 @@ type ( DisplayName string IsBot bool } + WorkspaceUserNamesUpdatedMsg struct { + TeamID string + UserNames map[string]string + } // UserExternalMsg flags a single user as external (Slack Connect / // shared-channel guest). Emitted by the user-resolution path when a // users.info response shows team_id != workspace TeamID. The App @@ -374,7 +378,8 @@ type ( // ToastMsg sets a transient string in the status bar's toast slot. Used // for short error notices (e.g. failed status change). Auto-clears after // 3 seconds via a CopiedClearMsg tick scheduled by the App. - ToastMsg struct{ Text string } + ToastMsg struct{ Text string } + SlashCommandExecutedMsg struct{ Text string } ) type loadingEntry struct { @@ -2368,6 +2373,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // in-history name patch. IsBot is carried for forward // compatibility but not consumed here. + case WorkspaceUserNamesUpdatedMsg: + if msg.TeamID != a.activeTeamID { + break + } + a.SetUserNames(msg.UserNames) + return a, nil + case UserExternalMsg: if a.externalUsers == nil { a.externalUsers = map[string]bool{} @@ -2558,6 +2570,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.SetExternalUsers(msg.ExternalUsers) a.SetUserNames(msg.UserNames) a.SetCustomEmoji(msg.CustomEmoji) + a.SetSlashCommands(msg.SlashCommands) a.currentUserID = msg.UserID a.activeTeamID = msg.TeamID if st, ok := a.statusByTeam[a.activeTeamID]; ok { @@ -2700,6 +2713,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.dndTickerOn = false } + case SlashCommandExecutedMsg: + a.learnSlashCommand(msg.Text) + a.statusbar.SetToast("Command sent") + cmds = append(cmds, tea.Tick(3*time.Second, func(time.Time) tea.Msg { + return statusbar.CopiedClearMsg{} + })) + case ToastMsg: a.statusbar.SetToast(msg.Text) cmds = append(cmds, tea.Tick(3*time.Second, func(time.Time) tea.Msg { @@ -3267,6 +3287,14 @@ func (a *App) handleInsertMode(msg tea.KeyMsg) tea.Cmd { threadTS := a.threadPanel.ThreadTS() channelID := a.threadPanel.ChannelID() a.exitInsertAfterSend() + if isSlashCommandText(text) { + return func() tea.Msg { + return SlashCommandMsg{ + ChannelID: channelID, + Text: text, + } + } + } return func() tea.Msg { return SendThreadReplyMsg{ ChannelID: channelID, @@ -6317,6 +6345,32 @@ func isSlashCommandText(text string) bool { return strings.HasPrefix(trimmed, "/") && len(trimmed) > 1 } +func (a *App) learnSlashCommand(text string) { + trimmed := strings.TrimSpace(text) + if !isSlashCommandText(trimmed) { + return + } + name := trimmed + if i := strings.IndexAny(trimmed, " \t\n"); i >= 0 { + name = trimmed[:i] + } + if name == "" { + return + } + + existing := a.compose.SlashCommands() + for _, cmd := range existing { + if cmd.Name == name { + return + } + } + + updated := make([]slashpicker.Command, 0, len(existing)+1) + updated = append(updated, slashpicker.Command{Name: name}) + updated = append(updated, existing...) + a.SetSlashCommands(updated) +} + func isCtrlV(msg tea.KeyMsg) bool { keyEvent := msg.Key() if (keyEvent.Code == 'v' || keyEvent.Code == 'V' || keyEvent.BaseCode == 'v' || keyEvent.BaseCode == 'V') && keyEvent.Mod.Contains(tea.ModCtrl) { diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 7dd470b..44e1262 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -19,6 +19,7 @@ import ( "github.com/gammons/slk/internal/ui/compose" "github.com/gammons/slk/internal/ui/messages" "github.com/gammons/slk/internal/ui/sidebar" + "github.com/gammons/slk/internal/ui/slashpicker" "github.com/gammons/slk/internal/ui/statusbar" "github.com/gammons/slk/internal/ui/styles" "golang.design/x/clipboard" @@ -376,6 +377,106 @@ func TestHandleInsertMode_PlainEnterRunsSlashCommand(t *testing.T) { } } +func TestHandleInsertMode_ThreadSlashCommandRunsInChannel(t *testing.T) { + app := NewApp() + app.activeChannelID = "C1" + app.threadPanel.SetThread(messages.MessageItem{TS: "P1"}, nil, "C1", "P1") + app.threadVisible = true + app.focusedPanel = PanelThread + app.SetMode(ModeInsert) + app.threadCompose.SetValue("/workflow start") + + cmd := app.handleInsertMode(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatalf("plain Enter with thread slash command should return a command cmd") + } + msg, ok := cmd().(SlashCommandMsg) + if !ok { + t.Fatalf("expected SlashCommandMsg, got %T", cmd()) + } + if msg.ChannelID != "C1" || msg.Text != "/workflow start" { + t.Fatalf("unexpected slash command msg: %+v", msg) + } + if app.threadCompose.Value() != "" { + t.Fatalf("expected thread compose to be reset after slash command, got %q", app.threadCompose.Value()) + } + if app.mode != ModeNormal { + t.Fatalf("after thread slash command, mode = %v, want ModeNormal", app.mode) + } +} + +func TestWorkspaceReadyInitialActiveSetsSlashCommands(t *testing.T) { + app := NewApp() + msg := WorkspaceReadyMsg{ + TeamID: "T1", + TeamName: "test", + Channels: []sidebar.ChannelItem{{ID: "C1", Name: "general", Type: "channel"}}, + SlashCommands: []slashpicker.Command{{Name: "/workflow", Description: "Run a workflow"}}, + InitialActive: true, + } + + _, _ = app.Update(msg) + app.SetMode(ModeInsert) + app.focusedPanel = PanelMessages + app.compose.Focus() + app.compose, _ = app.compose.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + + if !app.compose.IsSlashActive() { + t.Fatal("expected slash picker to open") + } + view := app.compose.SlashPickerView(80) + if !strings.Contains(view, "/workflow") { + t.Fatalf("expected initial workspace slash command in picker, got %q", view) + } +} + +func TestWorkspaceUserNamesUpdatedRefreshesMentionPicker(t *testing.T) { + app := NewApp() + app.activeTeamID = "T1" + + _, _ = app.Update(WorkspaceUserNamesUpdatedMsg{ + TeamID: "T1", + UserNames: map[string]string{"U1": "alice", "U2": "bob"}, + }) + + users := app.compose.MentionUsers() + if len(users) != 2 { + t.Fatalf("expected mention picker users refreshed, got %d", len(users)) + } +} + +func TestAppLearnsExecutedCustomSlashCommand(t *testing.T) { + app := NewApp() + app.SetSlashCommands([]slashpicker.Command{{Name: "/zoom", Description: "Zoom"}}) + + _, _ = app.Update(SlashCommandExecutedMsg{Text: "/incops declare"}) + + commands := app.compose.SlashCommands() + if len(commands) == 0 || commands[0].Name != "/incops" { + t.Fatalf("expected /incops learned at front of picker, got %+v", commands) + } +} + +func TestAppViewRendersSlashPicker(t *testing.T) { + app := NewApp() + app.width = 100 + app.height = 30 + app.loading = false + app.activeChannelID = "C1" + app.focusedPanel = PanelMessages + app.SetMode(ModeInsert) + app.SetChannels([]sidebar.ChannelItem{{ID: "C1", Name: "general", Type: "channel"}}) + app.SetSlashCommands([]slashpicker.Command{{Name: "/workflow", Description: "Run a workflow"}}) + app.compose.SetChannel("general") + app.compose.Focus() + app.compose, _ = app.compose.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + + view := app.View().Content + if !strings.Contains(view, "/workflow") { + t.Fatalf("expected app view to render slash picker, got %q", view) + } +} + // TestHandleInsertMode_PlainEnterReturnsToNormalMode locks in the // vim-style UX: hitting Enter to submit a channel message drops the // user back to ModeNormal instead of leaving them in insert mode. diff --git a/internal/ui/compose/model.go b/internal/ui/compose/model.go index b79ff05..1d9ee4c 100644 --- a/internal/ui/compose/model.go +++ b/internal/ui/compose/model.go @@ -1029,6 +1029,10 @@ func (m *Model) SetSlashCommands(commands []slashpicker.Command) { m.dirty() } +func (m Model) SlashCommands() []slashpicker.Command { + return append([]slashpicker.Command(nil), m.commands...) +} + func (m Model) IsSlashActive() bool { return m.slashActive } func (m *Model) CloseSlash() {