diff --git a/cmd/slk/main.go b/cmd/slk/main.go index 5a486bc..b593bba 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" @@ -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. @@ -850,6 +852,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}) + } }() }) @@ -1020,6 +1029,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.SlashCommandExecutedMsg{Text: text} + }) + app.SetMessageEditor(func(channelID, ts, text string) tea.Msg { wctx := router.Active() if wctx == nil { @@ -1337,6 +1360,7 @@ func run() error { ExternalUsers: external, UserID: wctx.UserID, CustomEmoji: wctx.CustomEmoji, + SlashCommands: wctx.SlashCommands, SectionsProvider: sectionsProviderAdapter{store: wctx.SectionStore}, } }) @@ -1481,6 +1505,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, }) @@ -1556,6 +1581,59 @@ func run() error { return err } +func buildSlashPickerCommands(commands []slackclient.SlashCommand) []slashpicker.Command { + 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 + } + 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"}, + {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"}, + {Name: "/zoom", Description: "Start or share a Zoom meeting", UsageHint: "start | meeting topic"}, + } +} + 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 { @@ -1572,6 +1650,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 @@ -1656,6 +1735,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 1613feb..39ae1b7 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" @@ -528,6 +529,15 @@ type HistorySinceResult struct { Capped bool } +type SlashCommand struct { + Command string + Description string + UsageHint string + Type string + AppID string + AppName 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; @@ -642,6 +652,160 @@ 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") + } + + 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) + } + + 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 (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 { + 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 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)) + } + if !resp.OK { + if resp.Error == "" { + resp.Error = "unknown_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 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 = entry.Name + } + if name == "" { + name = key + } + if name == "" { + continue + } + hint := entry.UsageHint + if hint == "" { + hint = entry.ArgHint + } + if hint == "" { + hint = entry.Hint + } + if hint == "" { + hint = entry.Usage + } + description := entry.Description + if description == "" { + description = entry.Desc + } + commands = append(commands, SlashCommand{ + Command: name, + 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 +} + // 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 b8f18a5..9ed5590 100644 --- a/internal/slack/client_test.go +++ b/internal/slack/client_test.go @@ -130,6 +130,100 @@ 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") + 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() + + 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")) + } + 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) { + 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) + } +} + +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 a95d190..69525d5 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -38,6 +38,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" @@ -138,6 +139,10 @@ type ( ChannelID string Text string } + SlashCommandMsg struct { + ChannelID string + Text string + } ThreadOpenedMsg struct { ChannelID string ThreadTS string @@ -247,6 +252,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 @@ -271,6 +280,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). @@ -330,6 +340,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). @@ -387,7 +398,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 { @@ -500,6 +512,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 @@ -782,6 +796,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 @@ -807,6 +822,7 @@ type App struct { channelSyncedAtReader func(channelID string) int64 olderMessagesFetcher OlderMessagesFetchFunc messageSender MessageSendFunc + slashCommandRunner SlashCommandFunc messageEditor MessageEditFunc messageDeleter MessageDeleteFunc messageMarkUnreader MarkUnreadFunc @@ -1997,6 +2013,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, func() tea.Msg { return ActivityListDirtyMsg{TeamID: team} }) } + 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 @@ -2463,6 +2486,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{} @@ -2523,6 +2553,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 { @@ -2668,6 +2699,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 { @@ -2815,6 +2847,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 { @@ -3133,6 +3172,24 @@ var koreanIMEQWERTY = map[rune]string{ } 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) @@ -3345,6 +3402,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() @@ -3358,6 +3419,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 @@ -3377,6 +3442,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() @@ -3390,6 +3459,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() @@ -3403,8 +3476,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() } if key.Matches(msg, a.keys.AttachFile) { @@ -3441,7 +3513,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() @@ -3469,7 +3541,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 @@ -3499,6 +3571,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, @@ -3517,7 +3597,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 @@ -3544,6 +3624,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, @@ -4213,6 +4301,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: @@ -4893,6 +5012,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 @@ -5366,6 +5489,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 @@ -5964,6 +6092,8 @@ func (a *App) View() tea.View { } else if channelView := a.compose.ChannelPickerView(msgWidth - 2); channelView != "" { composeCursorYOffset = lipgloss.Height(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) @@ -6070,6 +6200,8 @@ func (a *App) View() tea.View { } else if channelView := a.threadCompose.ChannelPickerView(threadWidth - 2); channelView != "" { threadComposeCursorYOffset = lipgloss.Height(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 @@ -6756,6 +6888,45 @@ 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 (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) { + 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 058a7b4..dc66a5d 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -20,6 +20,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" @@ -74,6 +75,57 @@ 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.ReportEventTypes { + t.Fatal("expected event type reporting for enhanced key metadata") + } + if !view.KeyboardEnhancements.ReportAlternateKeys { + t.Fatal("expected alternate key reporting for shifted printable keys") + } + if view.KeyboardEnhancements.ReportAllKeysAsEscapeCodes { + t.Fatal("must not force printable keys as escape codes; IME preedit may duplicate input") + } + if view.KeyboardEnhancements.ReportAssociatedText { + t.Fatal("must not request associated text by default; some terminals duplicate printable input") + } +} + func TestTypingStateAddAndExpire(t *testing.T) { app := NewApp() app.activeChannelID = "C1" @@ -303,6 +355,132 @@ 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) + } +} + +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) + } +} + func TestHandleInsertMode_CtrlEnterSends(t *testing.T) { app := NewApp() app.activeChannelID = "C1" @@ -2306,6 +2484,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/compose/model.go b/internal/ui/compose/model.go index fa8e43e..c73ad52 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". @@ -401,6 +410,8 @@ func (m *Model) Reset() { m.channelPicker.Close() m.emojiActive = false m.emojiPicker.Close() + m.slashActive = false + m.slashPicker.Close() m.pending = nil m.selectedAttachment = -1 m.uploading = false @@ -518,6 +529,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 @@ -555,6 +571,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 @@ -770,6 +798,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() { @@ -1032,6 +1109,24 @@ 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) SlashCommands() []slashpicker.Command { + return append([]slashpicker.Command(nil), m.commands...) +} + +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 } @@ -1050,6 +1145,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. @@ -1122,6 +1224,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 fee4cf0..e565153 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) +}