diff --git a/README.md b/README.md index f743b59..d442ff9 100644 --- a/README.md +++ b/README.md @@ -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 enabled, terminals commonly require holding Shift while selecting text. -The PR description editor defaults to Vim-style modal editing, including -Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard -yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a -non-modal editor. Target-branch, reviewer, and assignee completion use -`ctrl+n` and `ctrl+p`; reviewer and assignee fields accept comma-separated -GitHub usernames. Pending individual review requests and assignees are +Editable text fields default to Vim-style modal editing, including PR metadata, +reply, and local-AI discussion fields. They support Normal, Insert, and Visual +modes, word/find motions, deletion, system clipboard yank/paste, and +soft-wrap-aware movement. The active mode and input are shown in a Neovim-style +footer bar. Set `editing.mode = "standard"` for non-modal inputs with arrow-key +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 review, and requested teams, appear first as protected subdued tokens in the reviewer field. Their handles retain a darker version of their deterministic diff --git a/ai_tui.go b/ai_tui.go index 8a272de..fce3529 100644 --- a/ai_tui.go +++ b/ai_tui.go @@ -70,6 +70,7 @@ func (m *App) startAIDiscussion(threadID string) { return } m.aiMode, m.aiInput, m.writeThreadID, m.err = aiDiscussion, "", threadID, nil + m.resetInputEditor(&m.aiInputEditor, "") if thread := m.threadByID(threadID); thread != nil { m.folded[threadID] = false m.focus = threadDetailPane @@ -298,12 +299,20 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) { if m.aiMode != aiDiscussion { 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 m.aiCancel != nil { m.aiCancel() m.aiCancel = nil } m.aiMode, m.aiInput, m.writeThreadID, m.aiEvents = aiNone, "", "", nil + m.aiInputEditor = textEditor{} return m, nil, true } @@ -351,19 +360,12 @@ func (m App) updateAI(msg tea.Msg) (tea.Model, tea.Cmd, bool) { } else { 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: - if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { - m.aiInput += textInputKeyValue(key) + if m.aiInputEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) { + m.aiInput = m.aiInputEditor.Text } } - m.scroll = m.detailMaxScroll() + m.ensureThreadInputCursorVisible() case aiConfirm: switch { case keyMatches(raw, m.keybindings.General.Confirm): @@ -461,16 +463,17 @@ func (m App) viewAI() string { lines = append(lines, titleStyle.Render("Local AI discussion"), "", dimStyle.Render("This message stays local; only the configured model receives it."), "") lines = append(lines, renderTextInput( - m.aiInput, width-2, m.cursorOutput != nil, + m.aiInputEditor, width-2, m.cursorOutput != nil, )...) if m.err != nil { lines = append(lines, "", badStyle.Render(m.err.Error())) } 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.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), + m.inputCancelAction(), ))) case aiPreparing: lines = m.aiProgressLines(width) @@ -666,7 +669,9 @@ func (m App) inlineAIDiscussionLines(width int) []detailLine { } textWidth := max(1, width-5) 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{ 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{ rail: rail, 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.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), + m.inputCancelAction(), )), }) return lines diff --git a/branch_completion.go b/branch_completion.go index 541cb8a..0a127ca 100644 --- a/branch_completion.go +++ b/branch_completion.go @@ -178,13 +178,12 @@ func (m App) branchCompletionLines(width int) []string { return []string{dimStyle.Render(" no matching repository branches")} } lines := []string{dimStyle.Render(fmt.Sprintf( - " %s choose • %s complete • %s again advances", + " %s choose • %s complete", primaryCombinedKeyLabel( m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion, ), - primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), - primaryKeyLabel(m.keybindings.Input.NextField), + primaryKeyLabel(m.keybindings.Input.Newline), ))} now := time.Now() for index, suggestion := range suggestions { diff --git a/branch_completion_test.go b/branch_completion_test.go index 9822ee2..8dee6f0 100644 --- a/branch_completion_test.go +++ b/branch_completion_test.go @@ -68,19 +68,23 @@ func TestTargetBranchCompletionIsKeyboardFirst(t *testing.T) { m = updated.(App) updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeyTab}) 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" && 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 { 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) { @@ -94,7 +98,7 @@ func TestTargetBranchSuggestionsRenderAndValidationRejectsUnknownBranch(t *testi m.prEditBranches = []RepositoryBranch{{Name: "main"}, {Name: "release/2.0"}} 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) } if err := m.validatePREdit(); err == nil || !strings.Contains(err.Error(), "not an available") { diff --git a/cli.go b/cli.go index a362ef9..f7dd4f1 100644 --- a/cli.go +++ b/cli.go @@ -210,7 +210,7 @@ _diple() { '--compact-reviews=[aggregate submitted reviews]:boolean:(true false)' \ '--path-scroll=[scroll truncated paths]:boolean:(true false)' \ '--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' \ '--cache=[enable local read cache]:boolean:(true false)' \ '--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 path-scroll -d 'Scroll truncated paths' 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 cache -d 'Enable local read cache' complete -c diple -l cache-max-age -x -d 'Maximum offline cache age' diff --git a/keybindings.go b/keybindings.go index ccdf894..ed19353 100644 --- a/keybindings.go +++ b/keybindings.go @@ -559,7 +559,12 @@ func validateKeyBindingContexts(bindings KeyBindings) error { contextBinding{"previous_completion", input.PreviousCompletion}, contextBinding{"next_completion", input.NextCompletion}, contextBinding{"delete_backward", input.DeleteBackward}, + contextBinding{"delete_forward", input.DeleteForward}, 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 { return err } @@ -569,6 +574,13 @@ func validateKeyBindingContexts(bindings KeyBindings) error { contextBinding{"submit", input.Submit}, contextBinding{"newline", input.Newline}, 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 { return err } @@ -658,6 +670,7 @@ func validateKeyBindingContexts(bindings KeyBindings) error { contextBinding{"selection_other_end", vim.SelectionOtherEnd}, contextBinding{"yank", vim.Yank}, contextBinding{"delete", vim.Delete}, + contextBinding{"substitute", vim.ReplaceCharacter}, contextBinding{"paste", vim.Paste}, contextBinding{"line_start", vim.LineStart}, contextBinding{"first_non_blank", vim.FirstNonBlank}, @@ -706,6 +719,8 @@ func validateKeyBindingContexts(bindings KeyBindings) error { contextBinding{"delete_forward", input.DeleteForward}, contextBinding{"line_start", input.LineStart}, contextBinding{"line_end", input.LineEnd}, + contextBinding{"left", nonTextBindings(navigation.Left)}, + contextBinding{"right", nonTextBindings(navigation.Right)}, contextBinding{"up", nonTextBindings(navigation.Up)}, contextBinding{"down", nonTextBindings(navigation.Down)}, ) diff --git a/main.go b/main.go index 330e427..c491c3e 100644 --- a/main.go +++ b/main.go @@ -52,7 +52,7 @@ func main() { 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") 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() if flag.NArg() != 0 { diff --git a/pr_editor.go b/pr_editor.go index b99cecf..d43eaa5 100644 --- a/pr_editor.go +++ b/pr_editor.go @@ -29,17 +29,18 @@ func (m *App) startPREdit() tea.Cmd { } m.writeMode = writePREdit m.prEditField = prEditBodyField - m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false) - m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false) + modal := m.editorMode == "vim" + m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, modal) + m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, modal) m.prEditEditors[prEditReviewersField] = newTextEditor( - strings.Join(m.details.RequestedReviewers, ", "), false, + strings.Join(m.details.RequestedReviewers, ", "), modal, ) m.prEditEditors[prEditAssigneesField] = newTextEditor( - strings.Join(m.details.Assignees, ", "), false, + strings.Join(m.details.Assignees, ", "), modal, ) m.prEditEditors[prEditBodyField] = newTextEditor( normalizeLineEndings(m.details.Body), - m.editorMode == "vim", + modal, ) m.prEditEditors[prEditBodyField].highlightMarkdown = true m.prEditOriginal = m.currentPRMetadata() @@ -168,7 +169,9 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { } 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() { m.ensurePREditCursorVisible() return m, m.queuePREditDraft() @@ -195,13 +198,7 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } case "tab": - completed := m.prEditField == prEditBaseField && m.completeBranchSuggestion() - if isPREditPeopleField(m.prEditField) { - completed = m.completeUserSuggestion() - } - if !completed { - m.movePREditField(1) - } + m.movePREditField(1) case "shift+tab": m.movePREditField(-1) case "ctrl+n": @@ -548,7 +545,6 @@ func (m App) dashboardEditLayout() ([]string, int) { func (m App) prEditFieldLines(label string, field, width int) []string { active := m.prEditField == field - editor := m.prEditEditors[field] if field == prEditReviewersField { label += " (pending requests editable)" } @@ -556,10 +552,6 @@ func (m App) prEditFieldLines(label string, field, width int) []string { if active { prefix = "▶ " } - mode := editor.modeLabel() - if mode != "" { - label += " [" + mode + "]" - } labelLine := dimStyle.Render(prefix + label) if active { labelLine = titleStyle.Render(prefix + label) diff --git a/text_editor.go b/text_editor.go index baa5904..6c4e9ad 100644 --- a/text_editor.go +++ b/text_editor.go @@ -306,6 +306,8 @@ func (e *textEditor) handleVisualKey(key tea.KeyMsg, multiline bool, wrapWidth i e.yankSelection(wrapWidth) case keyMatches(k, e.keys.Vim.Delete): e.deleteSelection(wrapWidth) + case keyMatches(k, e.keys.Vim.ReplaceCharacter): + e.substituteSelection(wrapWidth) case keyMatches(k, e.keys.Vim.Paste): e.pasteClipboard(true, wrapWidth) default: @@ -407,6 +409,20 @@ func (e *textEditor) deleteSelection(wrapWidth int) { 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) { if e.clipboard == nil { e.clipboard = systemTextClipboard{} diff --git a/text_editor_test.go b/text_editor_test.go index 3dedf33..ed2e5b2 100644 --- a/text_editor_test.go +++ b/text_editor_test.go @@ -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) { clipboard := &memoryTextClipboard{} editor := newTextEditor("abcdef", true) diff --git a/tui.go b/tui.go index c33cde7..d9d8bc5 100644 --- a/tui.go +++ b/tui.go @@ -148,12 +148,14 @@ type App struct { pendingZ bool searching bool searchQuery string + searchEditor textEditor searchOrigin int helpVisible bool helpScroll int writeMode writeMode writeThreadID string replyDraft string + replyEditor textEditor resolveTarget bool autoMergeTarget bool mergeMethod string @@ -197,6 +199,7 @@ type App struct { aiMode aiMode aiMenuIndex int aiInput string + aiInputEditor textEditor aiPreview AIPreview aiCancel context.CancelFunc aiStatus AIProviderStatus @@ -298,6 +301,18 @@ func (m App) Init() tea.Cmd { 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 { 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.restoreReplyDraft(thread.ID) + m.resetInputEditor(&m.replyEditor, m.replyDraft) m.folded[thread.ID] = false m.focusThreadDetail() m.scroll = m.detailMaxScroll() @@ -583,30 +599,28 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { case writeReply: switch k { case "esc": - m.writeMode, m.replyDraft, m.writeThreadID = writeNone, "", "" - m.scroll = min(m.scroll, m.detailMaxScroll()) + if m.replyEditor.Modal && m.replyEditor.Mode != textEditorNormal { + 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": if strings.TrimSpace(m.replyDraft) == "" { m.err = errors.New("reply cannot be empty") } else { 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: - if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { - m.replyDraft += textInputKeyValue(key) + if m.replyEditor.handleKeyAtWidth(key, true, m.threadInputWidth()) { + m.replyDraft = m.replyEditor.Text m.err = nil } } if m.writeMode == writeReply { - m.scroll = m.detailMaxScroll() + m.ensureThreadInputCursorVisible() return m, m.queueReplyDraft() } 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 { m.recordHealth("draft persistence", healthWarning, err.Error()) } - m.replyDraft, m.writeThreadID = "", "" + m.replyDraft, m.replyEditor, m.writeThreadID = "", textEditor{}, "" m.err = nil m.lastRefresh = time.Now() return m, m.difflet.setState(diffletSuccess) @@ -1130,29 +1144,34 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+c": return m, tea.Quit case "esc": - m.searching, m.searchQuery = false, "" - m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) - m.scroll = 0 + if m.searchEditor.Modal && m.searchEditor.Mode != textEditorNormal { + m.searchEditor.handleKey(key, false) + 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": m.searching = false case "up": m.moveSearch(-1) case "down", "tab": 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": - m.searchQuery = "" - m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) - m.scroll = 0 + if !m.searchEditor.Modal || m.searchEditor.Mode == textEditorInsert { + m.resetSearchEditor("") + m.searchQuery = "" + m.threadIndex = clamp(m.searchOrigin, 0, len(m.details.Threads)-1) + m.scroll = 0 + } default: - if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { - m.searchQuery += textInputKeyValue(key) - m.selectBestSearchMatch() + before := m.searchEditor.Text + if m.searchEditor.handleKey(key, false) { + m.searchQuery = m.searchEditor.Text + if m.searchQuery != before { + m.selectBestSearchMatch() + } } } return m, nil @@ -1205,12 +1224,13 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case "F": if m.screen == threadScreen { - m.searchQuery = "" + m.searchQuery, m.searchEditor = "", textEditor{} m.threadIndex = clamp(m.threadIndex, 0, len(m.details.Threads)-1) } case "/": if m.screen == threadScreen { m.searching, m.searchQuery, m.searchOrigin = true, "", m.threadIndex + m.resetSearchEditor("") m.focus, m.listHidden, m.scroll = threadListPane, false, 0 } case "r": @@ -2168,17 +2188,20 @@ func (m App) viewWritePopup() string { "", } lines = append(lines, renderTextInput( - m.replyDraft, width-2, m.cursorOutput != nil, + m.replyEditor, width-2, m.cursorOutput != nil, )...) if m.err != nil { lines = append(lines, "", badStyle.Render(m.err.Error())) } - lines = append(lines, "", dimStyle.Render(fmt.Sprintf( - "%s newline • %s review • %s cancel", - primaryKeyLabel(m.keybindings.Input.Newline), - primaryKeyLabel(m.keybindings.Input.Submit), - primaryKeyLabel(m.keybindings.Input.Cancel), - ))) + if m.editorMode != "vim" { + lines = append(lines, "", dimStyle.Render(fmt.Sprintf( + "%s newline • %s review • %s %s", + primaryKeyLabel(m.keybindings.Input.Newline), + primaryKeyLabel(m.keybindings.Input.Submit), + primaryKeyLabel(m.keybindings.Input.Cancel), + m.inputCancelAction(), + ))) + } case writeReplyConfirm: lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""} 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"}, {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.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, helpBinding{combinedKeyLabel( 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"}, helpBinding{combinedKeyLabel( 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( m.keybindings.Vim.Insert, m.keybindings.Vim.Append, m.keybindings.Vim.InsertLineStart, m.keybindings.Vim.AppendLineEnd, @@ -2320,7 +2343,7 @@ func (m App) helpBindings() []helpBinding { ), "Enter Insert mode"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.OpenBelow, m.keybindings.Vim.OpenAbove, - ), "Open a line below / above"}, + ), "Open a description line below / above"}, helpBinding{combinedKeyLabel( m.keybindings.Vim.Visual, m.keybindings.Vim.VisualLine, ), "Start character-wise / line-wise Visual mode"}, @@ -2343,7 +2366,7 @@ func (m App) helpBindings() []helpBinding { helpBinding{combinedKeyLabel( m.keybindings.Navigation.Left, m.keybindings.Navigation.Down, m.keybindings.Navigation.Up, m.keybindings.Navigation.Right, - ), "Move the description cursor"}, + ), "Move the active field cursor"}, helpBinding{combinedKeyLabel( m.keybindings.Input.LineStart, m.keybindings.Input.LineEnd, ), "Move to the start / end of the current visual line"}, @@ -2695,6 +2718,34 @@ func (m App) viewDashboardWithLines(lines []string) string { 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 { mascot := m.difflet.frameLines() if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() { @@ -3488,18 +3539,27 @@ func (m App) viewThreads() string { primaryKeyLabel(m.keybindings.Input.Cancel), ) } else if m.writeMode == writeReply { - help = fmt.Sprintf( - "reply inline • %s newline • %s review • %s cancel", - primaryKeyLabel(m.keybindings.Input.Newline), - primaryKeyLabel(m.keybindings.Input.Submit), - primaryKeyLabel(m.keybindings.Input.Cancel), - ) + if m.editorMode == "vim" { + help = fmt.Sprintf( + "%s review • %s normal/cancel", + primaryKeyLabel(m.keybindings.Input.Submit), + 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 { 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.Submit), primaryKeyLabel(m.keybindings.Input.Cancel), + m.inputCancelAction(), ) } 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)))} if m.searching { queryWidth := max(1, innerWidth-len("Filter: ")-1) - query := ansi.Truncate(m.searchQuery, queryWidth, "…") - lines = append(lines, titleStyle.Render("Filter: ")+query+ - inputCursorFallback(m.cursorOutput != nil)) + query := renderSingleLineInput(m.searchEditor, queryWidth, m.cursorOutput != nil) + lines = append(lines, titleStyle.Render("Filter: ")+query) } if m.loading && len(m.details.Threads) == 0 { lines = append(lines, "Loading…") @@ -3839,7 +3898,9 @@ func (m App) inlineReplyLines(width int) []detailLine { } textWidth := max(1, width-5) 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{ 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 { lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())}) } - lines = append(lines, detailLine{ - rail: rail, - text: dimStyle.Render(fmt.Sprintf( - "%s newline • %s review • %s cancel", - primaryKeyLabel(m.keybindings.Input.Newline), - primaryKeyLabel(m.keybindings.Input.Submit), - primaryKeyLabel(m.keybindings.Input.Cancel), - )), - }) + if m.editorMode != "vim" { + lines = append(lines, detailLine{ + rail: rail, + text: dimStyle.Render(fmt.Sprintf( + "%s newline • %s review • %s %s", + primaryKeyLabel(m.keybindings.Input.Newline), + primaryKeyLabel(m.keybindings.Input.Submit), + primaryKeyLabel(m.keybindings.Input.Cancel), + m.inputCancelAction(), + )), + }) + } return lines } -func inputCursorFallback(hardwareCursor bool) string { - if hardwareCursor { - return "" +func renderTextInput(editor textEditor, width int, hardwareCursor bool) []string { + editor.hardwareCursor = hardwareCursor + 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 { - const cursorSentinel = "\ue000" - if hardwareCursor { - value += cursorSentinel - } else { - value += inputCursorFallback(false) +func renderSingleLineInput(editor textEditor, width int, hardwareCursor bool) string { + editor.hardwareCursor = hardwareCursor + rendered := renderTextEditor(editor, width, true) + return rendered[editorCursorVisualLine(editor, width)].text +} + +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 - for _, sourceLine := range strings.Split(value, "\n") { - wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width, ""), width, false) - if hardwareCursor { - wrapped = strings.TrimSuffix(wrapped, cursorSentinel) + width, height := m.detailPaneSize() + editor, prefix := m.replyEditor, "reply:body:" + if m.aiMode == aiDiscussion { + editor, prefix = m.aiInputEditor, "ai-discussion:body:" + } + 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 -} - -func textInputKeyValue(key tea.KeyMsg) string { - if key.Type == tea.KeySpace { - return " " + if cursorLine < 0 { + 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() { @@ -3906,10 +3991,13 @@ func (m App) positionThreadInputHardwareCursor() { paneWidth = m.threadListWidth() } 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( true, - 2+ansi.StringWidth("Filter: ")+ansi.StringWidth(query), + 2+ansi.StringWidth("Filter: ")+column, m.contentTop+topLines+3, ) return @@ -3923,10 +4011,20 @@ func (m App) positionThreadInputHardwareCursor() { if m.aiMode == aiDiscussion { 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 + wantedAnchor := fmt.Sprintf("%s%d", prefix, visualLine) for index := range lines { - if strings.HasPrefix(lines[index].anchor, prefix) { + if lines[index].anchor == wantedAnchor { cursorLine = index + break } } if cursorLine < 0 { @@ -3945,7 +4043,7 @@ func (m App) positionThreadInputHardwareCursor() { line := lines[cursorLine] m.cursorOutput.SetCursor( 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, ) } @@ -4226,21 +4324,47 @@ func (m App) frame(lines []string, help string) string { if status == "" && !m.lastRefresh.IsZero() { status = dimStyle.Render("updated " + m.lastRefresh.Format("15:04:05")) } - footer := truncate(help, m.width) + footerWidth := m.width 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") - if len(bodyLines) > max(0, m.height-1) { - bodyLines = bodyLines[:max(0, m.height-1)] + bodyHeight := max(0, m.height-1) + if len(bodyLines) > bodyHeight { + bodyLines = bodyLines[:bodyHeight] } for i := range bodyLines { 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")) } +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 { if m.err != nil { return diffletSad diff --git a/tui_test.go b/tui_test.go index bf3e6d7..cfa6d1e 100644 --- a/tui_test.go +++ b/tui_test.go @@ -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(runeKey("i")) send(tea.KeyMsg{Type: tea.KeyHome}) for range len("main") { send(tea.KeyMsg{Type: tea.KeyDelete}) } send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("release")}) + send(tea.KeyMsg{Type: tea.KeyEsc}) send(tea.KeyMsg{Type: tea.KeyShiftTab}) + send(runeKey("i")) send(tea.KeyMsg{Type: tea.KeyHome}) for range len("Old title") { send(tea.KeyMsg{Type: tea.KeyDelete}) @@ -794,9 +797,95 @@ func TestDashboardDescriptionCanUseStandardEditingMode(t *testing.T) { Permissions: ViewerPermissions{CanUpdatePR: true}, } m.startPREdit() - editor := m.prEditEditors[prEditBodyField] - if editor.Modal || editor.Mode != textEditorInsert || editor.Cursor != len([]rune("body")) { - t.Fatalf("standard description editor = %#v", editor) + for field, editor := range m.prEditEditors { + if editor.Modal || editor.Mode != textEditorInsert || + 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() - 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.screen, m.loading, m.width, m.height = threadScreen, false, 100, 24 m.details = PRDetails{ @@ -1058,6 +1151,7 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) { } m.searching, m.searchQuery = true, "main" + m.resetInputEditor(&m.searchEditor, m.searchQuery) _ = m.viewThreads() m.cursorOutput.mu.Lock() searchVisible, searchColumn := m.cursorOutput.visible, m.cursorOutput.column @@ -1068,6 +1162,7 @@ func TestThreadInputsUseHardwareCursor(t *testing.T) { m.searching = false m.aiMode, m.writeThreadID, m.aiInput = aiDiscussion, "thread-1", "Question" + m.resetInputEditor(&m.aiInputEditor, m.aiInput) m.focus = threadDetailPane m.scroll = m.detailMaxScroll() _ = m.viewThreads() @@ -1140,6 +1235,8 @@ func TestReplyComposerConfirmsAndAddsReturnedComment(t *testing.T) { if m.writeMode != writeReply { 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")}) m = updated.(App) 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) } } + 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 { 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") } - 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.resetInputEditor(&m.searchEditor, m.searchQuery) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace}) if got := updated.(App).searchQuery; got != "ng " { t.Fatalf("space key produced search query %q", got) @@ -1882,8 +1989,11 @@ func TestFuzzyFileSearchSupportsSpaceSeparatedTerms(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.resetInputEditor(&m.replyEditor, m.replyDraft) updated, _ := m.updateWriteInput(tea.KeyMsg{Type: tea.KeySpace}) m = updated.(App) if m.replyDraft != "reply " { @@ -1891,6 +2001,7 @@ func TestReplyAndAIDiscussionAcceptSpaceKeyWithoutRunes(t *testing.T) { } m.writeMode, m.aiMode, m.aiInput = writeNone, aiDiscussion, "question" + m.resetInputEditor(&m.aiInputEditor, m.aiInput) updated, _, handled := m.updateAI(tea.KeyMsg{Type: tea.KeySpace}) m = updated.(App) 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) { m := NewApp(nil, "o", "r", false, 50, 10*time.Second) m.screen = threadScreen diff --git a/user_completion.go b/user_completion.go index 170926e..37ebb07 100644 --- a/user_completion.go +++ b/user_completion.go @@ -315,7 +315,7 @@ func (m App) userCompletionLines(width int) []string { m.keybindings.Input.PreviousCompletion, m.keybindings.Input.NextCompletion, ), - primaryCombinedKeyLabel(m.keybindings.Input.NextField, m.keybindings.Input.Newline), + primaryKeyLabel(m.keybindings.Input.Newline), )), ) current := m.prEditOriginal.Assignees diff --git a/user_completion_test.go b/user_completion_test.go index 54139ae..c0db851 100644 --- a/user_completion_test.go +++ b/user_completion_test.go @@ -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) { m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second) m.writeMode = writePREdit diff --git a/version.go b/version.go index 72ff270..5e4cd98 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const dipleVersion = "0.2.0" +const dipleVersion = "0.3.0"