Skip to content

Commit d39c62b

Browse files
committed
implemented composing with nvim
1 parent 1b093ef commit d39c62b

11 files changed

Lines changed: 559 additions & 8 deletions

File tree

‎PLAN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Current Stage
44

5-
Implementation is past the local-shell phase and currently sits at a DB-first, live WhatsApp client with remote history/media support, outbound text, single-attachment media send, recent-sticker send, protocol-backed read receipts, reactions with a quick picker, own-message text editing, own-message delete-for-everybody, replies, quote-jump, a right-edge reply gesture in the message pane, typing presence, hybrid fuzzy group-member mention autocomplete, accent-aware search, visible-first chat-avatar sync/rendering with full profile images, first-class sticker receive/render/download behavior, app-state-backed muted/pinned chat settings, stable pending/paused/resumed pixel overlays for media/avatar/sticker rendering, direct-chat PN/LID canonicalization plus split-thread repair, desktop notifications with cached chat-avatar icons where supported plus a local global mute key/right-side configurable status-bar indicator, immediate DB catch-up sync overlay blocking with non-blocking startup app-state sync, async store/protocol-backed TUI actions for sends, lazy loads, filters, read receipts, reactions, retries, stickers, drafts, and mentions, denormalized chat-list previews, media retry UX, first-use-state logout reset, temp-backed non-exported chat media caches, clipboard attachment paste with Windows file-drop support, a persistent in-chat new-message divider, hardened Windows inline preview defaults, composer tail-follow scrolling, detached Shift+Enter external media launch, and the planned media/export CLI helpers. The next major gaps are live validation/polish of the notification and media-send paths on real chats, especially desktop delivery on fresh Linux and Windows installs, notification backend default resolution on a fresh config, audio/document fallback behavior, the new avatar/sticker behavior under daily use, plus attachment draft persistence and follow-on resend polish for failed rows.
5+
Implementation is past the local-shell phase and currently sits at a DB-first, live WhatsApp client with remote history/media support, outbound text, single-attachment media send, recent-sticker send, external-editor draft composition, protocol-backed read receipts, reactions with a quick picker, own-message text editing, own-message delete-for-everybody, replies, quote-jump, a right-edge reply gesture in the message pane, typing presence, hybrid fuzzy group-member mention autocomplete, accent-aware search, visible-first chat-avatar sync/rendering with full profile images, first-class sticker receive/render/download behavior, app-state-backed muted/pinned chat settings, stable pending/paused/resumed pixel overlays for media/avatar/sticker rendering, direct-chat PN/LID canonicalization plus split-thread repair, desktop notifications with cached chat-avatar icons where supported plus a local global mute key/right-side configurable status-bar indicator, immediate DB catch-up sync overlay blocking with non-blocking startup app-state sync, async store/protocol-backed TUI actions for sends, lazy loads, filters, read receipts, reactions, retries, stickers, drafts, and mentions, denormalized chat-list previews, media retry UX, first-use-state logout reset, temp-backed non-exported chat media caches, clipboard attachment paste with Windows file-drop support, a persistent in-chat new-message divider, hardened Windows inline preview defaults, composer tail-follow scrolling, detached Shift+Enter external media launch, and the planned media/export CLI helpers. The next major gaps are live validation/polish of the notification and media-send paths on real chats, especially desktop delivery on fresh Linux and Windows installs, notification backend default resolution on a fresh config, audio/document fallback behavior, the new avatar/sticker behavior under daily use, plus attachment draft persistence and follow-on resend polish for failed rows.
66

77
### Implemented now
88

@@ -11,7 +11,7 @@ Implementation is past the local-shell phase and currently sits at a DB-first, l
1111
- GitHub Actions CI validates Linux tests/vet, checks the Windows build graph, uploads Windows amd64/arm64 binaries for machines where local Windows builds are not available, and publishes a stable branch-build `windows-latest` release for direct tester updates.
1212
- SQLite-backed local state with migrations, chat/message/media/draft storage, denormalized chat previews, startup cache/mmap pragmas, stats, and FTS-backed search.
1313
- Bubble Tea TUI with modal interaction (`normal`, `insert`, `visual`, `command`, `search`), chat list, message viewport, optional info pane, composer, filters, and help.
14-
- Local draft persistence, local outgoing message persistence, normal/visual message yanking, text clipboard integration, clipboard image copy plus clipboard attachment paste/file-drop staging, attachment staging, message delete flow, and search routing by pane.
14+
- Local draft persistence, external-editor draft composition through configurable `$EDITOR`/`editor`, local outgoing message persistence, normal/visual message yanking, text clipboard integration, clipboard image copy plus clipboard attachment paste/file-drop staging, attachment staging, message delete flow, and search routing by pane.
1515
- Media backend detection and in-chat preview behavior with Unix `ueberzug++`, Windows-only `sixel`, `chafa`, compact audio playback rows, foreground and detached external media launch, plus native external open/save fallback paths. Linux auto preview uses `ueberzug++` pixel overlays before falling back to `chafa`/external openers and never selects Sixel; Windows auto preview still prefers Sixel in WezTerm when a Sixel renderer is installed, falls back to inline `chafa` before external openers when `preview_backend = "auto"`, draws Sixel out-of-band instead of embedding image escape payloads in Bubble Tea text frames, and explicit backend config wins when the requested backend is available on that platform.
1616
- Real `whatsmeow` session store, QR login, logout, rejected-session cleanup, first-use-state local reset on logout, and `doctor` session status reporting.
1717
- Live WhatsApp connection bootstrap from a paired session, protocol event subscription, inbound chat/message/receipt/media metadata ingestion into SQLite, DB-first UI refreshes, and visible connection state.

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ key_normal_open = "enter"
174174
key_normal_open_media = "o"
175175
key_normal_yank_message = "y"
176176
key_normal_edit_message = "leader e"
177+
key_normal_compose_editor = "leader c e"
177178
key_normal_pick_sticker = "leader t"
178179
key_normal_copy_image = "leader y"
179180
key_normal_save_media = "leader s"
@@ -218,6 +219,7 @@ Useful command-mode actions:
218219
:paste-image
219220
:attach
220221
:attach <path>
222+
:compose-editor
221223
:edit-message
222224
:sticker
223225
:delete-message
@@ -235,6 +237,8 @@ Useful command-mode actions:
235237

236238
Image clipboard paste/copy is image-only. `key_insert_paste_image` stages the current clipboard image as the composer attachment, preserving composer text as the caption. `key_normal_copy_image` copies the focused image message to the clipboard and auto-downloads remote image media first when possible. The default image clipboard commands auto-detect Wayland/X11 tools; set `clipboard_image_paste_command` or `clipboard_image_copy_command` to override them. Paste commands may write to `{path}` or stdout, and copy commands may use `{path}` and `{mime}` or receive image bytes on stdin.
237239

240+
External editor composition uses `key_normal_compose_editor` or `:compose-editor`. It opens the active chat draft in `editor`, writes the saved result back into the inline composer, persists it as the draft, and does not send until the normal composer send key is pressed. Editor commands are argv templates; if `{path}` is absent, vimwhat appends the temp draft file path.
241+
238242
Sticker sending uses `key_normal_pick_sticker` or `:sticker`. Linux defaults to `sticker_picker_command = "nsxiv -t -o -p {files}"`; commands may use `{files}` for the temporary sticker file list, `{dir}` for the picker directory, and `{chooser}` for a chooser-output file. WebP stickers are selectable in v1; Lottie/TGS stickers are cached as metadata but skipped by the current picker/send path.
239243

240244
All TUI action keys are configurable in `config.toml` using flat `key_<mode>_<action>` variables. Bindings accept printable single keys plus named tokens such as `space`, `enter`, `shift+enter`, `esc`, `tab`, `shift+tab`, `backspace`, `ctrl+x`, `alt+x`, `alt+enter`, and leader sequences. Duplicate bindings in the same mode and prefix conflicts such as binding both `space` and `leader s` are rejected at startup with a config error. Composer `shift+enter` only works when the terminal reports it distinctly; `ctrl+j` is the reliable newline fallback.

‎config.example.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ key_normal_cancel = "esc"
4747
key_normal_quit = "q"
4848
key_normal_help = "?"
4949
key_normal_insert = "i"
50+
key_normal_compose_editor = "leader c e"
5051
key_normal_reply = "r"
5152
key_normal_react = "leader r"
5253
key_normal_forward = "f"

‎internal/app/app.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,9 @@ func runTUI(env Environment, stderr io.Writer) int {
424424
ForwardMessages: func(request ui.ForwardMessagesRequest) tea.Cmd {
425425
return forwardMessagesCmd(liveEnabled, forwardRequests, request)
426426
},
427+
ComposeInEditor: func(chatID, initial string) tea.Cmd {
428+
return composeInEditor(env.Paths, env.Config, chatID, initial)
429+
},
427430
SaveMedia: func(media store.MediaMetadata) error {
428431
storeCtx, cancelStore := uiStoreWriteContext()
429432
defer cancelStore()

‎internal/app/integrations.go‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,102 @@ func pickAttachment(commandTemplate string) tea.Cmd {
502502
})
503503
}
504504

505+
func composeInEditor(paths config.Paths, cfg config.Config, chatID, initial string) tea.Cmd {
506+
draftPath, err := prepareComposeEditorDraft(paths, initial)
507+
if err != nil {
508+
return composerEditorError(chatID, err)
509+
}
510+
cmd, err := composeEditorCommand(paths, cfg.Editor, draftPath)
511+
if err != nil {
512+
_ = os.Remove(draftPath)
513+
return composerEditorError(chatID, err)
514+
}
515+
return tea.ExecProcess(cmd, func(err error) tea.Msg {
516+
return composerEditedMessage(chatID, draftPath, err)
517+
})
518+
}
519+
520+
func composerEditorError(chatID string, err error) tea.Cmd {
521+
return func() tea.Msg {
522+
return ui.ComposerEditedMsg{ChatID: chatID, Err: err}
523+
}
524+
}
525+
526+
func prepareComposeEditorDraft(paths config.Paths, initial string) (string, error) {
527+
root := strings.TrimSpace(paths.TransientDir)
528+
if root == "" {
529+
root = os.TempDir()
530+
} else if err := os.MkdirAll(root, 0o700); err != nil {
531+
return "", fmt.Errorf("create editor temp dir: %w", err)
532+
}
533+
dir := filepath.Join(root, "compose")
534+
if err := os.MkdirAll(dir, 0o700); err != nil {
535+
return "", fmt.Errorf("create editor compose dir: %w", err)
536+
}
537+
file, err := os.CreateTemp(dir, "vimwhat-compose-*.txt")
538+
if err != nil {
539+
return "", fmt.Errorf("create editor draft: %w", err)
540+
}
541+
path := file.Name()
542+
if _, err := file.WriteString(initial); err != nil {
543+
_ = file.Close()
544+
_ = os.Remove(path)
545+
return "", fmt.Errorf("write editor draft: %w", err)
546+
}
547+
if err := file.Close(); err != nil {
548+
_ = os.Remove(path)
549+
return "", fmt.Errorf("close editor draft: %w", err)
550+
}
551+
return path, nil
552+
}
553+
554+
func composeEditorCommand(paths config.Paths, editor, draftPath string) (*exec.Cmd, error) {
555+
editor = strings.TrimSpace(editor)
556+
if editor == "" {
557+
editor = config.Default(paths).Editor
558+
}
559+
hasPathPlaceholder := strings.Contains(editor, "{path}")
560+
argv, err := splitCommandLine(editor)
561+
if err != nil {
562+
return nil, err
563+
}
564+
if len(argv) == 0 {
565+
return nil, fmt.Errorf("editor command is empty")
566+
}
567+
replaceArgPlaceholder(argv, "{path}", draftPath)
568+
if !hasPathPlaceholder {
569+
argv = append(argv, draftPath)
570+
}
571+
if _, err := exec.LookPath(argv[0]); err != nil {
572+
return nil, fmt.Errorf("editor %q not found", argv[0])
573+
}
574+
cmd := exec.Command(argv[0], argv[1:]...)
575+
cmd.Stdin = os.Stdin
576+
cmd.Stdout = os.Stdout
577+
cmd.Stderr = os.Stderr
578+
return cmd, nil
579+
}
580+
581+
func composerEditedMessage(chatID, draftPath string, err error) ui.ComposerEditedMsg {
582+
defer os.Remove(draftPath)
583+
if err != nil {
584+
return ui.ComposerEditedMsg{ChatID: chatID, Err: err}
585+
}
586+
body, err := readEditedComposerDraft(draftPath)
587+
return ui.ComposerEditedMsg{ChatID: chatID, Body: body, Err: err}
588+
}
589+
590+
func readEditedComposerDraft(path string) (string, error) {
591+
data, err := os.ReadFile(path)
592+
if err != nil {
593+
return "", fmt.Errorf("read editor draft: %w", err)
594+
}
595+
body := strings.ReplaceAll(string(data), "\r\n", "\n")
596+
body = strings.ReplaceAll(body, "\r", "\n")
597+
body = strings.TrimSuffix(body, "\n")
598+
return body, nil
599+
}
600+
505601
func pickSticker(paths config.Paths, cfg config.Config, db *store.Store) tea.Cmd {
506602
return func() tea.Msg {
507603
cmd, err := stickerPickerCommand(paths, cfg, db)

‎internal/app/integrations_test.go‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,75 @@ func TestPickStickerUsesExistingCacheWithoutSync(t *testing.T) {
358358
}
359359
}
360360

361+
func TestPrepareComposeEditorDraftUsesTransientDir(t *testing.T) {
362+
root := t.TempDir()
363+
path, err := prepareComposeEditorDraft(config.Paths{TransientDir: root}, "hello")
364+
if err != nil {
365+
t.Fatalf("prepareComposeEditorDraft() error = %v", err)
366+
}
367+
defer os.Remove(path)
368+
if !strings.HasPrefix(path, filepath.Join(root, "compose")) {
369+
t.Fatalf("draft path = %q, want under transient compose dir", path)
370+
}
371+
if got := string(mustReadFile(t, path)); got != "hello" {
372+
t.Fatalf("draft content = %q, want hello", got)
373+
}
374+
info, err := os.Stat(path)
375+
if err != nil {
376+
t.Fatalf("Stat(draft) error = %v", err)
377+
}
378+
if info.Mode().Perm() != 0o600 {
379+
t.Fatalf("draft mode = %03o, want 600", info.Mode().Perm())
380+
}
381+
}
382+
383+
func TestComposeEditorCommandAppendsDraftPathWhenPlaceholderMissing(t *testing.T) {
384+
draftPath := filepath.Join(t.TempDir(), "draft.txt")
385+
cmd, err := composeEditorCommand(config.Paths{}, "sh -c true", draftPath)
386+
if err != nil {
387+
t.Fatalf("composeEditorCommand() error = %v", err)
388+
}
389+
want := "sh\x00-c\x00true\x00" + draftPath
390+
if got := strings.Join(cmd.Args, "\x00"); got != want {
391+
t.Fatalf("args = %q, want %q", got, want)
392+
}
393+
if cmd.Stdin != os.Stdin || cmd.Stdout != os.Stdout || cmd.Stderr != os.Stderr {
394+
t.Fatalf("editor stdio = stdin %T stdout %T stderr %T, want real process stdio", cmd.Stdin, cmd.Stdout, cmd.Stderr)
395+
}
396+
}
397+
398+
func TestComposeEditorCommandReplacesPathPlaceholder(t *testing.T) {
399+
draftPath := filepath.Join(t.TempDir(), "draft with spaces.txt")
400+
cmd, err := composeEditorCommand(config.Paths{}, `sh -c "printf edited > {path}"`, draftPath)
401+
if err != nil {
402+
t.Fatalf("composeEditorCommand() error = %v", err)
403+
}
404+
if len(cmd.Args) != 3 || !strings.Contains(cmd.Args[2], draftPath) {
405+
t.Fatalf("args = %#v, want placeholder replaced without appending", cmd.Args)
406+
}
407+
}
408+
409+
func TestComposeEditorCommandReportsMissingEditor(t *testing.T) {
410+
_, err := composeEditorCommand(config.Paths{}, "vimwhat-editor-definitely-missing", filepath.Join(t.TempDir(), "draft.txt"))
411+
if err == nil || !strings.Contains(err.Error(), "editor") || !strings.Contains(err.Error(), "not found") {
412+
t.Fatalf("composeEditorCommand() error = %v, want missing editor", err)
413+
}
414+
}
415+
416+
func TestComposerEditedMessageReadsNormalizesAndCleansUp(t *testing.T) {
417+
path := filepath.Join(t.TempDir(), "draft.txt")
418+
if err := os.WriteFile(path, []byte("hello\r\nworld\r\n"), 0o600); err != nil {
419+
t.Fatalf("WriteFile(draft) error = %v", err)
420+
}
421+
msg := composerEditedMessage("chat-1", path, nil)
422+
if msg.Err != nil || msg.ChatID != "chat-1" || msg.Body != "hello\nworld" {
423+
t.Fatalf("composerEditedMessage() = %+v, want normalized body", msg)
424+
}
425+
if _, err := os.Stat(path); !os.IsNotExist(err) {
426+
t.Fatalf("draft cleanup Stat() error = %v, want not exist", err)
427+
}
428+
}
429+
361430
func TestExpandStickerPickerArgsSplicesFiles(t *testing.T) {
362431
got := expandStickerPickerArgs(
363432
[]string{"nsxiv", "-t", "-o", "-p", "{files}"},

0 commit comments

Comments
 (0)