feat(chat): auto-expanding message input with shift+enter newline (#799)

This commit is contained in:
ayn2op 2026-05-27 16:03:25 -04:00 • committed by GitHub
parent 19907d7889
commit 318bf731ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 107 additions and 25 deletions

4
go.mod
View file

@ -11,7 +11,7 @@ require (
github.com/alecthomas/chroma/v2 v2.25.0
github.com/andybalholm/brotli v1.2.1
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97
github.com/ayn2op/tview v0.0.0-20260518065412-67dde5e23f28
github.com/ayn2op/tview v0.0.0-20260527184746-2ff331312d36
github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
github.com/diamondburned/arikawa/v3 v3.6.1-0.20260518050745-b430932b3ee1
github.com/diamondburned/ningen/v3 v3.0.1-0.20260306213430-5a08d3a709b4
@ -54,7 +54,7 @@ require (
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
github.com/twmb/murmur3 v1.1.8 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/exp/shiny v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/exp/shiny v0.0.0-20260527015227-08cc5374adb3 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mobile v0.0.0-20260520154334-0e4426e1883d // indirect
golang.org/x/sys v0.45.0 // indirect

8
go.sum
View file

@ -14,8 +14,8 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97 h1:WujETUV+v0DEJyZgjeLzQvihWyL80c0Tg4qf0dDo+Io=
github.com/ayn2op/clipboard v0.0.0-20260308203959-c5ad7df3fc97/go.mod h1:3kFnpNCa3dF6WryzOMCDao7PfZ7DTCh+pievlfuwV80=
github.com/ayn2op/tview v0.0.0-20260518065412-67dde5e23f28 h1:p3kPeeYPtPW0hqiQObSIGb6KsFYy0nasc/AZnPMvUqs=
github.com/ayn2op/tview v0.0.0-20260518065412-67dde5e23f28/go.mod h1:qYiaybvIwn7ry4iXX5Q8T3NgZacKoytk5kTwJ1Jy8WY=
github.com/ayn2op/tview v0.0.0-20260527184746-2ff331312d36 h1:MC55SU7Y8JVLn5nCXmEvLkfec/+7eo3KtserKVlSbFI=
github.com/ayn2op/tview v0.0.0-20260527184746-2ff331312d36/go.mod h1:W0lZFznN+hDXma+CT27czLM8/zbNOda6vsfVgHI1Sew=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
@ -117,8 +117,8 @@ go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp/shiny v0.0.0-20260508232706-74f9aab9d74a h1:rEXukQywIYF+MmY26FaDeMA3yOSPe4TAKjue5Maz3PM=
golang.org/x/exp/shiny v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:eQWld7Ucl0GVwFfFJYfSLmTbCJG2XEQrz+PGjltJgck=
golang.org/x/exp/shiny v0.0.0-20260527015227-08cc5374adb3 h1:UIWQrCxLb5XfsY23rY1N+HOiWQLccu3Sc7Brlvm9v08=
golang.org/x/exp/shiny v0.0.0-20260527015227-08cc5374adb3/go.mod h1:eQWld7Ucl0GVwFfFJYfSLmTbCJG2XEQrz+PGjltJgck=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mobile v0.0.0-20260520154334-0e4426e1883d h1:pWrEKZvKeqE2xPrylgBjgCyJSpPPt3L2WG2DmA+Xccg=

View file

@ -74,6 +74,12 @@ type (
Separator string `toml:"separator"`
}
MessageInputConfig struct {
// MaxHeight caps how tall (in newline-separated rows) the input grows before it starts scrolling internally.
// Must be >= 1; values <= 0 fall back to the default.
MaxHeight int `toml:"max_height"`
}
SidebarMarkersConfig struct {
Expanded string `toml:"expanded"`
Collapsed string `toml:"collapsed"`
@ -107,14 +113,15 @@ type (
AutocompleteLimit uint8 `toml:"autocomplete_limit"`
MessagesLimit uint8 `toml:"messages_limit"`
Markdown MarkdownConfig `toml:"markdown"`
Help HelpConfig `toml:"help"`
Picker PickerConfig `toml:"picker"`
Timestamps Timestamps `toml:"timestamps"`
DateSeparator DateSeparator `toml:"date_separator"`
Notifications Notifications `toml:"notifications"`
TypingIndicator TypingIndicator `toml:"typing_indicator"`
Sidebar SidebarConfig `toml:"sidebar"`
Markdown MarkdownConfig `toml:"markdown"`
Help HelpConfig `toml:"help"`
Picker PickerConfig `toml:"picker"`
Timestamps Timestamps `toml:"timestamps"`
DateSeparator DateSeparator `toml:"date_separator"`
Notifications Notifications `toml:"notifications"`
TypingIndicator TypingIndicator `toml:"typing_indicator"`
Sidebar SidebarConfig `toml:"sidebar"`
MessageInput MessageInputConfig `toml:"message_input"`
Icons Icons `toml:"icons"`
@ -171,6 +178,10 @@ func applyDefaults(cfg *Config) {
cfg.Status = discord.UnknownStatus
}
if cfg.MessageInput.MaxHeight <= 0 {
cfg.MessageInput.MaxHeight = 10
}
if cfg.DateSeparator.Format == "" {
cfg.DateSeparator.Format = "January 2, 2006"
}

View file

@ -20,6 +20,11 @@ autocomplete_limit = 20
# The number of messages to fetch when a text-based channel is selected from guilds tree. The minimum and maximum value is 1 and 100, respectively.
messages_limit = 50
[message_input]
# Maximum height (in newline-separated rows) the message input grows to before it starts scrolling internally.
# Set to 1 for a fixed single-line input.
max_height = 10
[markdown]
# Whether to parse and render markdown in messages or not.
enabled = true
@ -159,11 +164,12 @@ yank_url = "u"
yank_id = "i"
# Only while typing a message
# Alt+Enter: Insert a new line to the current text.
[keybinds.message_input]
# paste from clipboard (supports both text and images)
# Paste from clipboard (supports both text and images)
paste = "ctrl+v"
send = "enter"
# Insert a new line at the cursor.
newline = "shift+enter"
# Remove existing text or cancel reply.
cancel = "esc"
# Complete usernames when mentioning

View file

@ -97,6 +97,7 @@ type MessagesListKeybinds struct {
type MessageInputKeybinds struct {
Paste Keybind `toml:"paste"`
Send Keybind `toml:"send"`
Newline Keybind `toml:"newline"`
Cancel Keybind `toml:"cancel"`
TabComplete Keybind `toml:"tab_complete"`
Undo Keybind `toml:"undo"`
@ -198,6 +199,7 @@ func defaultMessageInputKeybinds() MessageInputKeybinds {
return MessageInputKeybinds{
Paste: desc("paste"),
Send: desc("send"),
Newline: desc("nl"),
Cancel: desc("cancel"),
TabComplete: desc("complete"),
Undo: desc("undo"),

View file

@ -93,6 +93,46 @@ func newMessageInput(cfg *config.Config, chat *Model) *messageInput {
return mi
}
// forwardToTextArea passes a key event to the embedded TextArea then resizes for the new content.
// SetChangedFunc isn't used: it fires inside replace's defer, before truncateLines/findCursor, so it would observe stale state.
func (mi *messageInput) forwardToTextArea(ev *tcell.EventKey) tview.Cmd {
cmd := mi.TextArea.Update(ev)
mi.resizeForContent()
return cmd
}
// resizeForContent fits the input to its newline count, capped at MessageInput.MaxHeight and at the parent flex's height (leaving at least 1 row for the messages list), then pins the scroll offset to the new visible window.
// Safe to call after every edit; ResizeItem and SetOffset are both idempotent.
func (mi *messageInput) resizeForContent() {
if mi.chat == nil || mi.chat.rightFlex == nil {
return
}
_, _, _, outerH := mi.Rect()
_, _, _, innerH := mi.InnerRect()
frame := outerH - innerH // border + title/footer + padding rows
_, _, _, parentH := mi.chat.rightFlex.InnerRect()
visible := min(
strings.Count(mi.GetText(), "\n")+1, // newline-driven height
max(mi.cfg.MessageInput.MaxHeight, 1), // user-configured cap
max(parentH-frame-1, 1), // available room, reserving 1 row for messages
)
mi.chat.rightFlex.ResizeItem(mi, visible+frame, 1) // outer height = inner content + frame
// Sync the textarea's inner height so the next keystroke's cursor clamping uses the new size.
mi.SetVisibleSize(0, visible)
// Clamp scroll: when content overflows, pin the bottom row to the end of the text so backspacing the last newline doesn't leave a blank trailing row; once everything fits, reset to (0,0).
total := mi.LineCount(0)
row, col := mi.GetOffset()
maxOff := max(total-visible, 0) // last valid rowOffset that still shows real content on the bottom row
if row > maxOff {
mi.SetOffset(maxOff, col)
} else if total <= visible && (row != 0 || col != 0) {
mi.SetOffset(0, 0)
}
}
func (mi *messageInput) reset() {
mi.edit = false
mi.sendMessageData = &api.SendMessageData{}
@ -101,6 +141,28 @@ func (mi *messageInput) reset() {
mi.SetText("", true)
}
// The following overrides wrap the embedded TextArea/Box methods to auto-resize when callers change text or chrome.
func (mi *messageInput) SetText(text string, cursorAtTheEnd bool) *tview.TextArea {
defer mi.resizeForContent()
return mi.TextArea.SetText(text, cursorAtTheEnd)
}
func (mi *messageInput) Replace(start, end int, text string) *tview.TextArea {
defer mi.resizeForContent()
return mi.TextArea.Replace(start, end, text)
}
func (mi *messageInput) SetTitle(title string) *tview.Box {
defer mi.resizeForContent()
return mi.Box.SetTitle(title)
}
func (mi *messageInput) SetFooter(footer string) *tview.Box {
defer mi.resizeForContent()
return mi.Box.SetFooter(footer)
}
func (mi *messageInput) stopTypingTimer() {
mi.typingTimerMu.Lock()
defer mi.typingTimerMu.Unlock()
@ -111,7 +173,6 @@ func (mi *messageInput) stopTypingTimer() {
}
func (mi *messageInput) Update(msg tview.Msg) tview.Cmd {
handler := mi.TextArea.Update
switch msg := msg.(type) {
case tabSuggestMsg:
return mi.tabSuggest()
@ -134,7 +195,9 @@ func (mi *messageInput) Update(msg tview.Msg) tview.Cmd {
case tview.KeyMsg:
switch {
case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Paste.Keybind):
return tview.Sequence(mi.pasteImage(), handler(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)))
return tview.Sequence(mi.pasteImage(), mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)))
case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Newline.Keybind):
return mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyEnter, "", tcell.ModNone))
case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Send.Keybind):
if mi.chat.GetVisible(mentionsListLayerName) {
return mi.tabComplete()
@ -156,9 +219,9 @@ func (mi *messageInput) Update(msg tview.Msg) tview.Cmd {
if mi.chat.GetVisible(mentionsListLayerName) {
return mi.tabComplete()
}
return handler(msg)
return mi.forwardToTextArea(msg)
case keybind.Matches(msg, mi.cfg.Keybinds.MessageInput.Undo.Keybind):
return handler(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
return mi.forwardToTextArea(tcell.NewEventKey(tcell.KeyCtrlZ, "", tcell.ModNone))
}
typingCmd := mi.sendTyping()
@ -175,11 +238,11 @@ func (mi *messageInput) Update(msg tview.Msg) tview.Cmd {
}
// Apply key edits first, then recompute autocomplete through Msg/Cmd.
return tview.Batch(typingCmd, tview.Sequence(handler(msg), mi.tabSuggest()))
return tview.Batch(typingCmd, tview.Sequence(mi.forwardToTextArea(msg), mi.tabSuggest()))
}
return tview.Batch(typingCmd, handler(msg))
return tview.Batch(typingCmd, mi.forwardToTextArea(msg))
}
return handler(msg)
return mi.TextArea.Update(msg)
}
type imagePastedMsg []byte
@ -768,7 +831,7 @@ func (mi *messageInput) ShortHelp() []keybind.Keybind {
}
cfg := mi.cfg.Keybinds.MessageInput
short := []keybind.Keybind{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
short := []keybind.Keybind{cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.Paste.Keybind, cfg.OpenEditor.Keybind}
if mi.canAttachFiles() {
short = append(short, cfg.OpenFilePicker.Keybind)
}
@ -793,7 +856,7 @@ func (mi *messageInput) FullHelp() [][]keybind.Keybind {
}
return [][]keybind.Keybind{
{cfg.Send.Keybind, cfg.Cancel.Keybind, cfg.TabComplete.Keybind, cfg.Undo.Keybind},
{cfg.Send.Keybind, cfg.Newline.Keybind, cfg.Cancel.Keybind, cfg.TabComplete.Keybind, cfg.Undo.Keybind},
openEditor,
}
}