Skip to content

Commit 5f93955

Browse files
author
gluck
committed
cli: a kill asks first, and lands on the row the cursor falls onto
Killing the attended aria from a listing ended the session even with living arias one row away: the successor now prefers the neighbour the list already knows, then the jumplist, then the parent, and only ends the session when there is none of the three. 'x' and ':kill' put a y/n question up. It is a mode and a pit, so every key that is not an answer is swallowed and the bar holds the sentence for exactly as long as the question stands. 'X' on a row kills with no question, which costs the hang-up-and-drop meaning of 'X' inside a pit only.
1 parent 5b8473d commit 5f93955

13 files changed

Lines changed: 342 additions & 27 deletions

File tree

‎internal/cli/command.go‎

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -376,14 +376,21 @@ func (in *interactiveInput) commandKill(ctx context.Context, args []string) (str
376376
if id == "" {
377377
return "", errors.New("kill: no aria named and none on screen")
378378
}
379+
return in.killAria(ctx, id, recursive, "")
380+
}
381+
382+
// killAria is the body both doors share. prefer is where the caller would
383+
// like the pager to stand afterwards (the row under the cursor in a listing);
384+
// it is honoured only if it is alive and is not the aria being killed.
385+
func (in *interactiveInput) killAria(ctx context.Context, id string, recursive bool, prefer string) (string, error) {
379386
acli, err := in.angelus()
380387
if err != nil {
381388
return "", err
382389
}
383390
onScreen := id == in.currentID()
384391
successor := ""
385392
if onScreen {
386-
successor = in.successorFor(ctx, acli, id)
393+
successor = in.successorFor(ctx, acli, id, prefer)
387394
}
388395
if err := acli.Kill(ctx, id, recursive); err != nil {
389396
return "", fmt.Errorf("kill %s: %w", id, err)
@@ -405,6 +412,32 @@ func (in *interactiveInput) commandKill(ctx context.Context, args []string) (str
405412
return "killed " + id + "; " + note, nil
406413
}
407414

415+
// askKill puts the question up and runs the kill only on a yes. The question
416+
// is drawn by the pager, so this hops onto the render lock the way every
417+
// other pager mutation does.
418+
func (in *interactiveInput) askKill(args []string) {
419+
spec, _, err := parseKillArgs(args)
420+
if err != nil {
421+
in.noteErr(err.Error())
422+
return
423+
}
424+
target := spec
425+
if target == "" {
426+
target = in.currentID()
427+
}
428+
if target == "" {
429+
in.noteErr("kill: no aria named and none on screen")
430+
return
431+
}
432+
in.mu.Lock()
433+
in.lt.tr.askConfirm(killPrompt(target), func() {
434+
in.commandAsync(func(ctx context.Context) (string, error) {
435+
return in.commandKill(ctx, args)
436+
})
437+
})
438+
in.mu.Unlock()
439+
}
440+
408441
// parseKillArgs reads kill's own flags off the ':' box's argv.
409442
func parseKillArgs(args []string) (spec string, recursive bool, err error) {
410443
for i := 0; i < len(args); i++ {
@@ -428,11 +461,11 @@ func parseKillArgs(args []string) (spec string, recursive bool, err error) {
428461
return spec, recursive, nil
429462
}
430463

431-
// successorFor answers where the transcript stands once id is gone: the most
432-
// recent aria in this session's jumplist that is still alive, else the parent
433-
// id lists for it. An aria forked from nothing, in a session with no history,
434-
// has no answer and the caller ends the session.
435-
func (in *interactiveInput) successorFor(ctx context.Context, acli *sdk.Angelus, id string) string {
464+
// successorFor answers where the transcript stands once id is gone: the
465+
// caller's preferred neighbour, else the most recent living aria in this
466+
// session's jumplist, else the parent. An aria with none of the three, in a
467+
// session with no history, has no answer and the caller ends the session.
468+
func (in *interactiveInput) successorFor(ctx context.Context, acli *sdk.Angelus, id, prefer string) string {
436469
resp, err := acli.List(ctx)
437470
if err != nil {
438471
return ""
@@ -449,6 +482,12 @@ func (in *interactiveInput) successorFor(ctx context.Context, acli *sdk.Angelus,
449482
parent = f.Parent
450483
}
451484
}
485+
// THE LIST THE READER IS IN WINS. Killing a row of `:ls` should leave the
486+
// cursor's neighbour on screen, not send the session somewhere else and
487+
// not end it while a living aria is one row away.
488+
if prefer != "" && prefer != id && alive[prefer] {
489+
return prefer
490+
}
452491
in.mu.Lock()
453492
trail := in.jumps.trail()
454493
in.mu.Unlock()
@@ -1008,13 +1047,13 @@ func (in *interactiveInput) seedSubject(gen uint64) {
10081047
// dropPitRow is 'x' in a pit: what dropping means depends on which
10091048
// pit. Today only the queue can be dropped from; the switch is here rather
10101049
// than in the transcript because every arm of it is an RPC.
1011-
func (in *interactiveInput) dropPitRow(name, id string) {
1050+
func (in *interactiveInput) dropPitRow(name, id, next string) {
10121051
// A ROW THAT NAMES AN ARIA IS KILLED, wherever it was listed. Only the
10131052
// queue counts by something else, and its ids are numbers.
10141053
if name != "queue" {
10151054
if rpc.ValidateAriaID(id) == nil {
10161055
in.commandAsync(func(ctx context.Context) (string, error) {
1017-
return in.commandKill(ctx, []string{id})
1056+
return in.killAria(ctx, id, false, next)
10181057
})
10191058
}
10201059
return

‎internal/cli/command_router.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,9 @@ func (in *interactiveInput) runOverlay(verb string, args []string) {
220220
return in.switchSubject(ctx, spec, true)
221221
})
222222
case "kill":
223-
in.commandAsync(func(ctx context.Context) (string, error) {
224-
return in.commandKill(ctx, args)
225-
})
223+
// ASK FIRST, from the ':' box as from the 'x' key: one question, one
224+
// wording, one place that spends it.
225+
in.askKill(args)
226226
case "send", "s":
227227
in.commandAsync(func(ctx context.Context) (string, error) {
228228
return in.commandSend(ctx, args)

‎internal/cli/confirm.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package cli
2+
3+
import "strings"
4+
5+
// A DESTRUCTIVE VERB ASKS FIRST. `x` on a row and `:kill` put a question on
6+
// the screen and hold the keyboard until it is answered; `X` on a row does
7+
// the same work with no question at all.
8+
//
9+
// The question is a pit (so the bar names it and every key that is not an
10+
// answer is swallowed) plus a pinned notice, which is the one alert that does
11+
// not retire on its own: it stands for exactly as long as the question does.
12+
13+
// confirmAsk is the pending question: the sentence, and what a yes runs.
14+
type confirmAsk struct {
15+
prompt string
16+
yes func()
17+
}
18+
19+
// askConfirm puts the question up. The caller's action runs on the input
20+
// goroutine, so it must hand off anything that dials, exactly as every other
21+
// hook here does.
22+
func (t *transcript) askConfirm(prompt string, yes func()) {
23+
t.confirm = &confirmAsk{prompt: prompt, yes: yes}
24+
t.pit.showList(pitConfirm, "", []pitRow{staticRow(" " + prompt + " [y/N]")})
25+
t.status.pinNotice(prompt)
26+
t.focused = focusPit
27+
t.render()
28+
}
29+
30+
// clearConfirm takes the question down and the notice with it, in that order:
31+
// the bar must not be left holding a sentence about a question nobody can see.
32+
func (t *transcript) clearConfirm() {
33+
t.confirm = nil
34+
if t.showing(pitConfirm) {
35+
t.pit.close()
36+
}
37+
t.status.setNotice("")
38+
}
39+
40+
func confirmYes(t *transcript) {
41+
ask := t.confirm
42+
t.clearConfirm()
43+
if ask != nil && ask.yes != nil {
44+
ask.yes()
45+
}
46+
t.render()
47+
}
48+
49+
func confirmNo(t *transcript) {
50+
t.clearConfirm()
51+
t.note("")
52+
t.render()
53+
}
54+
55+
// confirmLines is the question as the drawer draws it.
56+
func (t *transcript) confirmLines() []string {
57+
if t.confirm == nil {
58+
return nil
59+
}
60+
return []string{pitGray(clipToWidth(t.confirm.prompt+" [y/N]", t.w))}
61+
}
62+
63+
// killPrompt is the sentence the two kill doors ask.
64+
func killPrompt(id string) string { return "kill " + strings.TrimSpace(id) + "?" }

‎internal/cli/confirm_test.go‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// x on a row that names an aria asks before it kills, and holds the keyboard
9+
// until it is answered: a stray letter is not consent. X asks nothing.
10+
func TestConfirm_XAsksAndCapitalXDoesNot(t *testing.T) {
11+
for _, tc := range []struct {
12+
key byte
13+
wantAsk bool
14+
}{{'x', true}, {'X', false}} {
15+
tr, _ := visualFixture(t)
16+
var killed []string
17+
tr.dropRow = func(pit, id, next string) { killed = append(killed, id) }
18+
tr.pit.showList(pitOutput, ":ls", []pitRow{
19+
{text: " aria1234", id: "aria1234", yank: "aria1234"},
20+
{text: " aria5678", id: "aria5678", yank: "aria5678"},
21+
})
22+
tr.focused = focusPit
23+
tr.key(tc.key)
24+
if !tc.wantAsk {
25+
if len(killed) != 1 || killed[0] != "aria1234" {
26+
t.Fatalf("X must kill at once, killed %v", killed)
27+
}
28+
continue
29+
}
30+
if len(killed) != 0 {
31+
t.Fatalf("x killed %v without asking", killed)
32+
}
33+
if tr.mode() != modeConfirm {
34+
t.Fatalf("mode = %v, want the question to own the keyboard", tr.mode())
35+
}
36+
if got := strings.Join(tr.confirmLines(), ""); !strings.Contains(stripANSI(got), "kill aria1234?") {
37+
t.Fatalf("question = %q", got)
38+
}
39+
// A key that is not an answer changes nothing.
40+
tr.key('j')
41+
if tr.mode() != modeConfirm || len(killed) != 0 {
42+
t.Fatalf("a stray key answered the question: mode=%v killed=%v", tr.mode(), killed)
43+
}
44+
// n leaves it alone and takes the sentence off the bar with it.
45+
tr.key('n')
46+
if tr.mode() == modeConfirm || len(killed) != 0 {
47+
t.Fatalf("n did not dismiss: mode=%v killed=%v", tr.mode(), killed)
48+
}
49+
if note := tr.status.noticeText(); note != "" {
50+
t.Fatalf("the pinned notice outlived the question: %q", note)
51+
}
52+
}
53+
}
54+
55+
// The question pins its sentence to the bar for as long as it is up: a notice
56+
// that retires mid-question leaves a dialog nobody can read.
57+
func TestConfirm_TheNoticeStandsWhileTheQuestionDoes(t *testing.T) {
58+
tr, _ := visualFixture(t)
59+
done := false
60+
tr.askConfirm("kill aria1234?", func() { done = true })
61+
if got := tr.status.noticeText(); !strings.Contains(got, "kill aria1234?") {
62+
t.Fatalf("bar = %q, want the question", got)
63+
}
64+
tr.key('y')
65+
if !done {
66+
t.Fatal("y did not run the action")
67+
}
68+
if note := tr.status.noticeText(); note != "" {
69+
t.Fatalf("the notice must clear with the question: %q", note)
70+
}
71+
}

‎internal/cli/keymap.go‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const (
2121
modePanel // a '?'/'!'/'Q' panel is showing
2222
modeVisual // v/V: a visual selection is up and owns the motions
2323
modeFork // 'f' is down, waiting for the direction of the fork jump
24+
modeConfirm // a y/n question owns the keyboard until it is answered
2425
numKeyModes
2526
)
2627

@@ -35,11 +36,12 @@ const (
3536
inPanel keyModeSet = 1 << modePanel
3637
inVisual keyModeSet = 1 << modeVisual
3738
inFork keyModeSet = 1 << modeFork
39+
inConfirm keyModeSet = 1 << modeConfirm
3840

3941
// inPager is every mode with the pager up. Note that a transcript-mode
4042
// row is ALSO reachable while a panel is showing: the panel swallows only
4143
// its own keys and every other key wipes it and acts (see dispatch).
42-
inPager = inTranscript | inSearchBox | inJumpBox | inPanel | inVisual | inFork
44+
inPager = inTranscript | inSearchBox | inJumpBox | inPanel | inVisual | inFork | inConfirm
4345
inAnyBox = inIncipit | inPager
4446
)
4547

@@ -288,6 +290,18 @@ var keymap = []keyBinding{
288290
help: helpNone, pager: pagerForkCancel,
289291
},
290292

293+
// -- confirm mode: a destructive verb asks first ------------------------
294+
//
295+
// THE QUESTION OWNS THE KEYBOARD. Every key that is not an answer is
296+
// swallowed (see dispatch), because a stray letter must not be read as
297+
// consent, and the notice stays on the bar for exactly as long as the
298+
// question is up.
299+
{chord: byteChord('y'), modes: inConfirm, open: staysInline, why: "only reachable with a question up", help: helpConfirm, pager: confirmYes},
300+
{chord: byteChord('Y'), modes: inConfirm, open: staysInline, why: "only reachable with a question up", help: helpNone, pager: confirmYes},
301+
{chord: byteChord('n'), modes: inConfirm, open: staysInline, why: "only reachable with a question up", help: helpNone, pager: confirmNo},
302+
{chord: byteChord('N'), modes: inConfirm, open: staysInline, why: "only reachable with a question up", help: helpNone, pager: confirmNo},
303+
{chord: byteChord(0x1b), modes: inConfirm, open: staysInline, why: "only reachable with a question up", help: helpNone, pager: confirmNo},
304+
291305
// -- pager level: attending what is on screen --------------------------
292306
//
293307
// 'a' is ATTEND, one verb with two subjects: in the transcript it is the
@@ -389,7 +403,9 @@ var keymap = []keyBinding{
389403
// destructive one, and the two must not be neighbours. What it drops is
390404
// printed into the pager's notice and reprinted to the shell on the way
391405
// out, so a slip costs you the queue's PLACE, not its text.
392-
chord: byteChord('X'), modes: inTranscript | inPanel | inVisual,
406+
// NOT IN A PIT: there 'X' is the unasked drop of the selected row
407+
// (below), because a reader inside a list means the list.
408+
chord: byteChord('X'), modes: inTranscript | inVisual,
393409
open: staysInline, why: "it addresses a turn that is streaming in the view you are already in",
394410
help: helpHangUpDrop, input: inputHangUpDrop,
395411
},
@@ -405,6 +421,11 @@ var keymap = []keyBinding{
405421
open: staysInline, why: "it acts on a row in a pit that is already open",
406422
help: helpPitDrop, pager: pagerPitDrop,
407423
},
424+
{
425+
chord: byteChord('X'), modes: inPanel,
426+
open: staysInline, why: "it acts on a row in a pit that is already open",
427+
help: helpPitDrop, pager: pagerPitDropNow,
428+
},
408429

409430
// -- pager level: selection --------------------------------------------
410431
{chord: byteChord(0x0e), modes: inTranscript, open: opensPager, help: helpSelect, pager: pagerSelectNext},
@@ -725,6 +746,7 @@ const (
725746
helpQueuedPanel
726747
helpHelpPanel
727748
helpListPit
749+
helpConfirm
728750
helpCmdHistory
729751
helpCmdComplete
730752
helpCmdEdit
@@ -777,7 +799,7 @@ var helpRows = []helpRow{
777799
// shell user needs, which is that their fingers already know this box.
778800
{helpCmdEdit, "(in :) ^A ^E ^W ^K ^Y", "emacs/readline editing, the whole set"},
779801
{helpCmdAbort, "(in :) Esc / ^C / ^G", "abandon the line, close the box"},
780-
{helpPitDrop, "(in a list) x", "drop the selected entry (queue)"},
802+
{helpPitDrop, "(in a list) x / X", "drop or kill the selected row, asking first / at once"},
781803
{helpYank, "y", "copy selection (or aria id if none)"},
782804
{helpVerbose, "M-m", "toggle verbose tool output and the node addresses"},
783805
{helpSticky, "s", "pin the question of the turn you are inside"},
@@ -803,6 +825,7 @@ var helpRows = []helpRow{
803825
{helpFormPit, "S", "the form (state) in the pit"},
804826
{helpFocus, "T", "read the conversation without closing the pit (again to go back)"},
805827
{helpListPit, "l / L", "list this aria's tree / the home tree, in the pit (Enter attends, x kills)"},
828+
{helpConfirm, "y / n", "answer a confirmation (Esc is no); X on a row kills with no question"},
806829
{helpHelpPanel, "h", "close help"},
807830
}
808831

‎internal/cli/keymap_equiv_test.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ var oracleStates = map[string]func(*transcript){
168168
// only state here with no pre-refactor twin -- the mode did not exist --
169169
// so its row was recorded from the behaviour it was built to have.
170170
"fork": func(tr *transcript) { tr.key('f') },
171+
// A y/n question up: the mode a destructive verb opens, where every key
172+
// that is not an answer is swallowed.
173+
"confirm": func(tr *transcript) { tr.askConfirm("kill aria1234?", func() {}) },
171174
}
172175

173176
// REBASED A SECOND TIME, for the cold-selection seed (Ctrl-N/Ctrl-P). Two
@@ -255,6 +258,7 @@ var pagerOracle = []struct {
255258
// session's business, off this oracle's stage.
256259
"0x3f": "off:bottom fol=true srch=true q=\"\" mq=\"\" h=true s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
257260
"0x4c": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
261+
"0x58": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=true s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
258262
"0x5e": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
259263
"0x61": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=true s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
260264
// VISUAL MODE, 2026-09-11: v/V close whatever pit is up, drop the node
@@ -440,6 +444,7 @@ var pagerOracle = []struct {
440444
// session's business, off this oracle's stage.
441445
"0x0a": "off:same fol=false srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=true exp=0 jmp=false jq=\"\" vis=none",
442446
"0x0d": "off:same fol=false srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=true exp=0 jmp=false jq=\"\" vis=none",
447+
"0x58": "off:same fol=false srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=true exp=0 jmp=false jq=\"\" vis=none",
443448
"0x61": "off:same fol=false srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=true exp=0 jmp=false jq=\"\" vis=none",
444449
// VISUAL MODE, 2026-09-11: v/V close whatever pit is up, drop the node
445450
// selection, detach from the tail and seed a visual selection on the
@@ -505,6 +510,7 @@ var pagerOracle = []struct {
505510
// dismiss the pit. Nothing else here moves: the attend itself is the
506511
// session's business, off this oracle's stage.
507512
"0x4c": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
513+
"0x58": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=true g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
508514
"0x5e": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
509515
"0x61": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=true g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
510516
// VISUAL MODE, 2026-09-11: v/V close whatever pit is up, drop the node
@@ -776,6 +782,7 @@ var pagerOracle = []struct {
776782
// dismiss the pit. Nothing else here moves: the attend itself is the
777783
// session's business, off this oracle's stage.
778784
"0x4c": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
785+
"0x58": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
779786
"0x5e": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
780787
"0x61": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=true Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
781788
// VISUAL MODE, 2026-09-11: v/V close whatever pit is up, drop the node
@@ -1012,6 +1019,15 @@ var pagerOracle = []struct {
10121019
// transcript's own rows, which is why the rest of this row reads like
10131020
// the transcript's. Recorded from the behaviour the mode was built to
10141021
// have: it has no pre-refactor twin.
1022+
// The y/n question. Every key that is not an answer is swallowed, which
1023+
// is the whole point: consent is typed, never stumbled into.
1024+
{"confirm", "off:same fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none", map[string]string{
1025+
"0x1b": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
1026+
"0x4e": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
1027+
"0x59": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
1028+
"0x6e": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
1029+
"0x79": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
1030+
}},
10151031
{"fork", "off:same fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none", map[string]string{
10161032
"0x4c": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",
10171033
"0x5e": "off:bottom fol=true srch=false q=\"\" mq=\"\" h=false s=false Q=false g=false sel=false exp=0 jmp=false jq=\"\" vis=none",

0 commit comments

Comments
 (0)