v0.29.0 73d6864

resident_subagent_indicator_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/bnema/zut/packages/agent/subagents"
11	"github.com/bnema/zut/packages/provider"
12	"github.com/bnema/zut/packages/tui"
13	"github.com/mattn/go-runewidth"
14)
15
16func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T) {
17	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
18	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{
19		{ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},
20		{ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},
21		{ID: "foreign-id", Profile: "another process", State: subagents.ResidentRunning, OwnedElsewhere: true, TurnStartedAt: now.Add(-10 * time.Second)},
22		{ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},
23	}, 80, now)
24	got := strings.Join(plainResidentIndicatorLines(lines), "\n")
25	if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {
26		t.Fatalf("activity lines = %q", got)
27	}
28	if strings.Contains(got, "finished") || strings.Contains(got, "another process") {
29		t.Fatalf("activity lines include inactive or foreign child: %q", got)
30	}
31}
32
33type residentIndicatorTerminal struct {
34	alertTestTerminal
35	cols int
36	rows int
37}
38
39func (t *residentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
40func (t *residentIndicatorTerminal) Reset() {
41	t.mu.Lock()
42	defer t.mu.Unlock()
43	t.data = nil
44}
45
46func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) {
47	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
48		return func(ctx context.Context, _ string) error {
49			<-ctx.Done()
50			return ctx.Err()
51		}, nil
52	})
53	t.Cleanup(func() { _ = manager.Close(context.Background()) })
54	spawnRunningResidentIndicatorChildren(t, manager, 10)
55
56	term := &residentIndicatorTerminal{cols: 80, rows: 40}
57	interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
58	interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
59	interactive.rend.Resize(term.cols, term.rows)
60	term.Reset()
61	interactive.redraw()
62
63	plain := stripANSIBytes(term.String())
64	for index := 0; index < 10; index++ {
65		if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
66			t.Fatalf("interactive output missing %q:\n%s", want, plain)
67		}
68	}
69
70	if err := manager.Close(context.Background()); err != nil {
71		t.Fatal(err)
72	}
73	term.Reset()
74	interactive.redraw()
75	plain = stripANSIBytes(term.String())
76	if strings.Contains(plain, "agent-00") {
77		t.Fatalf("interactive output kept finished subagent activity:\n%s", plain)
78	}
79}
80
81func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) {
82	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
83		return func(ctx context.Context, _ string) error {
84			<-ctx.Done()
85			return ctx.Err()
86		}, nil
87	})
88	t.Cleanup(func() { _ = manager.Close(context.Background()) })
89	spawnRunningResidentIndicatorChildren(t, manager, 6)
90
91	term := &residentIndicatorTerminal{cols: 80, rows: 8}
92	interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
93	interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
94	interactive.rend.Resize(term.cols, term.rows)
95	term.Reset()
96	interactive.redraw()
97
98	plain := stripANSIBytes(term.String())
99	if count := strings.Count(plain, "agent-"); count != 1 {
100		t.Fatalf("interactive output showed %d active child rows, want one fitting row under short height:\n%s", count, plain)
101	}
102	if !strings.Contains(plain, "… 5 more active subagents") {
103		t.Fatalf("interactive output did not summarize omitted active children:\n%s", plain)
104	}
105}
106
107func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) {
108	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
109		return func(ctx context.Context, _ string) error {
110			<-ctx.Done()
111			return ctx.Err()
112		}, nil
113	})
114	t.Cleanup(func() { _ = manager.Close(context.Background()) })
115	spawnRunningResidentIndicatorChildren(t, manager, 2)
116
117	term := &residentIndicatorTerminal{cols: 80, rows: 8}
118	interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
119	interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
120	interactive.rend.Resize(term.cols, term.rows)
121	term.Reset()
122	interactive.redraw()
123
124	plain := stripANSIBytes(term.String())
125	for index := 0; index < 2; index++ {
126		if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
127			t.Fatalf("interactive output missing fitting active child %q:\n%s", want, plain)
128		}
129	}
130	if strings.Contains(plain, "more active subagents") {
131		t.Fatalf("interactive output summarized fitting active children:\n%s", plain)
132	}
133}
134
135func spawnRunningResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
136	t.Helper()
137	updates := make(chan string, count*2)
138	manager.SetUpdateObserver(func(childID string) {
139		select {
140		case updates <- childID:
141		default:
142		}
143	})
144	spawnResidentIndicatorChildren(t, manager, count)
145	defer manager.SetUpdateObserver(nil)
146
147	running := make(map[string]struct{}, count)
148	timer := time.NewTimer(time.Second)
149	defer timer.Stop()
150	for len(running) < count {
151		select {
152		case childID := <-updates:
153			snapshot, ok := manager.SnapshotFor(childID)
154			if ok && snapshot.State == subagents.ResidentRunning {
155				running[childID] = struct{}{}
156			}
157		case <-timer.C:
158			snapshots, _ := manager.ActiveSnapshotPage(0)
159			t.Fatalf("running resident children = %d, want %d; active snapshots=%#v", len(running), count, snapshots)
160		}
161	}
162}
163
164func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
165	t.Helper()
166	for index := 0; index < count; index++ {
167		spec := subagents.ResidentChildSpec{
168			ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
169			Provider: "openai", Model: "gpt-test",
170		}
171		if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
172			t.Fatalf("Spawn(%d): %v", index, err)
173		}
174	}
175}
176
177func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
178	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
179		return func(ctx context.Context, _ string) error {
180			<-ctx.Done()
181			return ctx.Err()
182		}, nil
183	})
184	t.Cleanup(func() { _ = manager.Close(context.Background()) })
185	spawnResidentIndicatorChildren(t, manager, 5)
186
187	lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC))
188	plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
189	if hidden != 3 {
190		t.Fatalf("hidden active children = %d, want 3", hidden)
191	}
192	if count := strings.Count(plain, "agent-"); count != 2 {
193		t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain)
194	}
195}
196
197func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
198	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
199	var snapshots []subagents.ResidentSnapshot
200	for index := 0; index < 12; index++ {
201		snapshots = append(snapshots, subagents.ResidentSnapshot{
202			ID: fmt.Sprintf("child-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), State: subagents.ResidentRunning,
203			TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now,
204		})
205	}
206	lines := renderResidentSubagentActivityLines(tui.Dark, "/", snapshots, 80, now)
207	plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
208	for index := 0; index < 12; index++ {
209		if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
210			t.Fatalf("activity lines missing %q:\n%s", want, plain)
211		}
212	}
213}
214
215func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t *testing.T) {
216	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
217	const width = 120
218	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
219		ID: "running-id", Profile: "審査", State: subagents.ResidentRunning,
220		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
221		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
222	}}, width, now)
223	plain := plainResidentIndicatorLines(lines)
224	if len(plain) != 1 {
225		t.Fatalf("activity lines = %#v, want one right-aligned row", plain)
226	}
227	metadata := "↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k"
228	if !strings.HasSuffix(plain[0], metadata) {
229		t.Fatalf("activity line = %q, want metadata suffix %q", plain[0], metadata)
230	}
231	if gotWidth := runewidth.StringWidth(plain[0]); gotWidth != width {
232		t.Fatalf("activity line width = %d, want %d: %q", gotWidth, width, plain[0])
233	}
234}
235
236func TestResidentUsageMetadataShowsRolloutBudget(t *testing.T) {
237	metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238		Budget: subagents.BudgetSnapshot{Used: 337_500, Limit: 500_000, Percent: 67, State: subagents.BudgetNormal},
239	})
240	if metadata != "budget:normal 67%/500k" {
241		t.Fatalf("budget metadata = %q", metadata)
242	}
243}
244
245func TestCompactResidentTokensAvoidsThousandKBoundary(t *testing.T) {
246	for _, tc := range []struct {
247		tokens int64
248		want   string
249	}{
250		{tokens: 999_499, want: "999k"},
251		{tokens: 999_500, want: "1.0M"},
252		{tokens: 999_999, want: "1.0M"},
253		{tokens: 1_000_000, want: "1.0M"},
254	} {
255		if got := compactResidentTokens(tc.tokens); got != tc.want {
256			t.Errorf("compactResidentTokens(%d) = %q, want %q", tc.tokens, got, tc.want)
257		}
258	}
259}
260
261func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
262	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
263	const width = 34
264	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
265		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
266		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
267		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
268	}}, width, now)
269	plain := plainResidentIndicatorLines(lines)
270	if len(plain) < 3 {
271		t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
272	}
273	if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], "    ") {
274		t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
275	}
276	joinedMetadata := strings.Join(plain[1:], " ")
277	for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
278		if !strings.Contains(joinedMetadata, want) {
279			t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
280		}
281	}
282	for _, line := range plain {
283		if gotWidth := runewidth.StringWidth(line); gotWidth > width {
284			t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
285		}
286	}
287}
288
289func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
290	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
291	const width = 34
292	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
293		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
294		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
295		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
296	}}, width, now)
297	if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
298		t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
299	}
300
301	got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
302		return len(candidate)
303	}))
304	if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
305		t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
306	}
307}
308
309func TestRenderResidentSubagentActivityLinesOmitsMetadataWhenIndentDoesNotFit(t *testing.T) {
310	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
311	snapshot := subagents.ResidentSnapshot{
312		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
313		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
314		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
315	}
316	for _, width := range []int{3, 4} {
317		t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
318			lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{snapshot}, width, now)
319			plain := plainResidentIndicatorLines(lines)
320			wantActivity := "  " + strings.Repeat(".", width-runewidth.StringWidth("  "))
321			if len(plain) != 1 || plain[0] != wantActivity {
322				t.Fatalf("activity lines = %#v, want only activity row %#v", plain, []string{wantActivity})
323			}
324			if strings.Contains(strings.Join(plain, "\n"), "↑84k") {
325				t.Fatalf("activity lines include metadata despite unavailable indent: %#v", plain)
326			}
327			if gotWidth := runewidth.StringWidth(plain[0]); gotWidth > width {
328				t.Fatalf("activity line width = %d, want <= %d: %q", gotWidth, width, plain[0])
329			}
330
331			limited := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width))
332			if len(limited) != 1 || limited[0] != plain[0] || strings.Contains(limited[0], "more active") {
333				t.Fatalf("limited activity lines = %#v, want preserved activity row %#v without overflow", limited, plain)
334			}
335		})
336	}
337}
338
339func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
340	started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
341	now := started.Add(2 * time.Second)
342	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
343		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
344		TurnStartedAt: started, ActivityUpdatedAt: started, WaitingForModel: true,
345	}}, 80, now)
346	got := strings.Join(plainResidentIndicatorLines(lines), "\n")
347	if !strings.Contains(got, "zzZz") {
348		t.Fatalf("model-wait indicator = %q, want animated waiting frame", got)
349	}
350}
351
352func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testing.T) {
353	now := time.Date(2026, time.August, 13, 12, 0, 2, 0, time.UTC)
354	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
355		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
356		TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now.Add(-time.Second),
357	}}, 80, now)
358	got := strings.Join(plainResidentIndicatorLines(lines), "\n")
359	if !strings.Contains(got, "/ reviewer · running · activity 1s ago · 1m00s") || strings.Contains(got, "Zzzz") {
360		t.Fatalf("tool-work indicator = %q", got)
361	}
362}
363
364func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
365	lines := []string{"  one", "  two", "  three", "  four"}
366	bottomRows := func(candidate []string) int { return 2 + len(candidate) }
367
368	allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
369	if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
370		t.Fatalf("fit lines = %#v, want all activity rows", allFit)
371	}
372
373	compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
374	wantCompact := []string{"  one", "  … 3 more active subagents"}
375	if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
376		t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
377	}
378}
379
380func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
381	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
382	want := []string{"one", "  … 2 more active subagents"}
383	if strings.Join(got, "\n") != strings.Join(want, "\n") {
384		t.Fatalf("limited lines = %#v, want %#v", got, want)
385	}
386}
387
388func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
389	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
390	want := []string{"  … 3 more active subagents"}
391	if strings.Join(got, "\n") != strings.Join(want, "\n") {
392		t.Fatalf("limited lines = %#v, want %#v", got, want)
393	}
394}
395
396func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
397	lines := []string{"  agent one", "    usage one", "  agent two", "    usage two"}
398	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
399	want := []string{"  agent one", "    usage one", "  agent two"}
400	if strings.Join(got, "\n") != strings.Join(want, "\n") {
401		t.Fatalf("limited lines = %#v, want %#v", got, want)
402	}
403}
404
405func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
406	lines := []string{"  agent one", "    usage one", "    usage continued", "  agent two", "    usage two"}
407	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
408	want := []string{"  agent one", "  … 2 more active subagents"}
409	if strings.Join(got, "\n") != strings.Join(want, "\n") {
410		t.Fatalf("limited lines = %#v, want %#v", got, want)
411	}
412}
413
414func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {
415	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))
416	want := []string{"one", "  … 6 more active subagents"}
417	if strings.Join(got, "\n") != strings.Join(want, "\n") {
418		t.Fatalf("limited lines = %#v, want %#v", got, want)
419	}
420}
421
422func plainResidentIndicatorLines(lines []string) []string {
423	plain := make([]string, len(lines))
424	for index, line := range lines {
425		plain[index] = stripANSIBytes(line)
426	}
427	return plain
428}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close