feat: improve textbox editor behaviour

This commit is contained in:
2026-07-31 15:36:23 +02:00
parent b24669e604
commit e36b00f741
15 changed files with 504 additions and 154 deletions

View File

@@ -201,12 +201,16 @@ focused pane by three items or rendered lines. Mouse reporting remains disabled
by default so normal terminal text selection is unchanged; with mouse reporting by default so normal terminal text selection is unchanged; with mouse reporting
enabled, terminals commonly require holding Shift while selecting text. enabled, terminals commonly require holding Shift while selecting text.
The PR description editor defaults to Vim-style modal editing, including Editable text fields default to Vim-style modal editing, including PR metadata,
Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard reply, and local-AI discussion fields. They support Normal, Insert, and Visual
yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a modes, word/find motions, deletion, system clipboard yank/paste, and
non-modal editor. Target-branch, reviewer, and assignee completion use soft-wrap-aware movement. The active mode and input are shown in a Neovim-style
`ctrl+n` and `ctrl+p`; reviewer and assignee fields accept comma-separated footer bar. Set `editing.mode = "standard"` for non-modal inputs with arrow-key
GitHub usernames. Pending individual review requests and assignees are cursor movement, including movement across wrapped lines. Search remains a
dedicated insert-only filter. Target-branch, reviewer, and assignee completion use
`ctrl+n` and `ctrl+p`; `enter` accepts the selected completion, while `tab`
moves to the next metadata field. Reviewer and assignee fields accept
comma-separated GitHub usernames. Pending individual review requests and assignees are
prefilled and marked in completion results. Reviewers who already submitted a prefilled and marked in completion results. Reviewers who already submitted a
review, and requested teams, appear first as protected subdued tokens in the review, and requested teams, appear first as protected subdued tokens in the
reviewer field. Their handles retain a darker version of their deterministic reviewer field. Their handles retain a darker version of their deterministic

View File

@@ -70,6 +70,7 @@ func (m *App) startAIDiscussion(threadID string) {
return return
} }
m.aiMode, m.aiInput, m.writeThreadID, m.err = aiDiscussion, "", threadID, nil m.aiMode, m.aiInput, m.writeThreadID, m.err = aiDiscussion, "", threadID, nil
m.resetInputEditor(&m.aiInputEditor, "")
if thread := m.threadByID(threadID); thread != nil { if thread := m.threadByID(threadID); thread != nil {
m.folded[threadID] = false m.folded[threadID] = false
m.focus = threadDetailPane m.focus = threadDetailPane
@@ -298,12 +299,20 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
if m.aiMode != aiDiscussion { if m.aiMode != aiDiscussion {
cancelled = cancelled || keyMatches(raw, m.keybindings.General.Back) cancelled = cancelled || keyMatches(raw, m.keybindings.General.Back)
} }
if cancelled && m.aiMode == aiDiscussion &&
m.aiInputEditor.Modal && m.aiInputEditor.Mode != textEditorNormal {
m.aiInputEditor.handleKeyAtWidth(key, true, m.threadInputWidth())
m.aiInput = m.aiInputEditor.Text
m.ensureThreadInputCursorVisible()
return m, nil, true
}
if cancelled { if cancelled {
if m.aiCancel != nil { if m.aiCancel != nil {
m.aiCancel() m.aiCancel()
m.aiCancel = nil m.aiCancel = nil
} }
m.aiMode, m.aiInput, m.writeThreadID, m.aiEvents = aiNone, "", "", nil m.aiMode, m.aiInput, m.writeThreadID, m.aiEvents = aiNone, "", "", nil
m.aiInputEditor = textEditor{}
return m, nil, true return m, nil, true
} }
@@ -351,19 +360,12 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
} else { } else {
return m, m.beginAIPrepare(m.writeThreadID, strings.TrimSpace(m.aiInput)), true return m, m.beginAIPrepare(m.writeThreadID, strings.TrimSpace(m.aiInput)), true
} }
case keyMatches(raw, m.keybindings.Input.Newline):
m.aiInput += "\n"
case keyMatches(raw, m.keybindings.Input.DeleteBackward):
runes := []rune(m.aiInput)
if len(runes) > 0 {
m.aiInput = string(runes[:len(runes)-1])
}
default: default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { if m.aiInputEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) {
m.aiInput += textInputKeyValue(key) m.aiInput = m.aiInputEditor.Text
} }
} }
m.scroll = m.detailMaxScroll() m.ensureThreadInputCursorVisible()
case aiConfirm: case aiConfirm:
switch { switch {
case keyMatches(raw, m.keybindings.General.Confirm): case keyMatches(raw, m.keybindings.General.Confirm):
@@ -461,16 +463,17 @@ func (m App) viewAI() string {
lines = append(lines, titleStyle.Render("Local AI discussion"), "", lines = append(lines, titleStyle.Render("Local AI discussion"), "",
dimStyle.Render("This message stays local; only the configured model receives it."), "") dimStyle.Render("This message stays local; only the configured model receives it."), "")
lines = append(lines, renderTextInput( lines = append(lines, renderTextInput(
m.aiInput, width-2, m.cursorOutput != nil, m.aiInputEditor, width-2, m.cursorOutput != nil,
)...) )...)
if m.err != nil { if m.err != nil {
lines = append(lines, "", badStyle.Render(m.err.Error())) lines = append(lines, "", badStyle.Render(m.err.Error()))
} }
lines = append(lines, "", dimStyle.Render(fmt.Sprintf( lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
"%s newline • %s prepare • %s cancel", "%s newline • %s prepare • %s %s",
primaryKeyLabel(m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel), primaryKeyLabel(m.keybindings.Input.Cancel),
m.inputCancelAction(),
))) )))
case aiPreparing: case aiPreparing:
lines = m.aiProgressLines(width) lines = m.aiProgressLines(width)
@@ -666,7 +669,9 @@ func (m App) inlineAIDiscussionLines(width int) []detailLine {
} }
textWidth := max(1, width-5) textWidth := max(1, width-5)
lineIndex := 0 lineIndex := 0
for _, part := range renderTextInput(m.aiInput, textWidth, m.cursorOutput != nil) { for _, part := range renderTextInput(
m.aiInputEditor, textWidth, m.cursorOutput != nil,
) {
lines = append(lines, detailLine{ lines = append(lines, detailLine{
rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part, rail: rail, anchor: fmt.Sprintf("ai-discussion:body:%d", lineIndex), text: part,
}) })
@@ -678,10 +683,11 @@ func (m App) inlineAIDiscussionLines(width int) []detailLine {
lines = append(lines, detailLine{ lines = append(lines, detailLine{
rail: rail, rail: rail,
text: dimStyle.Render(fmt.Sprintf( text: dimStyle.Render(fmt.Sprintf(
"%s newline • %s prepare • %s cancel", "%s newline • %s prepare • %s %s",
primaryKeyLabel(m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel), primaryKeyLabel(m.keybindings.Input.Cancel),
m.inputCancelAction(),
)), )),
}) })
return lines return lines

View File

@@ -178,13 +178,12 @@ func (m App) branchCompletionLines(width int) []string {
return []string{dimStyle.Render(" no matching repository branches")} return []string{dimStyle.Render(" no matching repository branches")}
} }
lines := []string{dimStyle.Render(fmt.Sprintf( lines := []string{dimStyle.Render(fmt.Sprintf(
" %s choose • %s complete • %s again advances", " %s choose • %s complete",
primaryCombinedKeyLabel( primaryCombinedKeyLabel(
m.keybindings.Input.PreviousCompletion, m.keybindings.Input.PreviousCompletion,
m.keybindings.Input.NextCompletion, m.keybindings.Input.NextCompletion,
), ),
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.NextField),
))} ))}
now := time.Now() now := time.Now()
for index, suggestion := range suggestions { for index, suggestion := range suggestions {

View File

@@ -68,19 +68,23 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) {
m = updated.(App) m = updated.(App)
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab}) updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App) m = updated.(App)
if got := m.prEditEditors[prEditBaseField].Text; got != "release" {
t.Fatalf("tab unexpectedly completed selected branch: %q", got)
}
if m.prEditField != prEditReviewersField {
t.Fatalf("tab did not advance from target branch: field=%d", m.prEditField)
}
m.prEditField = prEditBaseField
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(App)
if got := m.prEditEditors[prEditBaseField].Text; got != "release/2.0" && if got := m.prEditEditors[prEditBaseField].Text; got != "release/2.0" &&
got != "release/1.0" { got != "release/1.0" {
t.Fatalf("tab did not complete selected branch: %q", got) t.Fatalf("enter did not complete selected branch: %q", got)
} }
if m.prEditField != prEditBaseField { if m.prEditField != prEditBaseField {
t.Fatalf("completion moved away from target branch: field=%d", m.prEditField) t.Fatalf("completion moved away from target branch: field=%d", m.prEditField)
} }
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App)
if m.prEditField != prEditReviewersField {
t.Fatalf("second tab did not advance: field=%d", m.prEditField)
}
} }
func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testing.T) { func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testing.T) {
@@ -94,7 +98,7 @@ func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testi
m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}} m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}}
view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n")) view := ansi.Strip(strings.Join(m.prEditFieldLines("target branch", prEditBaseField, 80), "\n"))
if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "tab / enter complete") { if !strings.Contains(view, "release/2.0") || !strings.Contains(view, "enter complete") {
t.Fatalf("branch suggestions missing:\n%s", view) t.Fatalf("branch suggestions missing:\n%s", view)
} }
if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") { if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") {

4
cli.go
View File

@@ -210,7 +210,7 @@ _diple() {
'--compact-reviews=[aggregate submitted reviews]:boolean:(true false)' \ '--compact-reviews=[aggregate submitted reviews]:boolean:(true false)' \
'--path-scroll=[scroll truncated paths]:boolean:(true false)' \ '--path-scroll=[scroll truncated paths]:boolean:(true false)' \
'--path-scroll-interval[path scrolling interval]:duration:' \ '--path-scroll-interval[path scrolling interval]:duration:' \
'--editor-mode[description editor mode]:mode:(vim standard)' \ '--editor-mode[text input editor mode]:mode:(vim standard)' \
'--config[TOML configuration file]:file:_files' \ '--config[TOML configuration file]:file:_files' \
'--cache=[enable local read cache]:boolean:(true false)' \ '--cache=[enable local read cache]:boolean:(true false)' \
'--cache-max-age[maximum offline cache age]:duration:' \ '--cache-max-age[maximum offline cache age]:duration:' \
@@ -243,7 +243,7 @@ complete -c diple -l fold-resolved -d 'Start resolved threads folded'
complete -c diple -l compact-reviews -d 'Aggregate submitted reviews' complete -c diple -l compact-reviews -d 'Aggregate submitted reviews'
complete -c diple -l path-scroll -d 'Scroll truncated paths' complete -c diple -l path-scroll -d 'Scroll truncated paths'
complete -c diple -l path-scroll-interval -x -d 'Path scrolling interval' complete -c diple -l path-scroll-interval -x -d 'Path scrolling interval'
complete -c diple -l editor-mode -x -a 'vim standard' -d 'Description editor mode' complete -c diple -l editor-mode -x -a 'vim standard' -d 'Text input editor mode'
complete -c diple -l config -r -F -d 'TOML configuration file' complete -c diple -l config -r -F -d 'TOML configuration file'
complete -c diple -l cache -d 'Enable local read cache' complete -c diple -l cache -d 'Enable local read cache'
complete -c diple -l cache-max-age -x -d 'Maximum offline cache age' complete -c diple -l cache-max-age -x -d 'Maximum offline cache age'

View File

@@ -559,7 +559,12 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
contextBinding{"previous_completion", input.PreviousCompletion}, contextBinding{"previous_completion", input.PreviousCompletion},
contextBinding{"next_completion", input.NextCompletion}, contextBinding{"next_completion", input.NextCompletion},
contextBinding{"delete_backward", input.DeleteBackward}, contextBinding{"delete_backward", input.DeleteBackward},
contextBinding{"delete_forward", input.DeleteForward},
contextBinding{"clear", input.Clear}, contextBinding{"clear", input.Clear},
contextBinding{"line_start", input.LineStart},
contextBinding{"line_end", input.LineEnd},
contextBinding{"left", nonTextBindings(navigation.Left)},
contextBinding{"right", nonTextBindings(navigation.Right)},
); err != nil { ); err != nil {
return err return err
} }
@@ -569,6 +574,13 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
contextBinding{"submit", input.Submit}, contextBinding{"submit", input.Submit},
contextBinding{"newline", input.Newline}, contextBinding{"newline", input.Newline},
contextBinding{"delete_backward", input.DeleteBackward}, contextBinding{"delete_backward", input.DeleteBackward},
contextBinding{"delete_forward", input.DeleteForward},
contextBinding{"line_start", input.LineStart},
contextBinding{"line_end", input.LineEnd},
contextBinding{"left", nonTextBindings(navigation.Left)},
contextBinding{"down", nonTextBindings(navigation.Down)},
contextBinding{"up", nonTextBindings(navigation.Up)},
contextBinding{"right", nonTextBindings(navigation.Right)},
); err != nil { ); err != nil {
return err return err
} }
@@ -658,6 +670,7 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
contextBinding{"selection_other_end", vim.SelectionOtherEnd}, contextBinding{"selection_other_end", vim.SelectionOtherEnd},
contextBinding{"yank", vim.Yank}, contextBinding{"yank", vim.Yank},
contextBinding{"delete", vim.Delete}, contextBinding{"delete", vim.Delete},
contextBinding{"substitute", vim.ReplaceCharacter},
contextBinding{"paste", vim.Paste}, contextBinding{"paste", vim.Paste},
contextBinding{"line_start", vim.LineStart}, contextBinding{"line_start", vim.LineStart},
contextBinding{"first_non_blank", vim.FirstNonBlank}, contextBinding{"first_non_blank", vim.FirstNonBlank},
@@ -706,6 +719,8 @@ func validateKeyBindingContexts(bindings KeyBindings) error {
contextBinding{"delete_forward", input.DeleteForward}, contextBinding{"delete_forward", input.DeleteForward},
contextBinding{"line_start", input.LineStart}, contextBinding{"line_start", input.LineStart},
contextBinding{"line_end", input.LineEnd}, contextBinding{"line_end", input.LineEnd},
contextBinding{"left", nonTextBindings(navigation.Left)},
contextBinding{"right", nonTextBindings(navigation.Right)},
contextBinding{"up", nonTextBindings(navigation.Up)}, contextBinding{"up", nonTextBindings(navigation.Up)},
contextBinding{"down", nonTextBindings(navigation.Down)}, contextBinding{"down", nonTextBindings(navigation.Down)},
) )

View File

@@ -52,7 +52,7 @@ func main() {
cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable the local read cache and offline fallback") cacheEnabled = flag.Bool("cache", defaults.Cache.Enabled, "enable the local read cache and offline fallback")
cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "oldest cache entry accepted for offline fallback") cacheMaxAge = flag.Duration("cache-max-age", defaults.Cache.MaxAge.Duration, "oldest cache entry accepted for offline fallback")
cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read-cache directory") cacheDir = flag.String("cache-dir", defaults.Cache.Directory, "local read-cache directory")
editorMode = flag.String("editor-mode", defaults.Editing.Mode, "description editor mode") editorMode = flag.String("editor-mode", defaults.Editing.Mode, "text input editor mode")
) )
flag.Parse() flag.Parse()
if flag.NArg() != 0 { if flag.NArg() != 0 {

View File

@@ -29,17 +29,18 @@ func (m *App) startPREdit() tea.Cmd {
} }
m.writeMode = writePREdit m.writeMode = writePREdit
m.prEditField = prEditBodyField m.prEditField = prEditBodyField
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false) modal := m.editorMode == "vim"
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false) m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, modal)
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, modal)
m.prEditEditors[prEditReviewersField] = newTextEditor( m.prEditEditors[prEditReviewersField] = newTextEditor(
strings.Join(m.details.RequestedReviewers, ", "), false, strings.Join(m.details.RequestedReviewers, ", "), modal,
) )
m.prEditEditors[prEditAssigneesField] = newTextEditor( m.prEditEditors[prEditAssigneesField] = newTextEditor(
strings.Join(m.details.Assignees, ", "), false, strings.Join(m.details.Assignees, ", "), modal,
) )
m.prEditEditors[prEditBodyField] = newTextEditor( m.prEditEditors[prEditBodyField] = newTextEditor(
normalizeLineEndings(m.details.Body), normalizeLineEndings(m.details.Body),
m.editorMode == "vim", modal,
) )
m.prEditEditors[prEditBodyField].highlightMarkdown = true m.prEditEditors[prEditBodyField].highlightMarkdown = true
m.prEditOriginal = m.currentPRMetadata() m.prEditOriginal = m.currentPRMetadata()
@@ -168,7 +169,9 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if key.Type == tea.KeySpace && m.prEditField == prEditReviewersField { if key.Type == tea.KeySpace && m.prEditField == prEditReviewersField &&
(!m.prEditEditors[m.prEditField].Modal ||
m.prEditEditors[m.prEditField].Mode == textEditorInsert) {
if m.startNextReviewer() { if m.startNextReviewer() {
m.ensurePREditCursorVisible() m.ensurePREditCursorVisible()
return m, m.queuePREditDraft() return m, m.queuePREditDraft()
@@ -195,13 +198,7 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
case "tab": case "tab":
completed := m.prEditField == prEditBaseField && m.completeBranchSuggestion() m.movePREditField(1)
if isPREditPeopleField(m.prEditField) {
completed = m.completeUserSuggestion()
}
if !completed {
m.movePREditField(1)
}
case "shift+tab": case "shift+tab":
m.movePREditField(-1) m.movePREditField(-1)
case "ctrl+n": case "ctrl+n":
@@ -548,7 +545,6 @@ func (m App) dashboardEditLayout() ([]string, int) {
func (m App) prEditFieldLines(label string, field, width int) []string { func (m App) prEditFieldLines(label string, field, width int) []string {
active := m.prEditField == field active := m.prEditField == field
editor := m.prEditEditors[field]
if field == prEditReviewersField { if field == prEditReviewersField {
label += " (pending requests editable)" label += " (pending requests editable)"
} }
@@ -556,10 +552,6 @@ func (m App) prEditFieldLines(label string, field, width int) []string {
if active { if active {
prefix = "▶ " prefix = "▶ "
} }
mode := editor.modeLabel()
if mode != "" {
label += " [" + mode + "]"
}
labelLine := dimStyle.Render(prefix + label) labelLine := dimStyle.Render(prefix + label)
if active { if active {
labelLine = titleStyle.Render(prefix + label) labelLine = titleStyle.Render(prefix + label)

View File

@@ -306,6 +306,8 @@ func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth i
e.yankSelection(wrapWidth) e.yankSelection(wrapWidth)
case keyMatches(k, e.keys.Vim.Delete): case keyMatches(k, e.keys.Vim.Delete):
e.deleteSelection(wrapWidth) e.deleteSelection(wrapWidth)
case keyMatches(k, e.keys.Vim.ReplaceCharacter):
e.substituteSelection(wrapWidth)
case keyMatches(k, e.keys.Vim.Paste): case keyMatches(k, e.keys.Vim.Paste):
e.pasteClipboard(true, wrapWidth) e.pasteClipboard(true, wrapWidth)
default: default:
@@ -407,6 +409,20 @@ func (e *textEditor) deleteSelection(wrapWidth int) {
e.stopVisual() e.stopVisual()
} }
func (e *textEditor) substituteSelection(wrapWidth int) {
start, end, ok := e.selectionBounds(wrapWidth)
if !ok {
e.stopVisual()
return
}
runes := []rune(e.Text)
e.Text = string(append(runes[:start], runes[end:]...))
e.Cursor = start
e.Mode = textEditorInsert
e.visualLine = false
e.clearPending()
}
func (e *textEditor) pasteClipboard(replaceSelection bool, wrapWidth int) { func (e *textEditor) pasteClipboard(replaceSelection bool, wrapWidth int) {
if e.clipboard == nil { if e.clipboard == nil {
e.clipboard = systemTextClipboard{} e.clipboard = systemTextClipboard{}

View File

@@ -397,6 +397,23 @@ func TestVimVisualModeDeletesAcrossSoftWrappedRows(t *testing.T) {
} }
} }
func TestVimVisualSubstituteDeletesSelectionAndEntersInsertMode(t *testing.T) {
editor := newTextEditor("abcdef", true)
editor.Cursor = 1
editor.handleKey(runeKey("v"), false)
editor.handleKey(runeKey("l"), false)
editor.handleKey(runeKey("l"), false)
editor.handleKey(runeKey("s"), false)
if editor.Text != "aef" || editor.Cursor != 1 || editor.Mode != textEditorInsert {
t.Fatalf("visual substitute = %#v", editor)
}
editor.handleKey(runeKey("X"), false)
if editor.Text != "aXef" || editor.Cursor != 2 {
t.Fatalf("visual substitute insertion = %#v", editor)
}
}
func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) { func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) {
clipboard := &memoryTextClipboard{} clipboard := &memoryTextClipboard{}
editor := newTextEditor("abcdef", true) editor := newTextEditor("abcdef", true)

312
tui.go
View File

@@ -148,12 +148,14 @@ type App struct {
pendingZ bool pendingZ bool
searching bool searching bool
searchQuery string searchQuery string
searchEditor textEditor
searchOrigin int searchOrigin int
helpVisible bool helpVisible bool
helpScroll int helpScroll int
writeMode writeMode writeMode writeMode
writeThreadID string writeThreadID string
replyDraft string replyDraft string
replyEditor textEditor
resolveTarget bool resolveTarget bool
autoMergeTarget bool autoMergeTarget bool
mergeMethod string mergeMethod string
@@ -197,6 +199,7 @@ type App struct {
aiMode aiMode aiMode aiMode
aiMenuIndex int aiMenuIndex int
aiInput string aiInput string
aiInputEditor textEditor
aiPreview AIPreview aiPreview AIPreview
aiCancel context.CancelFunc aiCancel context.CancelFunc
aiStatus AIProviderStatus aiStatus AIProviderStatus
@@ -298,6 +301,18 @@ func (m App) Init() tea.Cmd {
return tea.Batch(commands...) return tea.Batch(commands...)
} }
func (m *App) resetInputEditor(editor *textEditor, text string) {
*editor = newTextEditor(text, m.editorMode == "vim")
editor.keys = m.keybindings
editor.hardwareCursor = m.cursorOutput != nil
}
func (m *App) resetSearchEditor(text string) {
m.searchEditor = newTextEditor(text, false)
m.searchEditor.keys = m.keybindings
m.searchEditor.hardwareCursor = m.cursorOutput != nil
}
func (m App) nextTick() tea.Cmd { func (m App) nextTick() tea.Cmd {
return tea.Tick(m.adaptivePollInterval(time.Now()), func(t time.Time) tea.Msg { return tickMsg(t) }) return tea.Tick(m.adaptivePollInterval(time.Now()), func(t time.Time) tea.Msg { return tickMsg(t) })
} }
@@ -416,6 +431,7 @@ func (m *App) startReply() {
} }
m.writeMode, m.writeThreadID, m.replyDraft, m.err = writeReply, thread.ID, "", nil m.writeMode, m.writeThreadID, m.replyDraft, m.err = writeReply, thread.ID, "", nil
m.restoreReplyDraft(thread.ID) m.restoreReplyDraft(thread.ID)
m.resetInputEditor(&m.replyEditor, m.replyDraft)
m.folded[thread.ID] = false m.folded[thread.ID] = false
m.focusThreadDetail() m.focusThreadDetail()
m.scroll = m.detailMaxScroll() m.scroll = m.detailMaxScroll()
@@ -583,30 +599,28 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case writeReply: case writeReply:
switch k { switch k {
case "esc": case "esc":
m.writeMode, m.replyDraft, m.writeThreadID = writeNone, "", "" if m.replyEditor.Modal && m.replyEditor.Mode != textEditorNormal {
m.scroll = min(m.scroll, m.detailMaxScroll()) m.replyEditor.handleKeyAtWidth(key, true, m.threadInputWidth())
m.replyDraft = m.replyEditor.Text
} else {
m.writeMode, m.replyDraft, m.replyEditor, m.writeThreadID =
writeNone, "", textEditor{}, ""
m.scroll = min(m.scroll, m.detailMaxScroll())
}
case "ctrl+s": case "ctrl+s":
if strings.TrimSpace(m.replyDraft) == "" { if strings.TrimSpace(m.replyDraft) == "" {
m.err = errors.New("reply cannot be empty") m.err = errors.New("reply cannot be empty")
} else { } else {
m.writeMode, m.err = writeReplyConfirm, nil m.writeMode, m.err = writeReplyConfirm, nil
} }
case "enter":
m.replyDraft += "\n"
case "backspace":
runes := []rune(m.replyDraft)
if len(runes) > 0 {
m.replyDraft = string(runes[:len(runes)-1])
}
m.err = nil
default: default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { if m.replyEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) {
m.replyDraft += textInputKeyValue(key) m.replyDraft = m.replyEditor.Text
m.err = nil m.err = nil
} }
} }
if m.writeMode == writeReply { if m.writeMode == writeReply {
m.scroll = m.detailMaxScroll() m.ensureThreadInputCursorVisible()
return m, m.queueReplyDraft() return m, m.queueReplyDraft()
} }
case writeReplyConfirm: case writeReplyConfirm:
@@ -997,7 +1011,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if err := m.drafts.delete(draftKey); err != nil { if err := m.drafts.delete(draftKey); err != nil {
m.recordHealth("draft persistence", healthWarning, err.Error()) m.recordHealth("draft persistence", healthWarning, err.Error())
} }
m.replyDraft, m.writeThreadID = "", "" m.replyDraft, m.replyEditor, m.writeThreadID = "", textEditor{}, ""
m.err = nil m.err = nil
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
return m, m.difflet.setState(diffletSuccess) return m, m.difflet.setState(diffletSuccess)
@@ -1130,29 +1144,34 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
case "esc": case "esc":
m.searching, m.searchQuery = false, "" if m.searchEditor.Modal && m.searchEditor.Mode != textEditorNormal {
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.searchEditor.handleKey(key, false)
m.scroll = 0 m.searchQuery = m.searchEditor.Text
} else {
m.searching, m.searchQuery, m.searchEditor = false, "", textEditor{}
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
m.scroll = 0
}
case "enter": case "enter":
m.searching = false m.searching = false
case "up": case "up":
m.moveSearch(-1) m.moveSearch(-1)
case "down", "tab": case "down", "tab":
m.moveSearch(1) m.moveSearch(1)
case "backspace":
runes := []rune(m.searchQuery)
if len(runes) > 0 {
m.searchQuery = string(runes[:len(runes)-1])
m.selectBestSearchMatch()
}
case "ctrl+u": case "ctrl+u":
m.searchQuery = "" if !m.searchEditor.Modal || m.searchEditor.Mode == textEditorInsert {
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) m.resetSearchEditor("")
m.scroll = 0 m.searchQuery = ""
m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1)
m.scroll = 0
}
default: default:
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { before := m.searchEditor.Text
m.searchQuery += textInputKeyValue(key) if m.searchEditor.handleKey(key, false) {
m.selectBestSearchMatch() m.searchQuery = m.searchEditor.Text
if m.searchQuery != before {
m.selectBestSearchMatch()
}
} }
} }
return m, nil return m, nil
@@ -1205,12 +1224,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "F": case "F":
if m.screen == threadScreen { if m.screen == threadScreen {
m.searchQuery = "" m.searchQuery, m.searchEditor = "", textEditor{}
m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1) m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1)
} }
case "/": case "/":
if m.screen == threadScreen { if m.screen == threadScreen {
m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex
m.resetSearchEditor("")
m.focus, m.listHidden, m.scroll = threadListPane, false, 0 m.focus, m.listHidden, m.scroll = threadListPane, false, 0
} }
case "r": case "r":
@@ -2168,17 +2188,20 @@ func (m App) viewWritePopup() string {
"", "",
} }
lines = append(lines, renderTextInput( lines = append(lines, renderTextInput(
m.replyDraft, width-2, m.cursorOutput != nil, m.replyEditor, width-2, m.cursorOutput != nil,
)...) )...)
if m.err != nil { if m.err != nil {
lines = append(lines, "", badStyle.Render(m.err.Error())) lines = append(lines, "", badStyle.Render(m.err.Error()))
} }
lines = append(lines, "", dimStyle.Render(fmt.Sprintf( if m.editorMode != "vim" {
"%s newline • %s review • %s cancel", lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
primaryKeyLabel(m.keybindings.Input.Newline), "%s newline • %s review • %s %s",
primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Cancel), primaryKeyLabel(m.keybindings.Input.Submit),
))) primaryKeyLabel(m.keybindings.Input.Cancel),
m.inputCancelAction(),
)))
}
case writeReplyConfirm: case writeReplyConfirm:
lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""} lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""}
lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...) lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...)
@@ -2293,9 +2316,9 @@ func (m App) helpBindings() []helpBinding {
{keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"}, {keyLabel(m.keybindings.Input.Cancel), "Return to Normal mode or cancel the editor"},
{combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Move through the description by half a page"},
{combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next branch or user completion"}, {combinedKeyLabel(m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion), "Select the previous / next branch or user completion"},
{combinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), "Complete the selected branch or user"}, {keyLabel(m.keybindings.Input.Newline), "Complete the selected branch or user"},
} }
if m.prEditEditors[prEditBodyField].Modal { if m.editorMode == "vim" {
bindings = append(bindings, bindings = append(bindings,
helpBinding{combinedKeyLabel( helpBinding{combinedKeyLabel(
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down, m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
@@ -2312,7 +2335,7 @@ func (m App) helpBindings() []helpBinding {
), "Move to the line start, first non-blank, or line end"}, ), "Move to the line start, first non-blank, or line end"},
helpBinding{combinedKeyLabel( helpBinding{combinedKeyLabel(
m.keybindings.Vim.GoPrefix, m.keybindings.Navigation.Last, m.keybindings.Vim.GoPrefix, m.keybindings.Navigation.Last,
), "Move to the start / end of the description"}, ), "Move to the start / end of the active field"},
helpBinding{combinedKeyLabel( helpBinding{combinedKeyLabel(
m.keybindings.Vim.Insert, m.keybindings.Vim.Append, m.keybindings.Vim.Insert, m.keybindings.Vim.Append,
m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd, m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd,
@@ -2320,7 +2343,7 @@ func (m App) helpBindings() []helpBinding {
), "Enter Insert mode"}, ), "Enter Insert mode"},
helpBinding{combinedKeyLabel( helpBinding{combinedKeyLabel(
m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove, m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove,
), "Open a line below / above"}, ), "Open a description line below / above"},
helpBinding{combinedKeyLabel( helpBinding{combinedKeyLabel(
m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine, m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine,
), "Start character-wise / line-wise Visual mode"}, ), "Start character-wise / line-wise Visual mode"},
@@ -2343,7 +2366,7 @@ func (m App) helpBindings() []helpBinding {
helpBinding{combinedKeyLabel( helpBinding{combinedKeyLabel(
m.keybindings.Navigation.Left, m.keybindings.Navigation.Down, m.keybindings.Navigation.Left, m.keybindings.Navigation.Down,
m.keybindings.Navigation.Up, m.keybindings.Navigation.Right, m.keybindings.Navigation.Up, m.keybindings.Navigation.Right,
), "Move the description cursor"}, ), "Move the active field cursor"},
helpBinding{combinedKeyLabel( helpBinding{combinedKeyLabel(
m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd, m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd,
), "Move to the start / end of the current visual line"}, ), "Move to the start / end of the current visual line"},
@@ -2695,6 +2718,34 @@ func (m App) viewDashboardWithLines(lines []string) string {
return view return view
} }
func (m App) activeInputEditor() (textEditor, string, bool) {
if m.helpVisible {
return textEditor{}, "", false
}
switch {
case m.writeMode == writeReply:
return m.replyEditor, "REPLY", m.replyEditor.Modal
case m.aiMode == aiDiscussion:
return m.aiInputEditor, "AI DISCUSSION", m.aiInputEditor.Modal
case m.writeMode == writePREdit:
contexts := [...]string{
"EDIT TITLE", "EDIT TARGET BRANCH", "EDIT REVIEWERS",
"EDIT ASSIGNEES", "EDIT DESCRIPTION",
}
editor := m.prEditEditors[m.prEditField]
return editor, contexts[m.prEditField], editor.Modal
default:
return textEditor{}, "", false
}
}
func (m App) inputCancelAction() string {
if m.editorMode == "vim" {
return "normal/cancel"
}
return "cancel"
}
func (m App) dashboardDisplayLines() []string { func (m App) dashboardDisplayLines() []string {
mascot := m.difflet.frameLines() mascot := m.difflet.frameLines()
if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() { if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() {
@@ -3488,18 +3539,27 @@ func (m App) viewThreads() string {
primaryKeyLabel(m.keybindings.Input.Cancel), primaryKeyLabel(m.keybindings.Input.Cancel),
) )
} else if m.writeMode == writeReply { } else if m.writeMode == writeReply {
help = fmt.Sprintf( if m.editorMode == "vim" {
"reply inline • %s newline • %s review • %s cancel", help = fmt.Sprintf(
primaryKeyLabel(m.keybindings.Input.Newline), "%s review • %s normal/cancel",
primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel), primaryKeyLabel(m.keybindings.Input.Cancel),
) )
} else {
help = fmt.Sprintf(
"reply inline • arrows move • %s newline • %s review • %s cancel",
primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel),
)
}
} else if m.aiMode == aiDiscussion { } else if m.aiMode == aiDiscussion {
help = fmt.Sprintf( help = fmt.Sprintf(
"local AI discussion • %s newline • %s prepare • %s cancel", "local AI discussion • arrows move • %s newline • %s prepare • %s %s",
primaryKeyLabel(m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Submit),
primaryKeyLabel(m.keybindings.Input.Cancel), primaryKeyLabel(m.keybindings.Input.Cancel),
m.inputCancelAction(),
) )
} }
m.positionThreadInputHardwareCursor() m.positionThreadInputHardwareCursor()
@@ -3520,9 +3580,8 @@ func (m App) threadList(width, height int) string {
lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d/%d)", len(matches), len(m.details.Threads)))} lines := []string{titleStyle.Render(fmt.Sprintf("Threads (%d/%d)", len(matches), len(m.details.Threads)))}
if m.searching { if m.searching {
queryWidth := max(1, innerWidth-len("Filter: ")-1) queryWidth := max(1, innerWidth-len("Filter: ")-1)
query := ansi.Truncate(m.searchQuery, queryWidth, "…") query := renderSingleLineInput(m.searchEditor, queryWidth, m.cursorOutput != nil)
lines = append(lines, titleStyle.Render("Filter: ")+query+ lines = append(lines, titleStyle.Render("Filter: ")+query)
inputCursorFallback(m.cursorOutput != nil))
} }
if m.loading && len(m.details.Threads) == 0 { if m.loading && len(m.details.Threads) == 0 {
lines = append(lines, "Loading…") lines = append(lines, "Loading…")
@@ -3839,7 +3898,9 @@ func (m App) inlineReplyLines(width int) []detailLine {
} }
textWidth := max(1, width-5) textWidth := max(1, width-5)
lineIndex := 0 lineIndex := 0
for _, part := range renderTextInput(m.replyDraft, textWidth, m.cursorOutput != nil) { for _, part := range renderTextInput(
m.replyEditor, textWidth, m.cursorOutput != nil,
) {
lines = append(lines, detailLine{ lines = append(lines, detailLine{
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part, rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
}) })
@@ -3848,48 +3909,72 @@ func (m App) inlineReplyLines(width int) []detailLine {
if m.err != nil { if m.err != nil {
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())}) lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
} }
lines = append(lines, detailLine{ if m.editorMode != "vim" {
rail: rail, lines = append(lines, detailLine{
text: dimStyle.Render(fmt.Sprintf( rail: rail,
"%s newline • %s review • %s cancel", text: dimStyle.Render(fmt.Sprintf(
primaryKeyLabel(m.keybindings.Input.Newline), "%s newline • %s review • %s %s",
primaryKeyLabel(m.keybindings.Input.Submit), primaryKeyLabel(m.keybindings.Input.Newline),
primaryKeyLabel(m.keybindings.Input.Cancel), primaryKeyLabel(m.keybindings.Input.Submit),
)), primaryKeyLabel(m.keybindings.Input.Cancel),
}) m.inputCancelAction(),
)),
})
}
return lines return lines
} }
func inputCursorFallback(hardwareCursor bool) string { func renderTextInput(editor textEditor, width int, hardwareCursor bool) []string {
if hardwareCursor { editor.hardwareCursor = hardwareCursor
return "" rendered := renderTextEditor(editor, width, true)
lines := make([]string, 0, len(rendered))
for _, line := range rendered {
lines = append(lines, line.text)
} }
return "\x1b[4m \x1b[24m" return lines
} }
func renderTextInput(value string, width int, hardwareCursor bool) []string { func renderSingleLineInput(editor textEditor, width int, hardwareCursor bool) string {
const cursorSentinel = "\ue000" editor.hardwareCursor = hardwareCursor
if hardwareCursor { rendered := renderTextEditor(editor, width, true)
value += cursorSentinel return rendered[editorCursorVisualLine(editor, width)].text
} else { }
value += inputCursorFallback(false)
func (m App) threadInputWidth() int {
width, _ := m.detailPaneSize()
return max(1, width-5)
}
func (m *App) ensureThreadInputCursorVisible() {
if m.writeMode != writeReply && m.aiMode != aiDiscussion {
return
} }
var lines []string width, height := m.detailPaneSize()
for _, sourceLine := range strings.Split(value, "\n") { editor, prefix := m.replyEditor, "reply:body:"
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width, ""), width, false) if m.aiMode == aiDiscussion {
if hardwareCursor { editor, prefix = m.aiInputEditor, "ai-discussion:body:"
wrapped = strings.TrimSuffix(wrapped, cursorSentinel) }
visualLine := editorCursorVisualLine(editor, m.threadInputWidth())
wantedAnchor := fmt.Sprintf("%s%d", prefix, visualLine)
lines := m.renderedDetailLines(width)
cursorLine := -1
for index := range lines {
if lines[index].anchor == wantedAnchor {
cursorLine = index
break
} }
lines = append(lines, strings.Split(wrapped, "\n")...)
} }
return lines if cursorLine < 0 {
} return
func textInputKeyValue(key tea.KeyMsg) string {
if key.Type == tea.KeySpace {
return " "
} }
return string(key.Runes) viewportHeight := max(1, height-2)
switch {
case cursorLine < m.scroll:
m.scroll = cursorLine
case cursorLine >= m.scroll+viewportHeight:
m.scroll = cursorLine - viewportHeight + 1
}
m.scroll = clamp(m.scroll, 0, max(0, len(lines)-viewportHeight))
} }
func (m App) positionThreadInputHardwareCursor() { func (m App) positionThreadInputHardwareCursor() {
@@ -3906,10 +3991,13 @@ func (m App) positionThreadInputHardwareCursor() {
paneWidth = m.threadListWidth() paneWidth = m.threadListWidth()
} }
queryWidth := max(1, max(1, paneWidth-2)-len("Filter: ")-1) queryWidth := max(1, max(1, paneWidth-2)-len("Filter: ")-1)
query := ansi.Truncate(m.searchQuery, queryWidth, "…") if m.searchEditor.Mode != textEditorInsert {
return
}
_, column := editorCursorVisualPosition(m.searchEditor, queryWidth)
m.cursorOutput.SetCursor( m.cursorOutput.SetCursor(
true, true,
2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query), 2+ansi.StringWidth("Filter: ")+column,
m.contentTop+topLines+3, m.contentTop+topLines+3,
) )
return return
@@ -3923,10 +4011,20 @@ func (m App) positionThreadInputHardwareCursor() {
if m.aiMode == aiDiscussion { if m.aiMode == aiDiscussion {
prefix = "ai-discussion:body:" prefix = "ai-discussion:body:"
} }
editor := m.replyEditor
if m.aiMode == aiDiscussion {
editor = m.aiInputEditor
}
if editor.Mode != textEditorInsert {
return
}
visualLine, column := editorCursorVisualPosition(editor, m.threadInputWidth())
cursorLine := -1 cursorLine := -1
wantedAnchor := fmt.Sprintf("%s%d", prefix, visualLine)
for index := range lines { for index := range lines {
if strings.HasPrefix(lines[index].anchor, prefix) { if lines[index].anchor == wantedAnchor {
cursorLine = index cursorLine = index
break
} }
} }
if cursorLine < 0 { if cursorLine < 0 {
@@ -3945,7 +4043,7 @@ func (m App) positionThreadInputHardwareCursor() {
line := lines[cursorLine] line := lines[cursorLine]
m.cursorOutput.SetCursor( m.cursorOutput.SetCursor(
true, true,
paneStart+2+ansi.StringWidth(line.rail)+ansi.StringWidth(line.fixed+line.text), paneStart+2+ansi.StringWidth(line.rail+line.fixed)+column,
m.contentTop+topLines+2+screenLine, m.contentTop+topLines+2+screenLine,
) )
} }
@@ -4226,21 +4324,47 @@ func (m App) frame(lines []string, help string) string {
if status == "" && !m.lastRefresh.IsZero() { if status == "" && !m.lastRefresh.IsZero() {
status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05")) status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05"))
} }
footer := truncate(help, m.width) footerWidth := m.width
if status != "" { if status != "" {
footer = truncate(help, max(0, m.width-lipgloss.Width(status)-2)) + " " + status footerWidth = max(0, m.width-lipgloss.Width(status)-2)
}
footer := m.renderFooter(help, footerWidth)
if status != "" {
footer += " " + status
} }
bodyLines := strings.Split(body, "\n") bodyLines := strings.Split(body, "\n")
if len(bodyLines) > max(0, m.height-1) { bodyHeight := max(0, m.height-1)
bodyLines = bodyLines[:max(0, m.height-1)] if len(bodyLines) > bodyHeight {
bodyLines = bodyLines[:bodyHeight]
} }
for i := range bodyLines { for i := range bodyLines {
bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "") bodyLines[i] = ansi.Truncate(bodyLines[i], m.width, "")
} }
bodyLines = append(bodyLines, ansi.Truncate(dimStyle.Render(footer), m.width, "")) for len(bodyLines) < bodyHeight {
bodyLines = append(bodyLines, "")
}
bodyLines = append(bodyLines, ansi.Truncate(footer, m.width, ""))
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n")) return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(bodyLines, "\n"))
} }
func (m App) renderFooter(help string, width int) string {
if width <= 0 {
return ""
}
editor, context, ok := m.activeInputEditor()
if !ok {
return dimStyle.Render(truncate(help, width))
}
mode := activeStyle.Render(" " + editor.modeLabel() + " ")
contextLabel := titleStyle.Render(context)
prefix := mode + " " + contextLabel
remaining := width - lipgloss.Width(prefix)
if remaining <= 2 {
return ansi.Truncate(prefix, width, "")
}
return prefix + " " + dimStyle.Render(truncate(help, remaining-2))
}
func (m App) restingDiffletState() diffletState { func (m App) restingDiffletState() diffletState {
if m.err != nil { if m.err != nil {
return diffletSad return diffletSad

View File

@@ -701,13 +701,16 @@ func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) {
send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(runeKey("i"))
send(tea.KeyMsg{Type: tea.KeyHome}) send(tea.KeyMsg{Type: tea.KeyHome})
for range len("main") { for range len("main") {
send(tea.KeyMsg{Type: tea.KeyDelete}) send(tea.KeyMsg{Type: tea.KeyDelete})
} }
send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")}) send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")})
send(tea.KeyMsg{Type: tea.KeyEsc})
send(tea.KeyMsg{Type: tea.KeyShiftTab}) send(tea.KeyMsg{Type: tea.KeyShiftTab})
send(runeKey("i"))
send(tea.KeyMsg{Type: tea.KeyHome}) send(tea.KeyMsg{Type: tea.KeyHome})
for range len("Old title") { for range len("Old title") {
send(tea.KeyMsg{Type: tea.KeyDelete}) send(tea.KeyMsg{Type: tea.KeyDelete})
@@ -794,9 +797,95 @@ func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) {
Permissions: ViewerPermissions{CanUpdatePR: true}, Permissions: ViewerPermissions{CanUpdatePR: true},
} }
m.startPREdit() m.startPREdit()
editor := m.prEditEditors[prEditBodyField] for field, editor := range m.prEditEditors {
if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune("body")) { if editor.Modal || editor.Mode != textEditorInsert ||
t.Fatalf("standard description editor = %#v", editor) editor.Cursor != len([]rune(editor.Text)) {
t.Fatalf("standard editor field %d = %#v", field, editor)
}
}
}
func TestVimModeAppliesToEveryTextInput(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.loading, m.width, m.height = false, 80, 24
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "body", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
for field, editor := range m.prEditEditors {
if !editor.Modal || editor.Mode != textEditorNormal {
t.Fatalf("Vim PR editor field %d = %#v", field, editor)
}
}
m.writeMode, m.replyDraft = writeReply, "reply"
m.resetInputEditor(&m.replyEditor, m.replyDraft)
updated, _ := m.updateWriteInput(runeKey("i"))
m = updated.(App)
updated, _ = m.updateWriteInput(runeKey("!"))
m = updated.(App)
updated, _ = m.updateWriteInput(tea.KeyMsg{Type: tea.KeyEsc})
m = updated.(App)
if m.replyDraft != "!reply" || m.replyEditor.Mode != textEditorNormal {
t.Fatalf("Vim reply = %q mode=%s", m.replyDraft, m.replyEditor.Mode)
}
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "ask"
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
updated, _, handled := m.updateAI(runeKey("A"))
m = updated.(App)
if !handled || m.aiInputEditor.Mode != textEditorInsert ||
m.aiInputEditor.Cursor != len([]rune("ask")) {
t.Fatalf("Vim AI append handled=%v editor=%#v", handled, m.aiInputEditor)
}
m.aiMode = aiNone
m.screen, m.loading = threadScreen, false
m.details.Threads = []ReviewThread{{ID: "thread", Path: "main.go"}}
updated, _ = m.Update(runeKey("/"))
m = updated.(App)
if !m.searching || m.searchEditor.Modal || m.searchEditor.Mode != textEditorInsert {
t.Fatalf("insert-only search editor = %#v searching=%v", m.searchEditor, m.searching)
}
updated, _ = m.Update(runeKey("main"))
m = updated.(App)
if m.searchQuery != "main" || m.searchEditor.Mode != textEditorInsert {
t.Fatalf("Vim search query=%q editor=%#v", m.searchQuery, m.searchEditor)
}
}
func TestVimModeUsesGlobalFooterBar(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, time.Second)
m.width, m.height = 80, 10
m.writeMode, m.replyDraft = writeReply, "reply"
m.resetInputEditor(&m.replyEditor, m.replyDraft)
footer := ansi.Strip(m.renderFooter("ctrl+s review • esc normal/cancel", m.width))
if !strings.Contains(footer, "NORMAL") || !strings.Contains(footer, "REPLY") {
t.Fatalf("Normal reply footer = %q", footer)
}
m.replyEditor.Mode = textEditorInsert
footer = ansi.Strip(m.renderFooter("ctrl+s review • esc normal/cancel", m.width))
if !strings.Contains(footer, "INSERT") || strings.Contains(footer, "NORMAL") {
t.Fatalf("Insert reply footer = %q", footer)
}
}
func TestDashboardVimModeBarStaysOnBottomRow(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.screen, m.loading, m.width, m.height = dashboardScreen, false, 80, 24
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Title: "Title"},
Body: "short", BaseRef: "main",
Permissions: ViewerPermissions{CanUpdatePR: true},
}
m.startPREdit()
lines := strings.Split(ansi.Strip(m.viewDashboard()), "\n")
if len(lines) != m.height || !strings.Contains(lines[len(lines)-1], "NORMAL") ||
!strings.Contains(lines[len(lines)-1], "EDIT DESCRIPTION") {
t.Fatalf("dashboard mode bar is not on bottom row:\n%s", strings.Join(lines, "\n"))
} }
} }
@@ -1046,7 +1135,11 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) {
} }
defer file.Close() defer file.Close()
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(
&recordingPRService{}, "o", "r", false, 50, time.Second, settings,
)
m.cursorOutput = newTerminalCursorOutput(file) m.cursorOutput = newTerminalCursorOutput(file)
m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24 m.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24
m.details = PRDetails{ m.details = PRDetails{
@@ -1058,6 +1151,7 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) {
} }
m.searching, m.searchQuery = true, "main" m.searching, m.searchQuery = true, "main"
m.resetInputEditor(&m.searchEditor, m.searchQuery)
_ = m.viewThreads() _ = m.viewThreads()
m.cursorOutput.mu.Lock() m.cursorOutput.mu.Lock()
searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column
@@ -1068,6 +1162,7 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) {
m.searching = false m.searching = false
m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question" m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question"
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
m.focus = threadDetailPane m.focus = threadDetailPane
m.scroll = m.detailMaxScroll() m.scroll = m.detailMaxScroll()
_ = m.viewThreads() _ = m.viewThreads()
@@ -1140,6 +1235,8 @@ func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) {
if m.writeMode != writeReply { if m.writeMode != writeReply {
t.Fatalf("reply key opened mode %d", m.writeMode) t.Fatalf("reply key opened mode %d", m.writeMode)
} }
updated, _ = m.Update(runeKey("i"))
m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")}) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")})
m = updated.(App) m = updated.(App)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
@@ -1183,6 +1280,13 @@ func TestReplyComposerRendersInlineWithCurrentThread(t *testing.T) {
t.Fatalf("inline reply view is missing %q:\n%s", wanted, plain) t.Fatalf("inline reply view is missing %q:\n%s", wanted, plain)
} }
} }
inline := m.inlineReplyLines(80)
for _, line := range inline {
if strings.Contains(ansi.Strip(line.text), "newline") ||
strings.Contains(ansi.Strip(line.text), "review") {
t.Fatalf("Vim reply contains redundant inline key help: %#v", inline)
}
}
if m.focus != threadDetailPane { if m.focus != threadDetailPane {
t.Fatal("inline reply did not focus the thread detail pane") t.Fatal("inline reply did not focus the thread detail pane")
} }
@@ -1873,8 +1977,11 @@ func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) {
t.Fatalf("query matched despite a missing term") t.Fatalf("query matched despite a missing term")
} }
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.screen, m.searching, m.searchQuery = threadScreen, true, "ng" m.screen, m.searching, m.searchQuery = threadScreen, true, "ng"
m.resetInputEditor(&m.searchEditor, m.searchQuery)
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace}) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
if got := updated.(App).searchQuery; got != "ng " { if got := updated.(App).searchQuery; got != "ng " {
t.Fatalf("space key produced search query %q", got) t.Fatalf("space key produced search query %q", got)
@@ -1882,8 +1989,11 @@ func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(t *testing.T) {
} }
func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) { func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.writeMode, m.replyDraft = writeReply, "reply" m.writeMode, m.replyDraft = writeReply, "reply"
m.resetInputEditor(&m.replyEditor, m.replyDraft)
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace}) updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(App) m = updated.(App)
if m.replyDraft != "reply " { if m.replyDraft != "reply " {
@@ -1891,6 +2001,7 @@ func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
} }
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question" m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question"
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace}) updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(App) m = updated.(App)
if !handled || m.aiInput != "question " { if !handled || m.aiInput != "question " {
@@ -1898,6 +2009,38 @@ func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) {
} }
} }
func TestReplyAndAIDiscussionArrowKeysMoveInputCursor(t *testing.T) {
settings := defaultAppSettings()
settings.EditorMode = "standard"
m := NewAppWithSettings(nil, "o", "r", false, 50, 10*time.Second, settings)
m.width, m.height = 80, 24
m.writeMode, m.replyDraft = writeReply, "mistke"
m.resetInputEditor(&m.replyEditor, m.replyDraft)
for range 2 {
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeyLeft})
m = updated.(App)
}
updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")})
m = updated.(App)
if m.replyDraft != "mistake" || m.replyEditor.Cursor != 5 {
t.Fatalf("reply after cursor edit = %q at %d", m.replyDraft, m.replyEditor.Cursor)
}
m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "abc\ndef"
m.resetInputEditor(&m.aiInputEditor, m.aiInput)
m.aiInputEditor.Cursor = 1
updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(App)
if !handled || m.aiInputEditor.Cursor != 5 {
t.Fatalf("AI down movement handled=%v cursor=%d, want 5", handled, m.aiInputEditor.Cursor)
}
updated, _, handled = m.updateAI(tea.KeyMsg{Type: tea.KeyRight})
m = updated.(App)
if !handled || m.aiInputEditor.Cursor != 6 {
t.Fatalf("AI right movement handled=%v cursor=%d, want 6", handled, m.aiInputEditor.Cursor)
}
}
func TestFileSearchCanJumpOrCancel(t *testing.T) { func TestFileSearchCanJumpOrCancel(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen m.screen = threadScreen

View File

@@ -315,7 +315,7 @@ func (m App) userCompletionLines(width int) []string {
m.keybindings.Input.PreviousCompletion, m.keybindings.Input.PreviousCompletion,
m.keybindings.Input.NextCompletion, m.keybindings.Input.NextCompletion,
), ),
primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), primaryKeyLabel(m.keybindings.Input.Newline),
)), )),
) )
current := m.prEditOriginal.Assignees current := m.prEditOriginal.Assignees

View File

@@ -50,6 +50,36 @@ func TestReviewerCompletionSupportsMultipleEligibleUsers(t *testing.T) {
} }
} }
func TestReviewerCompletionUsesEnterAndTabLeavesField(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.writeMode = writePREdit
m.prEditField = prEditReviewersField
m.details = PRDetails{PullRequest: PullRequest{Author: "author"}}
m.prEditUsers = []RepositoryUser{{Login: "alice", CanReview: true}}
m.prEditEditors[prEditReviewersField] = newTextEditor("ali", false)
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(App)
if m.prEditField != prEditAssigneesField ||
m.prEditEditors[prEditReviewersField].Text != "ali" {
t.Fatalf(
"tab field=%d reviewers=%q",
m.prEditField, m.prEditEditors[prEditReviewersField].Text,
)
}
m.prEditField = prEditReviewersField
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(App)
if m.prEditField != prEditReviewersField ||
m.prEditEditors[prEditReviewersField].Text != "alice" {
t.Fatalf(
"enter field=%d reviewers=%q",
m.prEditField, m.prEditEditors[prEditReviewersField].Text,
)
}
}
func TestReviewerInputCommitsMultipleEligibleUsersWithSpace(t *testing.T) { func TestReviewerInputCommitsMultipleEligibleUsersWithSpace(t *testing.T) {
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
m.writeMode = writePREdit m.writeMode = writePREdit

View File

@@ -1,3 +1,3 @@
package main package main
const dipleVersion = "0.2.0" const dipleVersion = "0.3.0"