detached d6b93bc

Merge pull request #121 from bnema/integrate/9fa3d049-zut-shutdown

fix(agent): streamline interactive shutdown
commit
d6b93bcda3279ebfc2e3ef2994f0db522de82dec
author
bnema · · 2026-08-12 12:29 UTC
committer
GitHub · · 2026-08-12 12:29 UTC
merge of
84a9305 + 8774229 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +128 -27
M packages/agent/cli.go +28 -22
@@ -1772,7 +1772,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
17721772 persistMu.Lock()
17731773 current := sess
17741774 persistMu.Unlock()
1775- if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {
1775+ hasMessages := false
1776+ if finalAg := iv.Agent(); finalAg != nil {
1777+ hasMessages = finalAg.MessageCount() > 0
1778+ }
1779+ if resumeID := resumableSessionID(ZutHome(), current, hasMessages); resumeID != "" {
17761780 fmt.Print(resumeSessionHint(resumeID))
17771781 }
17781782 }()
@@ -2709,32 +2713,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
27092713 return runErr
27102714 }
27112715
2712-func resumableSessionID(root string, sess *core.Session) string {
2713- if sess == nil || sess.ID == "" || sess.Path == "" {
2716+func resumableSessionID(root string, sess *core.Session, hasMessages bool) string {
2717+ if !hasMessages || sess == nil || sess.ID == "" || sess.Path == "" {
27142718 return ""
27152719 }
2716- path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)
2717- if err != nil || !sameSessionPath(path, sess.Path) {
2720+ managedRoot, err := filepath.Abs(filepath.Join(root, "sessions"))
2721+ if err != nil {
27182722 return ""
27192723 }
2720- snapshot, err := core.ReadSessionSnapshot(path)
2721- if err != nil || len(snapshot.Messages) == 0 {
2724+ path, err := filepath.Abs(sess.Path)
2725+ if err != nil {
27222726 return ""
27232727 }
2724- return sess.ID
2725-}
2726-
2727-func sameSessionPath(left, right string) bool {
2728- leftAbs, leftErr := filepath.Abs(left)
2729- rightAbs, rightErr := filepath.Abs(right)
2730- if leftErr == nil && rightErr == nil {
2731- return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2728+ rel, err := filepath.Rel(managedRoot, path)
2729+ if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
2730+ return ""
2731+ }
2732+ if _, err := os.Stat(path); err != nil {
2733+ return ""
27322734 }
2733- return filepath.Clean(left) == filepath.Clean(right)
2735+ return sess.ID
27342736 }
27352737
27362738 func resumeSessionHint(id string) string {
2737- return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)
2739+ return fmt.Sprintf("Session ID: %s\nResume with: zut --resume %s\n\n", id, id)
27382740 }
27392741
27402742 func agentSessionsRoot(root string, args Args) string {
@@ -2871,12 +2873,16 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex
28712873 if sess == nil || ag == nil {
28722874 return next, nil
28732875 }
2874- msgs := ag.Messages()
2875- for i := from; i < len(msgs); i++ {
2876- if err := sess.AppendMessage(msgs[i]); err != nil {
2877- return next, fmt.Errorf("append transcript message %d: %w", i, err)
2876+ msgs, total := ag.MessagesFrom(from)
2877+ for offset, msg := range msgs {
2878+ index := from + offset
2879+ if err := sess.AppendMessage(msg); err != nil {
2880+ return next, fmt.Errorf("append transcript message %d: %w", index, err)
28782881 }
2879- next = i + 1
2882+ next = index + 1
2883+ }
2884+ if len(msgs) == 0 {
2885+ next = total
28802886 }
28812887 cum := ag.Cost()
28822888 if err := sess.AppendUsage(cum, cum); err != nil {
@@ -1772,7 +1772,11 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1772 persistMu.Lock()1772 persistMu.Lock()
1773 current := sess1773 current := sess
1774 persistMu.Unlock()1774 persistMu.Unlock()
1775- if resumeID := resumableSessionID(ZutHome(), current); resumeID != "" {1775+ hasMessages := false
1776+ if finalAg := iv.Agent(); finalAg != nil {
1777+ hasMessages = finalAg.MessageCount() > 0
1778+ }
1779+ if resumeID := resumableSessionID(ZutHome(), current, hasMessages); resumeID != "" {
1776 fmt.Print(resumeSessionHint(resumeID))1780 fmt.Print(resumeSessionHint(resumeID))
1777 }1781 }
1778 }()1782 }()
@@ -2709,32 +2713,30 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2709 return runErr2713 return runErr
2710 }2714 }
2711 2715
2712-func resumableSessionID(root string, sess *core.Session) string {2716+func resumableSessionID(root string, sess *core.Session, hasMessages bool) string {
2713- if sess == nil || sess.ID == "" || sess.Path == "" {2717+ if !hasMessages || sess == nil || sess.ID == "" || sess.Path == "" {
2714 return ""2718 return ""
2715 }2719 }
2716- path, err := core.FindManagedSessionByID(context.Background(), root, sess.ID)2720+ managedRoot, err := filepath.Abs(filepath.Join(root, "sessions"))
2717- if err != nil || !sameSessionPath(path, sess.Path) {2721+ if err != nil {
2718 return ""2722 return ""
2719 }2723 }
2720- snapshot, err := core.ReadSessionSnapshot(path)2724+ path, err := filepath.Abs(sess.Path)
2721- if err != nil || len(snapshot.Messages) == 0 {2725+ if err != nil {
2722 return ""2726 return ""
2723 }2727 }
2724- return sess.ID2728+ rel, err := filepath.Rel(managedRoot, path)
2725-}2729+ if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
2726-2730+ return ""
2727-func sameSessionPath(left, right string) bool {2731+ }
2728- leftAbs, leftErr := filepath.Abs(left)2732+ if _, err := os.Stat(path); err != nil {
2729- rightAbs, rightErr := filepath.Abs(right)2733+ return ""
2730- if leftErr == nil && rightErr == nil {
2731- return filepath.Clean(leftAbs) == filepath.Clean(rightAbs)
2732 }2734 }
2733- return filepath.Clean(left) == filepath.Clean(right)2735+ return sess.ID
2734 }2736 }
2735 2737
2736 func resumeSessionHint(id string) string {2738 func resumeSessionHint(id string) string {
2737- return fmt.Sprintf("Resume this session with: zut --resume %s\n", id)2739+ return fmt.Sprintf("Session ID: %s\nResume with: zut --resume %s\n\n", id, id)
2738 }2740 }
2739 2741
2740 func agentSessionsRoot(root string, args Args) string {2742 func agentSessionsRoot(root string, args Args) string {
@@ -2871,12 +2873,16 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex
2871 if sess == nil || ag == nil {2873 if sess == nil || ag == nil {
2872 return next, nil2874 return next, nil
2873 }2875 }
2874- msgs := ag.Messages()2876+ msgs, total := ag.MessagesFrom(from)
2875- for i := from; i < len(msgs); i++ {2877+ for offset, msg := range msgs {
2876- if err := sess.AppendMessage(msgs[i]); err != nil {2878+ index := from + offset
2877- return next, fmt.Errorf("append transcript message %d: %w", i, err)2879+ if err := sess.AppendMessage(msg); err != nil {
2880+ return next, fmt.Errorf("append transcript message %d: %w", index, err)
2878 }2881 }
2879- next = i + 12882+ next = index + 1
2883+ }
2884+ if len(msgs) == 0 {
2885+ next = total
2880 }2886 }
2881 cum := ag.Cost()2887 cum := ag.Cost()
2882 if err := sess.AppendUsage(cum, cum); err != nil {2888 if err := sess.AppendUsage(cum, cum); err != nil {
M packages/agent/cli_session_test.go +25 -4
@@ -322,7 +322,7 @@ func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
322322 if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {
323323 t.Fatalf("empty session stat error = %v, want deleted", err)
324324 }
325- if got := resumableSessionID(root, empty); got != "" {
325+ if got := resumableSessionID(root, empty, false); got != "" {
326326 t.Fatalf("empty session hint ID = %q, want empty", got)
327327 }
328328
@@ -340,7 +340,7 @@ func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
340340 if err := arbitrary.Close(); err != nil {
341341 t.Fatal(err)
342342 }
343- if got := resumableSessionID(root, arbitrary); got != "" {
343+ if got := resumableSessionID(root, arbitrary, true); got != "" {
344344 t.Fatalf("arbitrary session hint ID = %q, want empty", got)
345345 }
346346 }
@@ -390,7 +390,7 @@ func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
390390 if sess == nil || sess.Path != path {
391391 t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
392392 }
393- if got := resumableSessionID(root, sess); got != "" {
393+ if got := resumableSessionID(root, sess, false); got != "" {
394394 t.Fatalf("persisted empty session hint ID = %q, want empty", got)
395395 }
396396 if err := sess.Close(); err != nil {
@@ -446,12 +446,33 @@ func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
446446
447447 func TestResumeSessionHint(t *testing.T) {
448448 id := uuid.NewString()
449- want := "Resume this session with: zut --resume " + id + "\n"
449+ want := "Session ID: " + id + "\nResume with: zut --resume " + id + "\n\n"
450450 if got := resumeSessionHint(id); got != want {
451451 t.Fatalf("resume hint = %q, want %q", got, want)
452452 }
453453 }
454454
455+func TestResumableSessionIDAcceptsManagedPersistedSession(t *testing.T) {
456+ root := t.TempDir()
457+ cwd := t.TempDir()
458+ sess, err := core.NewSession(root, cwd, "provider", "model", "test")
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ if err := sess.AppendMessage(provider.Message{
463+ Role: provider.RoleUser,
464+ Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
465+ }); err != nil {
466+ t.Fatal(err)
467+ }
468+ if err := sess.Close(); err != nil {
469+ t.Fatal(err)
470+ }
471+ if got := resumableSessionID(root, sess, true); got != sess.ID {
472+ t.Fatalf("managed session hint ID = %q, want %q", got, sess.ID)
473+ }
474+}
475+
455476 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
456477 msgs := make([]provider.Message, 0, 101)
457478 msgs = append(msgs, provider.Message{
@@ -322,7 +322,7 @@ func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
322 if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {322 if _, err := os.Stat(empty.Path); !os.IsNotExist(err) {
323 t.Fatalf("empty session stat error = %v, want deleted", err)323 t.Fatalf("empty session stat error = %v, want deleted", err)
324 }324 }
325- if got := resumableSessionID(root, empty); got != "" {325+ if got := resumableSessionID(root, empty, false); got != "" {
326 t.Fatalf("empty session hint ID = %q, want empty", got)326 t.Fatalf("empty session hint ID = %q, want empty", got)
327 }327 }
328 328
@@ -340,7 +340,7 @@ func TestResumeHintSuppressesEmptyAndArbitrarySessions(t *testing.T) {
340 if err := arbitrary.Close(); err != nil {340 if err := arbitrary.Close(); err != nil {
341 t.Fatal(err)341 t.Fatal(err)
342 }342 }
343- if got := resumableSessionID(root, arbitrary); got != "" {343+ if got := resumableSessionID(root, arbitrary, true); got != "" {
344 t.Fatalf("arbitrary session hint ID = %q, want empty", got)344 t.Fatalf("arbitrary session hint ID = %q, want empty", got)
345 }345 }
346 }346 }
@@ -390,7 +390,7 @@ func TestResumeHintSuppressesPersistedEmptyExplicitUUIDSession(t *testing.T) {
390 if sess == nil || sess.Path != path {390 if sess == nil || sess.Path != path {
391 t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)391 t.Fatalf("resumed persisted empty session = %#v, want path %q", sess, path)
392 }392 }
393- if got := resumableSessionID(root, sess); got != "" {393+ if got := resumableSessionID(root, sess, false); got != "" {
394 t.Fatalf("persisted empty session hint ID = %q, want empty", got)394 t.Fatalf("persisted empty session hint ID = %q, want empty", got)
395 }395 }
396 if err := sess.Close(); err != nil {396 if err := sess.Close(); err != nil {
@@ -446,12 +446,33 @@ func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) {
446 446
447 func TestResumeSessionHint(t *testing.T) {447 func TestResumeSessionHint(t *testing.T) {
448 id := uuid.NewString()448 id := uuid.NewString()
449- want := "Resume this session with: zut --resume " + id + "\n"449+ want := "Session ID: " + id + "\nResume with: zut --resume " + id + "\n\n"
450 if got := resumeSessionHint(id); got != want {450 if got := resumeSessionHint(id); got != want {
451 t.Fatalf("resume hint = %q, want %q", got, want)451 t.Fatalf("resume hint = %q, want %q", got, want)
452 }452 }
453 }453 }
454 454
455+func TestResumableSessionIDAcceptsManagedPersistedSession(t *testing.T) {
456+ root := t.TempDir()
457+ cwd := t.TempDir()
458+ sess, err := core.NewSession(root, cwd, "provider", "model", "test")
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ if err := sess.AppendMessage(provider.Message{
463+ Role: provider.RoleUser,
464+ Content: []provider.Content{provider.TextBlock{Text: "resume me"}},
465+ }); err != nil {
466+ t.Fatal(err)
467+ }
468+ if err := sess.Close(); err != nil {
469+ t.Fatal(err)
470+ }
471+ if got := resumableSessionID(root, sess, true); got != sess.ID {
472+ t.Fatalf("managed session hint ID = %q, want %q", got, sess.ID)
473+ }
474+}
475+
455 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {476 func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) {
456 msgs := make([]provider.Message, 0, 101)477 msgs := make([]provider.Message, 0, 101)
457 msgs = append(msgs, provider.Message{478 msgs = append(msgs, provider.Message{
M packages/agent/modes/interactive.go +4 -1
@@ -1092,7 +1092,10 @@ func (i *Interactive) Run(ctx context.Context) error {
10921092 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
10931093 _, _ = term.Write([]byte(seq))
10941094 }
1095- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))
1095+ // Erase the live frame and place the cursor deterministically before any
1096+ // exit summary or shell prompt is written. Do not erase scrollback: users
1097+ // should still be able to review the session after closing zut.
1098+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
10961099 i.applyInputCursorColor()
10971100
10981101 // Streaming pacer: drains buffered text deltas at a steady rate
@@ -1092,7 +1092,10 @@ func (i *Interactive) Run(ctx context.Context) error {
1092 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {1092 if seq := tui.ReportCWD(i.cfg.CWD); seq != "" {
1093 _, _ = term.Write([]byte(seq))1093 _, _ = term.Write([]byte(seq))
1094 }1094 }
1095- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqShowCursor))1095+ // Erase the live frame and place the cursor deterministically before any
1096+ // exit summary or shell prompt is written. Do not erase scrollback: users
1097+ // should still be able to review the session after closing zut.
1098+ defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
1096 i.applyInputCursorColor()1099 i.applyInputCursorColor()
1097 1100
1098 // Streaming pacer: drains buffered text deltas at a steady rate1101 // Streaming pacer: drains buffered text deltas at a steady rate
A packages/agent/modes/interactive_terminal_test.go +48 -0
@@ -0,0 +1,48 @@
1+package modes
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/zut/packages/tui"
13+)
14+
15+type cleanupTestTerminal struct {
16+ bytes.Buffer
17+}
18+
19+func (t *cleanupTestTerminal) Size() (int, int) { return 80, 24 }
20+func (t *cleanupTestTerminal) OnResize(func()) {}
21+func (t *cleanupTestTerminal) SetNonblock(bool) error { return nil }
22+func (t *cleanupTestTerminal) ReadByte() (byte, error) { return 0, io.EOF }
23+func (t *cleanupTestTerminal) EnterRaw() (func() error, error) {
24+ return func() error { return nil }, nil
25+}
26+func (t *cleanupTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
27+ return 0, false, io.EOF
28+}
29+
30+func TestInteractiveRunClearsVisibleFrameOnExit(t *testing.T) {
31+ term := &cleanupTestTerminal{}
32+ interactive := NewInteractive(InteractiveConfig{Terminal: term})
33+ ctx, cancel := context.WithCancel(context.Background())
34+ cancel()
35+
36+ err := interactive.Run(ctx)
37+ if !errors.Is(err, context.Canceled) {
38+ t.Fatalf("Run error = %v, want context.Canceled", err)
39+ }
40+
41+ cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages +
42+ tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff +
43+ tui.ResetCursorColor() + tui.ResetCursorShape() +
44+ tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
45+ if got := term.String(); !strings.HasSuffix(got, cleanup) {
46+ t.Fatalf("terminal output does not end with frame cleanup\ngot suffix: %q\nwant: %q", got, cleanup)
47+ }
48+}
@@ -0,0 +1,48 @@
1+package modes
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/zut/packages/tui"
13+)
14+
15+type cleanupTestTerminal struct {
16+ bytes.Buffer
17+}
18+
19+func (t *cleanupTestTerminal) Size() (int, int) { return 80, 24 }
20+func (t *cleanupTestTerminal) OnResize(func()) {}
21+func (t *cleanupTestTerminal) SetNonblock(bool) error { return nil }
22+func (t *cleanupTestTerminal) ReadByte() (byte, error) { return 0, io.EOF }
23+func (t *cleanupTestTerminal) EnterRaw() (func() error, error) {
24+ return func() error { return nil }, nil
25+}
26+func (t *cleanupTestTerminal) PeekByteTimeout(time.Duration) (byte, bool, error) {
27+ return 0, false, io.EOF
28+}
29+
30+func TestInteractiveRunClearsVisibleFrameOnExit(t *testing.T) {
31+ term := &cleanupTestTerminal{}
32+ interactive := NewInteractive(InteractiveConfig{Terminal: term})
33+ ctx, cancel := context.WithCancel(context.Background())
34+ cancel()
35+
36+ err := interactive.Run(ctx)
37+ if !errors.Is(err, context.Canceled) {
38+ t.Fatalf("Run error = %v, want context.Canceled", err)
39+ }
40+
41+ cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages +
42+ tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff +
43+ tui.ResetCursorColor() + tui.ResetCursorShape() +
44+ tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
45+ if got := term.String(); !strings.HasSuffix(got, cleanup) {
46+ t.Fatalf("terminal output does not end with frame cleanup\ngot suffix: %q\nwant: %q", got, cleanup)
47+ }
48+}
M packages/core/agent.go +23 -0
@@ -278,6 +278,29 @@ func (a *Agent) Messages() []provider.Message {
278278 return out
279279 }
280280
281+// MessageCount returns the current transcript length without copying it.
282+func (a *Agent) MessageCount() int {
283+ a.mu.Lock()
284+ defer a.mu.Unlock()
285+ return len(a.messages)
286+}
287+
288+// MessagesFrom returns a copy of the transcript suffix beginning at from and
289+// the total transcript length observed under the same lock.
290+func (a *Agent) MessagesFrom(from int) ([]provider.Message, int) {
291+ a.mu.Lock()
292+ defer a.mu.Unlock()
293+ if from < 0 {
294+ from = 0
295+ }
296+ if from > len(a.messages) {
297+ from = len(a.messages)
298+ }
299+ out := make([]provider.Message, len(a.messages)-from)
300+ copy(out, a.messages[from:])
301+ return out, len(a.messages)
302+}
303+
281304 // Revision returns a monotonically increasing transcript version.
282305 // It is cheap to query and changes whenever Messages() would return
283306 // different transcript content because of append/set operations.
@@ -278,6 +278,29 @@ func (a *Agent) Messages() []provider.Message {
278 return out278 return out
279 }279 }
280 280
281+// MessageCount returns the current transcript length without copying it.
282+func (a *Agent) MessageCount() int {
283+ a.mu.Lock()
284+ defer a.mu.Unlock()
285+ return len(a.messages)
286+}
287+
288+// MessagesFrom returns a copy of the transcript suffix beginning at from and
289+// the total transcript length observed under the same lock.
290+func (a *Agent) MessagesFrom(from int) ([]provider.Message, int) {
291+ a.mu.Lock()
292+ defer a.mu.Unlock()
293+ if from < 0 {
294+ from = 0
295+ }
296+ if from > len(a.messages) {
297+ from = len(a.messages)
298+ }
299+ out := make([]provider.Message, len(a.messages)-from)
300+ copy(out, a.messages[from:])
301+ return out, len(a.messages)
302+}
303+
281 // Revision returns a monotonically increasing transcript version.304 // Revision returns a monotonically increasing transcript version.
282 // It is cheap to query and changes whenever Messages() would return305 // It is cheap to query and changes whenever Messages() would return
283 // different transcript content because of append/set operations.306 // different transcript content because of append/set operations.

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