feat(palette): autocomplete shortcodes with tab
- commit
- b16caf04b704d3351ca08405fcc2f68c1195648c
- author
- brice · · 2026-07-14 11:32 UTC
- parent
- 4b73ee6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +66 -0
M internal/usecase/daemon/palette.go +1 -0
| @@ -125,6 +125,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 125 | 125 | ac.overlays.palette.Backspace() | |
| 126 | 126 | changed = true | |
| 127 | 127 | }, | |
| 128 | + | tab: func() { changed = ac.overlays.palette.CompleteSelected() }, | |
| 128 | 129 | up: func() { ac.overlays.palette.Up(); changed = true }, | |
| 129 | 130 | down: func() { ac.overlays.palette.Down(); changed = true }, | |
| 130 | 131 | cancel: func() { cancel = true }, | |
| @@ -125,6 +125,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 125 | ac.overlays.palette.Backspace() | 125 | ac.overlays.palette.Backspace() | ||
| 126 | changed = true | 126 | changed = true | ||
| 127 | }, | 127 | }, | ||
| 128 | + | tab: func() { changed = ac.overlays.palette.CompleteSelected() }, | |||
| 128 | up: func() { ac.overlays.palette.Up(); changed = true }, | 129 | up: func() { ac.overlays.palette.Up(); changed = true }, | ||
| 129 | down: func() { ac.overlays.palette.Down(); changed = true }, | 130 | down: func() { ac.overlays.palette.Down(); changed = true }, | ||
| 130 | cancel: func() { cancel = true }, | 131 | cancel: func() { cancel = true }, | ||
M internal/usecase/daemon/palette_test.go +65 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "sync" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/mock" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -530,6 +531,70 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) { | |||
| 530 | 531 | wg.Wait() | |
| 531 | 532 | } | |
| 532 | 533 | ||
| 534 | + | func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) { | |
| 535 | + | tests := []struct { | |
| 536 | + | name string | |
| 537 | + | config map[string]string | |
| 538 | + | recent string | |
| 539 | + | query string | |
| 540 | + | want string | |
| 541 | + | wantRepaint bool | |
| 542 | + | setupRepaints int | |
| 543 | + | }{ | |
| 544 | + | {name: "changed completion repaints once", query: "NX", want: "NXT", wantRepaint: true}, | |
| 545 | + | {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1", wantRepaint: false}, | |
| 546 | + | {name: "empty query starts at registry first", want: "CNT", wantRepaint: true}, | |
| 547 | + | {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantRepaint: true}, | |
| 548 | + | {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantRepaint: true, setupRepaints: 1}, | |
| 549 | + | {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantRepaint: true, setupRepaints: 1}, | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | for _, tt := range tests { | |
| 553 | + | t.Run(tt.name, func(t *testing.T) { | |
| 554 | + | writes := make(chan []byte, 1) | |
| 555 | + | p, release := newBlockingPTYWithWrites(t, writes) | |
| 556 | + | defer release() | |
| 557 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 558 | + | if tt.recent != "" { | |
| 559 | + | d.recordPaletteUse(tt.recent) | |
| 560 | + | } | |
| 561 | + | if tt.config != nil { | |
| 562 | + | d.ApplyConfig(domain.Config{Codes: tt.config}) | |
| 563 | + | for range tt.setupRepaints { | |
| 564 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 569 | + | awaitFrame(t, sends, ports.MsgOutput) // drain palette open repaint | |
| 570 | + | if tt.query != "" { | |
| 571 | + | d.handleInput(sess, ac, []byte(tt.query)) | |
| 572 | + | awaitFrame(t, sends, ports.MsgOutput) // drain typed-query repaint | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | d.handleInput(sess, ac, []byte("\t")) | |
| 576 | + | ac.overlays.paletteMu.Lock() | |
| 577 | + | got := ac.overlays.palette.Query() | |
| 578 | + | ac.overlays.paletteMu.Unlock() | |
| 579 | + | require.Equal(t, tt.want, got) | |
| 580 | + | if tt.wantRepaint { | |
| 581 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 582 | + | } | |
| 583 | + | requireNoPaletteFrame(t, sends) | |
| 584 | + | requireNoPTYWrite(t, writes) | |
| 585 | + | }) | |
| 586 | + | } | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func requireNoPaletteFrame(t *testing.T, sends <-chan ports.Frame) { | |
| 590 | + | t.Helper() | |
| 591 | + | select { | |
| 592 | + | case frame := <-sends: | |
| 593 | + | t.Fatalf("unexpected frame after no-op Tab: %#v", frame) | |
| 594 | + | case <-time.After(50 * time.Millisecond): | |
| 595 | + | } | |
| 596 | + | } | |
| 597 | + | ||
| 533 | 598 | func TestPaletteExecMethods(t *testing.T) { | |
| 534 | 599 | p1, release1 := newBlockingPTY(t) | |
| 535 | 600 | p2, release2 := newBlockingPTY(t) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | + | "time" | |||
| 7 | 8 | ||||
| 8 | "github.com/stretchr/testify/mock" | 9 | "github.com/stretchr/testify/mock" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| @@ -530,6 +531,70 @@ func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) { | |||||
| 530 | wg.Wait() | 531 | wg.Wait() | ||
| 531 | } | 532 | } | ||
| 532 | 533 | ||||
| 534 | + | func TestPaletteTabCompletesSelectedCommandWithoutForwardingToPTY(t *testing.T) { | |||
| 535 | + | tests := []struct { | |||
| 536 | + | name string | |||
| 537 | + | config map[string]string | |||
| 538 | + | recent string | |||
| 539 | + | query string | |||
| 540 | + | want string | |||
| 541 | + | wantRepaint bool | |||
| 542 | + | setupRepaints int | |||
| 543 | + | }{ | |||
| 544 | + | {name: "changed completion repaints once", query: "NX", want: "NXT", wantRepaint: true}, | |||
| 545 | + | {name: "exact argument command is a no-op", query: "JRS 1", want: "JRS 1", wantRepaint: false}, | |||
| 546 | + | {name: "empty query starts at registry first", want: "CNT", wantRepaint: true}, | |||
| 547 | + | {name: "empty query starts at daemon MRU", recent: "NXT", want: "NXT", wantRepaint: true}, | |||
| 548 | + | {name: "effective configured override completes", config: map[string]string{"new-tab": "NEW"}, want: "NEW", wantRepaint: true, setupRepaints: 1}, | |||
| 549 | + | {name: "stale pre-override MRU is ignored", config: map[string]string{"new-tab": "NEW"}, recent: "CNT", want: "NEW", wantRepaint: true, setupRepaints: 1}, | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | for _, tt := range tests { | |||
| 553 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 554 | + | writes := make(chan []byte, 1) | |||
| 555 | + | p, release := newBlockingPTYWithWrites(t, writes) | |||
| 556 | + | defer release() | |||
| 557 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 558 | + | if tt.recent != "" { | |||
| 559 | + | d.recordPaletteUse(tt.recent) | |||
| 560 | + | } | |||
| 561 | + | if tt.config != nil { | |||
| 562 | + | d.ApplyConfig(domain.Config{Codes: tt.config}) | |||
| 563 | + | for range tt.setupRepaints { | |||
| 564 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 569 | + | awaitFrame(t, sends, ports.MsgOutput) // drain palette open repaint | |||
| 570 | + | if tt.query != "" { | |||
| 571 | + | d.handleInput(sess, ac, []byte(tt.query)) | |||
| 572 | + | awaitFrame(t, sends, ports.MsgOutput) // drain typed-query repaint | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | d.handleInput(sess, ac, []byte("\t")) | |||
| 576 | + | ac.overlays.paletteMu.Lock() | |||
| 577 | + | got := ac.overlays.palette.Query() | |||
| 578 | + | ac.overlays.paletteMu.Unlock() | |||
| 579 | + | require.Equal(t, tt.want, got) | |||
| 580 | + | if tt.wantRepaint { | |||
| 581 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 582 | + | } | |||
| 583 | + | requireNoPaletteFrame(t, sends) | |||
| 584 | + | requireNoPTYWrite(t, writes) | |||
| 585 | + | }) | |||
| 586 | + | } | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func requireNoPaletteFrame(t *testing.T, sends <-chan ports.Frame) { | |||
| 590 | + | t.Helper() | |||
| 591 | + | select { | |||
| 592 | + | case frame := <-sends: | |||
| 593 | + | t.Fatalf("unexpected frame after no-op Tab: %#v", frame) | |||
| 594 | + | case <-time.After(50 * time.Millisecond): | |||
| 595 | + | } | |||
| 596 | + | } | |||
| 597 | + | ||||
| 533 | func TestPaletteExecMethods(t *testing.T) { | 598 | func TestPaletteExecMethods(t *testing.T) { | ||
| 534 | p1, release1 := newBlockingPTY(t) | 599 | p1, release1 := newBlockingPTY(t) | ||
| 535 | p2, release2 := newBlockingPTY(t) | 600 | p2, release2 := newBlockingPTY(t) | ||