fix(agent): streamline interactive shutdown
- commit
- 877422982d093535fe68b0ad6137417fa43a9108
- author
- brice · · 2026-08-12 12:26 UTC
- parent
- c55f92f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +81 -32
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 -5
| @@ -1092,11 +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 | - | // Erase the live frame on exit without moving the cursor. The shell can | |
| 1096 | - | // then draw its prompt on the row where zot ended instead of leaving the | |
| 1097 | - | // inactive TUI visible above it. Do not erase scrollback: users should | |
| 1098 | - | // still be able to review the session after closing zot. | |
| 1099 | - | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + 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)) | |
| 1100 | 1099 | i.applyInputCursorColor() | |
| 1101 | 1100 | ||
| 1102 | 1101 | // Streaming pacer: drains buffered text deltas at a steady rate | |
| @@ -1092,11 +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 | - | // Erase the live frame on exit without moving the cursor. The shell can | 1095 | + | // Erase the live frame and place the cursor deterministically before any |
| 1096 | - | // then draw its prompt on the row where zot ended instead of leaving the | 1096 | + | // exit summary or shell prompt is written. Do not erase scrollback: users |
| 1097 | - | // inactive TUI visible above it. Do not erase scrollback: users should | 1097 | + | // should still be able to review the session after closing zut. |
| 1098 | - | // still be able to review the session after closing zot. | 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)) |
| 1099 | - | defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.SeqShowCursor)) | |||
| 1100 | i.applyInputCursorColor() | 1099 | i.applyInputCursorColor() | ||
| 1101 | 1100 | ||||
| 1102 | // Streaming pacer: drains buffered text deltas at a steady rate | 1101 | // Streaming pacer: drains buffered text deltas at a steady rate | ||
M packages/agent/modes/interactive_terminal_test.go +1 -1
| @@ -41,7 +41,7 @@ func TestInteractiveRunClearsVisibleFrameOnExit(t *testing.T) { | |||
| 41 | 41 | cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + | |
| 42 | 42 | tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + | |
| 43 | 43 | tui.ResetCursorColor() + tui.ResetCursorShape() + | |
| 44 | - | tui.SeqClearScreenNoHome + tui.SeqShowCursor | |
| 44 | + | tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor | |
| 45 | 45 | if got := term.String(); !strings.HasSuffix(got, cleanup) { | |
| 46 | 46 | t.Fatalf("terminal output does not end with frame cleanup\ngot suffix: %q\nwant: %q", got, cleanup) | |
| 47 | 47 | } | |
| @@ -41,7 +41,7 @@ func TestInteractiveRunClearsVisibleFrameOnExit(t *testing.T) { | |||||
| 41 | cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + | 41 | cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + | ||
| 42 | tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + | 42 | tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + | ||
| 43 | tui.ResetCursorColor() + tui.ResetCursorShape() + | 43 | tui.ResetCursorColor() + tui.ResetCursorShape() + | ||
| 44 | - | tui.SeqClearScreenNoHome + tui.SeqShowCursor | 44 | + | tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor |
| 45 | if got := term.String(); !strings.HasSuffix(got, cleanup) { | 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) | 46 | t.Fatalf("terminal output does not end with frame cleanup\ngot suffix: %q\nwant: %q", got, cleanup) | ||
| 47 | } | 47 | } | ||
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. | ||