Merge pull request #121 from bnema/integrate/9fa3d049-zut-shutdown
fix(agent): streamline interactive shutdown
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 | |||
| 1772 | 1772 | persistMu.Lock() | |
| 1773 | 1773 | current := sess | |
| 1774 | 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 | 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 | 2713 | return runErr | |
| 2710 | 2714 | } | |
| 2711 | 2715 | ||
| 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 == "" { | |
| 2714 | 2718 | return "" | |
| 2715 | 2719 | } | |
| 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 { | |
| 2718 | 2722 | return "" | |
| 2719 | 2723 | } | |
| 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 { | |
| 2722 | 2726 | return "" | |
| 2723 | 2727 | } | |
| 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 "" | |
| 2732 | 2734 | } | |
| 2733 | - | return filepath.Clean(left) == filepath.Clean(right) | |
| 2735 | + | return sess.ID | |
| 2734 | 2736 | } | |
| 2735 | 2737 | ||
| 2736 | 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 | 2742 | func agentSessionsRoot(root string, args Args) string { | |
| @@ -2871,12 +2873,16 @@ func writeNewTranscriptLocked(ag *core.Agent, sess *core.Session, from int) (nex | |||
| 2871 | 2873 | if sess == nil || ag == nil { | |
| 2872 | 2874 | return next, nil | |
| 2873 | 2875 | } | |
| 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) | |
| 2878 | 2881 | } | |
| 2879 | - | next = i + 1 | |
| 2882 | + | next = index + 1 | |
| 2883 | + | } | |
| 2884 | + | if len(msgs) == 0 { | |
| 2885 | + | next = total | |
| 2880 | 2886 | } | |
| 2881 | 2887 | cum := ag.Cost() | |
| 2882 | 2888 | 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 := sess | 1773 | 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 runErr | 2713 | 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.ID | 2728 | + | 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, nil | 2874 | 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 + 1 | 2882 | + | 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) { | |||
| 322 | 322 | if _, err := os.Stat(empty.Path); !os.IsNotExist(err) { | |
| 323 | 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 | 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 | 340 | if err := arbitrary.Close(); err != nil { | |
| 341 | 341 | t.Fatal(err) | |
| 342 | 342 | } | |
| 343 | - | if got := resumableSessionID(root, arbitrary); got != "" { | |
| 343 | + | if got := resumableSessionID(root, arbitrary, true); got != "" { | |
| 344 | 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 | 390 | if sess == nil || sess.Path != path { | |
| 391 | 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 | 394 | t.Fatalf("persisted empty session hint ID = %q, want empty", got) | |
| 395 | 395 | } | |
| 396 | 396 | if err := sess.Close(); err != nil { | |
| @@ -446,12 +446,33 @@ func TestOpenOrCreateSessionUUIDLookupReturnsMetadataErrors(t *testing.T) { | |||
| 446 | 446 | ||
| 447 | 447 | func TestResumeSessionHint(t *testing.T) { | |
| 448 | 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 | 450 | if got := resumeSessionHint(id); got != want { | |
| 451 | 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 | 476 | func TestTrimMessagesForResumeCarriesDeferredToolActivation(t *testing.T) { | |
| 456 | 477 | msgs := make([]provider.Message, 0, 101) | |
| 457 | 478 | 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 { | |||
| 1092 | 1092 | if seq := tui.ReportCWD(i.cfg.CWD); seq != "" { | |
| 1093 | 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 | 1099 | i.applyInputCursorColor() | |
| 1097 | 1100 | ||
| 1098 | 1101 | // 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 rate | 1101 | // 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 { | |||
| 278 | 278 | 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 | 304 | // Revision returns a monotonically increasing transcript version. | |
| 282 | 305 | // It is cheap to query and changes whenever Messages() would return | |
| 283 | 306 | // different transcript content because of append/set operations. | |
| @@ -278,6 +278,29 @@ func (a *Agent) Messages() []provider.Message { | |||||
| 278 | return out | 278 | 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 return | 305 | // 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. | ||