Răsfoiți Sursa

feat(ui/chat): add panel navigation, channel state, read fix, reply style, picker browse mode

- ESC cycles focus: input → messages → guilds → input; left/right arrows
  navigate between guilds and messages panels
- Persist category/forum expand/collapse state in state.json
- Fix MarkRead using stale channel.LastMessageID; use newest fetched msg
- Render reply quote lines with italic style
- Add two-phase ESC to overlay pickers: first ESC enters browse mode
  (j/k/g/G navigate, i returns to input), second ESC closes picker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude 1 lună în urmă
părinte
comite
33cae89f62

+ 6 - 2
CLAUDE.md

@@ -32,7 +32,7 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - `internal/markdown/renderer.go` — AST→styled lines, handles Discord markdown flavors
 - `internal/config/` — `config.go` (struct + loader), `config.toml` (defaults, embedded), `keybinds.go`, `theme.go`, `editor.go`
 - `internal/consts/` — app name, cache dir
-- `internal/ui/chat/guildstate.go` — persists guild expand/collapse state to `~/.cache/discordo/state.json`
+- `internal/ui/chat/guildstate.go` — persists guild + channel expand/collapse state to `~/.cache/discordo/state.json`
 - Rendering pipeline: messages → `tview.LineBuilder` → `[]tview.Line` (segments with `tcell.Style`)
 - URLs get `style.Url(rawURL)` metadata for OSC 8 terminal hyperlinks
 - External commands (editor, image viewer) use `app.Suspend()` pattern — suspends TUI, runs command, resumes
@@ -69,7 +69,11 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - **Command mode**: `:` keybind opens vim-style command input, supports `:q`/`:quit`/`:logout` (`command_input.go`)
 - **LRU cache cap**: `itemByID` map evicts stale entries when exceeding 500 items (COMP #17)
 - **Replace open-golang**: replaced `skratchdot/open-golang` with stdlib `os/exec` + `runtime.GOOS` (TECH #1)
-- **ESC focus navigation**: ESC in message input → focus messages list; ESC in messages list → focus guilds tree
+- **ESC focus cycling**: ESC cycles focus: input → messages → guilds → input (full loop); left/right arrows navigate between guilds and messages
+- **Channel state persistence**: category/forum expand/collapse state saved to `state.json` alongside guild state (`ExpandedChannels` map)
+- **MarkRead fix**: uses newest fetched message ID instead of potentially stale `channel.LastMessageID`
+- **Reply quote italic**: reply lines rendered with dim + italic style
+- **Picker browse mode**: ESC in overlay pickers (channels, search, attachments) enters browse mode (j/k/g/G navigate, `i` returns to input, second ESC closes) via shared `pickerBrowseHandleKey` helper
 
 ## Config Fields We Added
 - `image_viewer` — external image viewer command (default: `"mpv"`, `"default"` = system opener)

+ 1 - 0
internal/ui/chat/attachment_handler.go

@@ -277,5 +277,6 @@ func (ml *messagesList) showAttachmentsOverlay() {
 			layers.WithOverlay(),
 		).
 		SendToFront(attachmentsListLayerName)
+	ml.attachmentsPicker.resetBrowse()
 	ml.chatView.app.SetFocus(ml.attachmentsPicker)
 }

+ 10 - 3
internal/ui/chat/attachments_picker.go

@@ -15,9 +15,10 @@ type attachmentItem struct {
 
 type attachmentsPicker struct {
 	*picker.Model
-	cfg      *config.Config
-	chatView *Model
-	items    []attachmentItem
+	cfg        *config.Config
+	chatView   *Model
+	items      []attachmentItem
+	browseMode bool
 }
 
 var _ help.KeyMap = (*attachmentsPicker)(nil)
@@ -50,8 +51,14 @@ func (ap *attachmentsPicker) close() {
 	ap.chatView.app.SetFocus(ap.chatView.messagesList)
 }
 
+func (ap *attachmentsPicker) resetBrowse() { ap.browseMode = false }
+
 func (ap *attachmentsPicker) HandleEvent(event tview.Event) tview.Command {
 	switch event := event.(type) {
+	case *tview.KeyEvent:
+		if cmd, handled := pickerBrowseHandleKey(event, &ap.browseMode, ap.Model, func() { ap.close() }); handled {
+			return cmd
+		}
 	case *picker.SelectedEvent:
 		index, ok := event.Reference.(int)
 		if !ok {

+ 9 - 2
internal/ui/chat/channels_picker.go

@@ -15,19 +15,26 @@ import (
 
 type channelsPicker struct {
 	*picker.Model
-	chatView *Model
+	chatView   *Model
+	browseMode bool
 }
 
 var _ help.KeyMap = (*channelsPicker)(nil)
 
 func newChannelsPicker(cfg *config.Config, chatView *Model) *channelsPicker {
-	cp := &channelsPicker{picker.NewModel(), chatView}
+	cp := &channelsPicker{Model: picker.NewModel(), chatView: chatView}
 	ConfigurePicker(cp.Model, cfg, "Channels")
 	return cp
 }
 
+func (cp *channelsPicker) resetBrowse() { cp.browseMode = false }
+
 func (cp *channelsPicker) HandleEvent(event tview.Event) tview.Command {
 	switch event := event.(type) {
+	case *tview.KeyEvent:
+		if cmd, handled := pickerBrowseHandleKey(event, &cp.browseMode, cp.Model, func() { cp.chatView.closePicker() }); handled {
+			return cmd
+		}
 	case *picker.SelectedEvent:
 		channelID, ok := event.Reference.(discord.ChannelID)
 		if !ok || !channelID.IsValid() {

+ 33 - 9
internal/ui/chat/guilds_tree.go

@@ -211,10 +211,10 @@ func (gt *guildsTree) createChannelNode(node *tview.TreeNode, channel discord.Ch
 		channelNode.SetIndent(gt.cfg.Theme.GuildsTree.Indents.GroupDM)
 	case discord.GuildCategory:
 		channelNode.SetIndent(gt.cfg.Theme.GuildsTree.Indents.Category)
-		channelNode.SetExpandable(true).SetExpanded(true)
+		channelNode.SetExpandable(true).SetExpanded(gt.guildState.isChannelExpanded(channel.ID, true))
 	case discord.GuildForum:
 		channelNode.SetIndent(gt.cfg.Theme.GuildsTree.Indents.Forum)
-		channelNode.SetExpandable(true).SetExpanded(false)
+		channelNode.SetExpandable(true).SetExpanded(gt.guildState.isChannelExpanded(channel.ID, false))
 	default:
 		channelNode.SetIndent(gt.cfg.Theme.GuildsTree.Indents.Channel)
 	}
@@ -271,12 +271,19 @@ func (gt *guildsTree) createChannelNodes(node *tview.TreeNode, channels []discor
 	}
 }
 
+func (gt *guildsTree) persistExpandState(ref any, expanded bool) {
+	switch ref := ref.(type) {
+	case discord.GuildID:
+		go gt.guildState.setExpanded(ref, expanded)
+	case discord.ChannelID:
+		go gt.guildState.setChannelExpanded(ref, expanded)
+	}
+}
+
 func (gt *guildsTree) onSelected(node *tview.TreeNode) tview.Command {
 	if len(node.GetChildren()) != 0 {
 		node.SetExpanded(!node.IsExpanded())
-		if guildID, ok := node.GetReference().(discord.GuildID); ok {
-			go gt.guildState.setExpanded(guildID, node.IsExpanded())
-		}
+		gt.persistExpandState(node.GetReference(), node.IsExpanded())
 		return nil
 	}
 
@@ -356,7 +363,18 @@ func (gt *guildsTree) loadChannel(channel discord.Channel) tview.Command {
 			return nil
 		}
 
-		go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
+		// Use the newest fetched message ID for MarkRead — channel.LastMessageID
+		// can be stale in the cabinet cache, causing some channels to remain unread.
+		// Messages are snowflake-ordered, so the last element is the newest.
+		lastMsgID := channel.LastMessageID
+		if len(messages) > 0 {
+			if newest := messages[len(messages)-1].ID; newest > lastMsgID {
+				lastMsgID = newest
+			}
+		}
+		if lastMsgID.IsValid() {
+			go gt.chat.state.ReadState.MarkRead(channel.ID, lastMsgID)
+		}
 
 		if guildID := channel.GuildID; guildID.IsValid() {
 			gt.chat.messagesList.requestGuildMembers(guildID, messages)
@@ -376,9 +394,7 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) {
 		return
 	}
 	parent.Collapse()
-	if guildID, ok := parent.GetReference().(discord.GuildID); ok {
-		go gt.guildState.setExpanded(guildID, false)
-	}
+	gt.persistExpandState(parent.GetReference(), false)
 	gt.SetCurrentNode(parent)
 }
 
@@ -409,6 +425,14 @@ func (gt *guildsTree) HandleEvent(event tview.Event) tview.Command {
 			return handler(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone))
 		case keybind.Matches(event, gt.cfg.Keybinds.GuildsTree.YankID.Keybind):
 			return gt.yankID()
+		case event.Key() == tcell.KeyEsc:
+			// ESC cycles: input → messages → guilds → input
+			if cmd := gt.chat.focusMessageInput(); cmd != nil {
+				return cmd
+			}
+			return gt.chat.focusMessagesList()
+		case event.Key() == tcell.KeyRight:
+			return gt.chat.focusMessagesList()
 		}
 		// Do not fall through to TreeView defaults for unmatched keys.
 		return nil

+ 34 - 4
internal/ui/chat/guildstate.go

@@ -12,25 +12,35 @@ import (
 )
 
 type guildState struct {
-	ExpandedGuilds map[discord.GuildID]bool `json:"expanded_guilds"`
-	mu             sync.RWMutex
+	ExpandedGuilds   map[discord.GuildID]bool   `json:"expanded_guilds"`
+	ExpandedChannels map[discord.ChannelID]bool `json:"expanded_channels,omitempty"`
+	mu               sync.RWMutex
 }
 
 var stateFilePath = filepath.Join(consts.CacheDir(), "state.json")
 
 func loadGuildState() *guildState {
-	gs := &guildState{ExpandedGuilds: make(map[discord.GuildID]bool)}
+	gs := &guildState{
+		ExpandedGuilds:   make(map[discord.GuildID]bool),
+		ExpandedChannels: make(map[discord.ChannelID]bool),
+	}
 	data, err := os.ReadFile(stateFilePath)
 	if err != nil {
 		return gs
 	}
 	if err := json.Unmarshal(data, gs); err != nil {
 		slog.Warn("failed to parse guild state", "err", err)
-		return &guildState{ExpandedGuilds: make(map[discord.GuildID]bool)}
+		return &guildState{
+			ExpandedGuilds:   make(map[discord.GuildID]bool),
+			ExpandedChannels: make(map[discord.ChannelID]bool),
+		}
 	}
 	if gs.ExpandedGuilds == nil {
 		gs.ExpandedGuilds = make(map[discord.GuildID]bool)
 	}
+	if gs.ExpandedChannels == nil {
+		gs.ExpandedChannels = make(map[discord.ChannelID]bool)
+	}
 	return gs
 }
 
@@ -68,3 +78,23 @@ func (gs *guildState) isExpanded(id discord.GuildID) bool {
 	defer gs.mu.RUnlock()
 	return gs.ExpandedGuilds[id]
 }
+
+func (gs *guildState) setChannelExpanded(id discord.ChannelID, expanded bool) {
+	gs.mu.Lock()
+	if expanded {
+		gs.ExpandedChannels[id] = true
+	} else {
+		delete(gs.ExpandedChannels, id)
+	}
+	gs.mu.Unlock()
+	gs.save()
+}
+
+func (gs *guildState) isChannelExpanded(id discord.ChannelID, defaultExpanded bool) bool {
+	gs.mu.RLock()
+	defer gs.mu.RUnlock()
+	if v, ok := gs.ExpandedChannels[id]; ok {
+		return v
+	}
+	return defaultExpanded
+}

+ 10 - 5
internal/ui/chat/messages_list.go

@@ -619,16 +619,16 @@ func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message
 }
 
 func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
-	dimStyle := baseStyle.Dim(true)
+	replyStyle := baseStyle.Dim(true).Italic(true)
 	// indicator
-	builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", dimStyle)
+	builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", replyStyle)
 
 	if m := message.ReferencedMessage; m != nil {
 		m.GuildID = message.GuildID
-		ml.drawAuthor(builder, *m, dimStyle)
-		ml.drawContent(builder, *m, dimStyle)
+		ml.drawAuthor(builder, *m, replyStyle)
+		ml.drawContent(builder, *m, replyStyle)
 	} else {
-		builder.Write("Original message was deleted", dimStyle)
+		builder.Write("Original message was deleted", replyStyle)
 	}
 
 	builder.NewLine()
@@ -661,6 +661,11 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
 			ml.clearSelection()
 			return tview.SetFocus(ml.chatView.guildsTree)
+		case event.Key() == tcell.KeyLeft:
+			if cmd := ml.chatView.focusGuildsTree(); cmd != nil {
+				return cmd
+			}
+			return nil
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind):
 			return ml.selectUp()
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind):

+ 2 - 0
internal/ui/chat/model.go

@@ -168,6 +168,7 @@ func (m *Model) openPicker() {
 		layers.WithVisible(true),
 		layers.WithOverlay(),
 	).SendToFront(channelsPickerLayerName)
+	m.channelsPicker.resetBrowse()
 	m.channelsPicker.update()
 }
 
@@ -184,6 +185,7 @@ func (m *Model) openSearch() {
 		layers.WithVisible(true),
 		layers.WithOverlay(),
 	).SendToFront(searchPickerLayerName)
+	m.searchPicker.resetBrowse()
 	m.searchPicker.update()
 }
 

+ 9 - 2
internal/ui/chat/search_picker.go

@@ -14,19 +14,26 @@ const searchPickerLayerName = "searchPicker"
 
 type searchPicker struct {
 	*picker.Model
-	chatView *Model
+	chatView   *Model
+	browseMode bool
 }
 
 var _ help.KeyMap = (*searchPicker)(nil)
 
 func newSearchPicker(cfg *config.Config, chatView *Model) *searchPicker {
-	sp := &searchPicker{picker.NewModel(), chatView}
+	sp := &searchPicker{Model: picker.NewModel(), chatView: chatView}
 	ConfigurePicker(sp.Model, cfg, "Search")
 	return sp
 }
 
+func (sp *searchPicker) resetBrowse() { sp.browseMode = false }
+
 func (sp *searchPicker) HandleEvent(event tview.Event) tview.Command {
 	switch event := event.(type) {
+	case *tview.KeyEvent:
+		if cmd, handled := pickerBrowseHandleKey(event, &sp.browseMode, sp.Model, func() { sp.chatView.closeSearch() }); handled {
+			return cmd
+		}
 	case *picker.SelectedEvent:
 		messageIndex, ok := event.Reference.(int)
 		if !ok {

+ 39 - 0
internal/ui/chat/util.go

@@ -8,6 +8,7 @@ import (
 	"github.com/ayn2op/tview"
 	"github.com/ayn2op/tview/list"
 	"github.com/ayn2op/tview/picker"
+	"github.com/gdamore/tcell/v3"
 )
 
 func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) {
@@ -41,6 +42,44 @@ func ConfigurePicker(model *picker.Model, cfg *config.Config, title string) {
 	})
 }
 
+// pickerBrowseHandleKey implements a two-phase ESC for overlay pickers.
+// First ESC enters browse mode (j/k navigate, i returns to input).
+// Second ESC calls closeFn to close the picker.
+// Returns (command, handled). If handled is false, the caller should
+// fall through to the normal picker event handling.
+func pickerBrowseHandleKey(event *tview.KeyEvent, browseMode *bool, model *picker.Model, closeFn func()) (tview.Command, bool) {
+	if !*browseMode {
+		if event.Key() == tcell.KeyEsc {
+			*browseMode = true
+			return nil, true
+		}
+		return nil, false
+	}
+
+	// Browse mode: intercept keys before they reach the input field.
+	switch {
+	case event.Key() == tcell.KeyEsc:
+		*browseMode = false
+		closeFn()
+		return nil, true
+	case event.Key() == tcell.KeyRune && event.Str() == "i":
+		*browseMode = false
+		return nil, true
+	case event.Key() == tcell.KeyRune && event.Str() == "j":
+		return model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlN, "", tcell.ModCtrl)), true
+	case event.Key() == tcell.KeyRune && event.Str() == "k":
+		return model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModCtrl)), true
+	case event.Key() == tcell.KeyRune && event.Str() == "g":
+		return model.HandleEvent(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)), true
+	case event.Key() == tcell.KeyRune && event.Str() == "G":
+		return model.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)), true
+	case event.Key() == tcell.KeyEnter:
+		return model.HandleEvent(event), true
+	}
+	// Swallow other keys in browse mode so they don't reach the input.
+	return nil, true
+}
+
 func humanJoin(items []string) string {
 	count := len(items)
 	switch count {