Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cli): add diff rendering and compact mode improvements

authored by

Cheyan and committed by
Lyric Wai
ecfca419 e071dec0

+1190 -6
+18
agent/engine.go
··· 81 81 } 82 82 } 83 83 84 + func WithOnToolCallStart(fn func(*Context, ToolCall)) Option { 85 + return func(e *Engine) { 86 + if fn != nil { 87 + e.onToolCallStart = fn 88 + } 89 + } 90 + } 91 + 92 + func WithOnToolCallDone(fn func(*Context, ToolCall, string, error)) Option { 93 + return func(e *Engine) { 94 + if fn != nil { 95 + e.onToolCallDone = fn 96 + } 97 + } 98 + } 99 + 84 100 func WithPlanStepUpdate(fn func(*Context, PlanStepUpdate)) Option { 85 101 return func(e *Engine) { 86 102 if fn != nil { ··· 168 184 paramsBuilder func(opts RunOptions) map[string]any 169 185 onToolStart func(ctx *Context, toolName string) 170 186 onToolSuccess func(ctx *Context, toolName string) 187 + onToolCallStart func(ctx *Context, tc ToolCall) 188 + onToolCallDone func(ctx *Context, tc ToolCall, observation string, err error) 171 189 onPlanStepUpdate func(ctx *Context, update PlanStepUpdate) 172 190 fallbackFinal func() *Final 173 191
+131
agent/engine_hooks_test.go
··· 971 971 } 972 972 } 973 973 } 974 + 975 + // ============================================================ 976 + // Tests for OnToolCallStart / OnToolCallDone callbacks 977 + // ============================================================ 978 + 979 + func TestWithOnToolCallStart_SetsField(t *testing.T) { 980 + fn := func(ctx *Context, tc ToolCall) {} 981 + client := newMockClient(finalResponse("ok")) 982 + e := New(client, baseRegistry(), baseCfg(), DefaultPromptSpec(), WithOnToolCallStart(fn)) 983 + if e.onToolCallStart == nil { 984 + t.Fatal("expected onToolCallStart to be set") 985 + } 986 + } 987 + 988 + func TestWithOnToolCallStart_NilIgnored(t *testing.T) { 989 + client := newMockClient(finalResponse("ok")) 990 + e := New(client, baseRegistry(), baseCfg(), DefaultPromptSpec(), WithOnToolCallStart(nil)) 991 + if e.onToolCallStart != nil { 992 + t.Fatal("expected onToolCallStart to remain nil for nil input") 993 + } 994 + } 995 + 996 + func TestWithOnToolCallDone_SetsField(t *testing.T) { 997 + fn := func(ctx *Context, tc ToolCall, observation string, err error) {} 998 + client := newMockClient(finalResponse("ok")) 999 + e := New(client, baseRegistry(), baseCfg(), DefaultPromptSpec(), WithOnToolCallDone(fn)) 1000 + if e.onToolCallDone == nil { 1001 + t.Fatal("expected onToolCallDone to be set") 1002 + } 1003 + } 1004 + 1005 + func TestWithOnToolCallDone_NilIgnored(t *testing.T) { 1006 + client := newMockClient(finalResponse("ok")) 1007 + e := New(client, baseRegistry(), baseCfg(), DefaultPromptSpec(), WithOnToolCallDone(nil)) 1008 + if e.onToolCallDone != nil { 1009 + t.Fatal("expected onToolCallDone to remain nil for nil input") 1010 + } 1011 + } 1012 + 1013 + func TestOnToolCallStart_CalledBeforeExecution(t *testing.T) { 1014 + reg := baseRegistry() 1015 + reg.Register(&mockTool{name: "write", result: "written"}) 1016 + 1017 + var calledName string 1018 + var calledParams map[string]any 1019 + client := newMockClient( 1020 + toolCallResponse("write"), 1021 + finalResponse("done"), 1022 + ) 1023 + 1024 + e := New(client, reg, baseCfg(), DefaultPromptSpec(), 1025 + WithOnToolCallStart(func(ctx *Context, tc ToolCall) { 1026 + calledName = tc.Name 1027 + calledParams = tc.Params 1028 + }), 1029 + ) 1030 + 1031 + _, _, err := e.Run(context.Background(), "test", RunOptions{}) 1032 + if err != nil { 1033 + t.Fatalf("unexpected error: %v", err) 1034 + } 1035 + if calledName != "write" { 1036 + t.Errorf("expected onToolCallStart called with 'write', got %q", calledName) 1037 + } 1038 + if calledParams == nil { 1039 + t.Error("expected onToolCallStart to receive non-nil Params") 1040 + } 1041 + } 1042 + 1043 + func TestOnToolCallDone_CalledAfterSuccess(t *testing.T) { 1044 + reg := baseRegistry() 1045 + reg.Register(&mockTool{name: "search", result: "found"}) 1046 + 1047 + var calledName string 1048 + var calledObs string 1049 + var calledErr error 1050 + client := newMockClient( 1051 + toolCallResponse("search"), 1052 + finalResponse("done"), 1053 + ) 1054 + 1055 + e := New(client, reg, baseCfg(), DefaultPromptSpec(), 1056 + WithOnToolCallDone(func(ctx *Context, tc ToolCall, observation string, err error) { 1057 + calledName = tc.Name 1058 + calledObs = observation 1059 + calledErr = err 1060 + }), 1061 + ) 1062 + 1063 + _, _, err := e.Run(context.Background(), "test", RunOptions{}) 1064 + if err != nil { 1065 + t.Fatalf("unexpected error: %v", err) 1066 + } 1067 + if calledName != "search" { 1068 + t.Errorf("expected onToolCallDone called with 'search', got %q", calledName) 1069 + } 1070 + if calledObs != "found" { 1071 + t.Errorf("expected observation='found', got %q", calledObs) 1072 + } 1073 + if calledErr != nil { 1074 + t.Errorf("expected err=nil, got %v", calledErr) 1075 + } 1076 + } 1077 + 1078 + func TestOnToolCallDone_CalledWithError(t *testing.T) { 1079 + reg := baseRegistry() 1080 + reg.Register(&mockTool{name: "fail", result: "", err: fmt.Errorf("boom")}) 1081 + 1082 + var calledErr error 1083 + client := newMockClient( 1084 + toolCallResponse("fail"), 1085 + finalResponse("done"), 1086 + ) 1087 + 1088 + e := New(client, reg, baseCfg(), DefaultPromptSpec(), 1089 + WithOnToolCallDone(func(ctx *Context, tc ToolCall, observation string, err error) { 1090 + calledErr = err 1091 + }), 1092 + ) 1093 + 1094 + _, _, err := e.Run(context.Background(), "test", RunOptions{}) 1095 + if err != nil { 1096 + t.Fatalf("unexpected error: %v", err) 1097 + } 1098 + if calledErr == nil { 1099 + t.Fatal("expected onToolCallDone to receive error, got nil") 1100 + } 1101 + if calledErr.Error() != "boom" { 1102 + t.Errorf("expected error='boom', got %v", calledErr) 1103 + } 1104 + }
+10
agent/engine_loop.go
··· 374 374 } 375 375 } 376 376 } 377 + if e.onToolCallStart != nil { 378 + for i := range items { 379 + if !items[i].skip { 380 + e.onToolCallStart(st.agentCtx, items[i].tc) 381 + } 382 + } 383 + } 377 384 378 385 // --- Phase 2: concurrent execution --- 379 386 execCtx := ctx ··· 460 467 461 468 if item.err == nil && e.onToolSuccess != nil { 462 469 e.onToolSuccess(st.agentCtx, tc.Name) 470 + } 471 + if e.onToolCallDone != nil { 472 + e.onToolCallDone(st.agentCtx, tc, item.observation, item.err) 463 473 } 464 474 465 475 if item.err == nil && st.agentCtx.Plan != nil && tc.Name != "plan_create" {
+2 -1
cmd/mistermorph/chatcmd/format.go
··· 6 6 "strings" 7 7 8 8 "github.com/quailyquaily/mistermorph/agent" 9 + "github.com/quailyquaily/mistermorph/internal/clifmt" 9 10 ) 10 11 11 12 func formatChatOutput(final *agent.Final) string { ··· 14 15 } 15 16 switch output := final.Output.(type) { 16 17 case string: 17 - return strings.TrimSpace(output) 18 + return clifmt.RenderMarkdown(strings.TrimSpace(output)) 18 19 case nil: 19 20 payload, _ := json.MarshalIndent(final, "", " ") 20 21 return strings.TrimSpace(string(payload))
+1 -1
cmd/mistermorph/chatcmd/repl.go
··· 53 53 sess.setWriter(rl.Stdout()) 54 54 writer := sess.currentWriter() 55 55 56 - printChatSessionHeader(writer, strings.TrimSpace(sess.mainCfg.Model), sess.workspaceDir, sess.fileCacheDir) 56 + printChatSessionHeader(writer, sess.compactMode, strings.TrimSpace(sess.mainCfg.Model), sess.workspaceDir, sess.fileCacheDir) 57 57 58 58 reg := chatcommands.NewRegistry() 59 59 history := make([]llm.Message, 0, 32)
+192
cmd/mistermorph/chatcmd/session.go
··· 6 6 "io" 7 7 "log/slog" 8 8 "os" 9 + "path/filepath" 9 10 "strings" 10 11 "sync" 11 12 "time" 12 13 13 14 "github.com/quailyquaily/mistermorph/agent" 15 + "github.com/quailyquaily/mistermorph/internal/clifmt" 14 16 "github.com/quailyquaily/mistermorph/internal/acpclient" 15 17 "github.com/quailyquaily/mistermorph/internal/configutil" 16 18 "github.com/quailyquaily/mistermorph/internal/llmconfig" ··· 65 67 uiMu sync.Mutex 66 68 stopAnim func() 67 69 setAnimMessage func(string) 70 + fileSnapshots map[string]string // path -> content before write_file 68 71 } 69 72 70 73 func cloneToolRegistry(base *tools.Registry) *tools.Registry { ··· 432 435 } 433 436 })) 434 437 438 + // Capture old file content before write_file or bash executes so we can render diffs. 439 + opts = append(opts, agent.WithOnToolCallStart(func(runCtx *agent.Context, tc agent.ToolCall) { 440 + if sess == nil { 441 + return 442 + } 443 + if tc.Name == "write_file" { 444 + path, _ := tc.Params["path"].(string) 445 + path = sess.resolveWritePath(path) 446 + if path == "" { 447 + return 448 + } 449 + data, err := os.ReadFile(path) 450 + if err != nil { 451 + return // File doesn't exist yet (new file) – nothing to diff. 452 + } 453 + sess.fileSnapshots[path] = string(data) 454 + } else if tc.Name == "bash" { 455 + sess.snapshotProjectFiles() 456 + } 457 + })) 458 + 459 + // Render diff after write_file or bash completes successfully. 460 + opts = append(opts, agent.WithOnToolCallDone(func(runCtx *agent.Context, tc agent.ToolCall, observation string, err error) { 461 + if sess == nil || err != nil { 462 + return 463 + } 464 + if tc.Name == "write_file" { 465 + path, _ := tc.Params["path"].(string) 466 + resolvedPath := sess.resolveWritePath(path) 467 + if resolvedPath == "" { 468 + return 469 + } 470 + oldContent, hadOld := sess.fileSnapshots[resolvedPath] 471 + delete(sess.fileSnapshots, resolvedPath) 472 + if !hadOld { 473 + return // New file – no diff to show. 474 + } 475 + newData, readErr := os.ReadFile(resolvedPath) 476 + if readErr != nil { 477 + return 478 + } 479 + newContent := string(newData) 480 + if oldContent == newContent { 481 + return // No change. 482 + } 483 + writer := sess.currentWriter() 484 + diff := clifmt.RenderDiff(resolvedPath, oldContent, newContent) 485 + if diff != "" { 486 + _, _ = fmt.Fprintln(writer, diff) 487 + } 488 + } else if tc.Name == "bash" { 489 + sess.renderBashDiffs() 490 + } 491 + })) 492 + 435 493 makeEngine := func(engReg *tools.Registry, engClient llm.Client, defaultModel string) *agent.Engine { 436 494 currentPromptSpec := promptSpec 437 495 if sess != nil { ··· 492 550 basePromptSpec: promptSpec, 493 551 promptSpec: promptSpec, 494 552 timeout: timeout, 553 + fileSnapshots: make(map[string]string), 495 554 } 496 555 sess.rebuildPromptSpec() 497 556 sess.engine = sess.makeEngine(sess.toolRegistry, sess.client, sess.mainCfg.Model) ··· 499 558 return sess, nil 500 559 } 501 560 561 + // resolveWritePath tries to resolve a write_file path to an absolute path 562 + // using the session's workspace, file cache, and launch directories. 563 + // It also handles workspace_dir/ and file_state_dir/ aliases like the 564 + // write_file tool does. 565 + func (s *chatSession) resolveWritePath(path string) string { 566 + path = strings.TrimSpace(path) 567 + if path == "" { 568 + return "" 569 + } 570 + if filepath.IsAbs(path) { 571 + return path 572 + } 573 + 574 + // Handle aliases like "workspace_dir/hello.go" or "file_state_dir/config.yaml". 575 + if parts := strings.SplitN(path, "/", 2); len(parts) == 2 { 576 + switch parts[0] { 577 + case "workspace_dir": 578 + if s.workspaceDir != "" { 579 + return filepath.Join(s.workspaceDir, parts[1]) 580 + } 581 + case "file_cache_dir": 582 + if s.fileCacheDir != "" { 583 + return filepath.Join(s.fileCacheDir, parts[1]) 584 + } 585 + case "file_state_dir": 586 + if s.launchDir != "" { 587 + return filepath.Join(s.launchDir, parts[1]) 588 + } 589 + } 590 + } 591 + 592 + candidates := []string{} 593 + if s.workspaceDir != "" { 594 + candidates = append(candidates, filepath.Join(s.workspaceDir, path)) 595 + } 596 + if s.fileCacheDir != "" { 597 + candidates = append(candidates, filepath.Join(s.fileCacheDir, path)) 598 + } 599 + if s.launchDir != "" { 600 + candidates = append(candidates, filepath.Join(s.launchDir, path)) 601 + } 602 + for _, cand := range candidates { 603 + if _, err := os.Stat(cand); err == nil { 604 + return cand 605 + } 606 + } 607 + // Fallback to first candidate even if file doesn't exist yet. 608 + if len(candidates) > 0 { 609 + return candidates[0] 610 + } 611 + return "" 612 + } 613 + 502 614 func (s *chatSession) cleanup() { 503 615 if s.memCleanup != nil { 504 616 s.memCleanup() 505 617 } 506 618 } 619 + 620 + // snapshotProjectFiles scans the project directory and stores the current 621 + // content of all existing text files into fileSnapshots. 622 + func (s *chatSession) snapshotProjectFiles() { 623 + if s == nil { 624 + return 625 + } 626 + dir := s.projectDir() 627 + if dir == "" { 628 + return 629 + } 630 + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { 631 + if err != nil { 632 + return nil 633 + } 634 + if d.IsDir() { 635 + name := d.Name() 636 + if strings.HasPrefix(name, ".") && path != dir { 637 + return filepath.SkipDir 638 + } 639 + if name == "node_modules" || name == "vendor" || name == "target" || name == "dist" || name == "build" { 640 + return filepath.SkipDir 641 + } 642 + return nil 643 + } 644 + info, err := d.Info() 645 + if err != nil { 646 + return nil 647 + } 648 + if info.Size() > 1024*1024 { 649 + return nil 650 + } 651 + data, err := os.ReadFile(path) 652 + if err != nil { 653 + return nil 654 + } 655 + if isBinaryData(data) { 656 + return nil 657 + } 658 + s.fileSnapshots[path] = string(data) 659 + return nil 660 + }) 661 + } 662 + 663 + func isBinaryData(data []byte) bool { 664 + limit := 8192 665 + if len(data) < limit { 666 + limit = len(data) 667 + } 668 + for i := 0; i < limit; i++ { 669 + if data[i] == 0 { 670 + return true 671 + } 672 + } 673 + return false 674 + } 675 + 676 + // renderBashDiffs compares fileSnapshots against current disk content and 677 + // renders diffs for any files that changed during a bash tool call. 678 + func (s *chatSession) renderBashDiffs() { 679 + if s == nil { 680 + return 681 + } 682 + writer := s.currentWriter() 683 + for path, oldContent := range s.fileSnapshots { 684 + newData, err := os.ReadFile(path) 685 + delete(s.fileSnapshots, path) 686 + if err != nil { 687 + continue 688 + } 689 + newContent := string(newData) 690 + if oldContent == newContent { 691 + continue 692 + } 693 + diff := clifmt.RenderDiff(path, oldContent, newContent) 694 + if diff != "" { 695 + _, _ = fmt.Fprintln(writer, diff) 696 + } 697 + } 698 + }
+7 -3
cmd/mistermorph/chatcmd/ui.go
··· 133 133 return stop, setMessage 134 134 } 135 135 136 - func printChatSessionHeader(writer io.Writer, model string, workspaceDir string, fileCacheDir string) { 137 - _, _ = fmt.Fprint(writer, chatBanner) 136 + func printChatSessionHeader(writer io.Writer, compact bool, model string, workspaceDir string, fileCacheDir string) { 137 + if !compact { 138 + _, _ = fmt.Fprint(writer, chatBanner) 139 + } 138 140 if model != "" { 139 141 _, _ = fmt.Fprintf(writer, "model=%s\n", model) 140 142 } ··· 144 146 if fileCacheDir != "" { 145 147 _, _ = fmt.Fprintf(writer, "file_cache_dir=%s\n", fileCacheDir) 146 148 } 147 - _, _ = fmt.Fprintln(writer, "\033[90mInteractive chat started. Press Ctrl+C or type /exit to quit.\033[0m") 149 + if !compact { 150 + _, _ = fmt.Fprintln(writer, "\033[90mInteractive chat started. Press Ctrl+C or type /exit to quit.\033[0m") 151 + } 148 152 }
+14 -1
go.mod
··· 3 3 go 1.25.0 4 4 5 5 require ( 6 + github.com/alecthomas/chroma/v2 v2.23.1 6 7 github.com/aws/aws-sdk-go-v2/config v1.32.16 7 8 github.com/aws/aws-sdk-go-v2/credentials v1.19.15 9 + github.com/charmbracelet/glamour v1.0.0 8 10 github.com/chzyer/readline v1.5.1 9 11 github.com/google/uuid v1.6.0 10 12 github.com/gorilla/websocket v1.5.3 ··· 13 15 github.com/nickalie/go-webpbin v0.0.0-20220110095747-f10016bf2dc1 14 16 github.com/openai/openai-go/v3 v3.2.0 15 17 github.com/quailyquaily/uniai v0.1.21 18 + github.com/sergi/go-diff v1.4.0 16 19 github.com/spf13/cast v1.10.0 17 20 github.com/spf13/cobra v1.8.1 18 21 github.com/spf13/viper v1.19.0 ··· 46 49 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect 47 50 github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect 48 51 github.com/aws/smithy-go v1.25.0 // indirect 52 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 53 + github.com/aymerick/douceur v0.2.0 // indirect 49 54 github.com/bep/debounce v1.2.1 // indirect 55 + github.com/charmbracelet/colorprofile v0.4.1 // indirect 56 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect 57 + github.com/charmbracelet/x/ansi v0.11.4 // indirect 58 + github.com/charmbracelet/x/cellbuf v0.0.14 // indirect 59 + github.com/charmbracelet/x/exp/slice v0.0.0-20260122224438-b01af16209d9 // indirect 60 + github.com/charmbracelet/x/term v0.2.2 // indirect 61 + github.com/clipperhouse/displaywidth v0.7.0 // indirect 62 + github.com/clipperhouse/stringish v0.1.1 // indirect 63 + github.com/clipperhouse/uax29/v2 v2.4.0 // indirect 50 64 github.com/cloudflare/circl v1.6.3 // indirect 51 65 github.com/coder/websocket v1.8.14 // indirect 52 66 github.com/cyphar/filepath-securejoin v0.6.1 // indirect ··· 89 103 github.com/samber/lo v1.52.0 // indirect 90 104 github.com/segmentio/asm v1.2.0 // indirect 91 105 github.com/segmentio/encoding v0.5.3 // indirect 92 - github.com/sergi/go-diff v1.4.0 // indirect 93 106 github.com/shopspring/decimal v1.4.0 // indirect 94 107 github.com/skeema/knownhosts v1.3.2 // indirect 95 108 github.com/sourcegraph/conc v0.3.0 // indirect
+56
go.sum
··· 15 15 github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 16 16 github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= 17 17 github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= 18 + github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 19 + github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 20 + github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= 21 + github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= 22 + github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= 23 + github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 18 24 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 19 25 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 20 26 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= ··· 49 55 github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= 50 56 github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= 51 57 github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 58 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 59 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 60 + github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 61 + github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 62 + github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 63 + github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 52 64 github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= 53 65 github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 66 + github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= 67 + github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= 68 + github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= 69 + github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= 70 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 71 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 72 + github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= 73 + github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= 74 + github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= 75 + github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= 76 + github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= 77 + github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 78 + github.com/charmbracelet/x/exp/slice v0.0.0-20260122224438-b01af16209d9 h1:BBTx26Fy+CW9U3kLiWBuWn9pI9C1NybaS+p/AZeAOkA= 79 + github.com/charmbracelet/x/exp/slice v0.0.0-20260122224438-b01af16209d9/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= 80 + github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 81 + github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 54 82 github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= 55 83 github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 56 84 github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= 57 85 github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 58 86 github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= 59 87 github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 88 + github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= 89 + github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= 90 + github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 91 + github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 92 + github.com/clipperhouse/uax29/v2 v2.4.0 h1:RXqE/l5EiAbA4u97giimKNlmpvkmz+GrBVTelsoXy9g= 93 + github.com/clipperhouse/uax29/v2 v2.4.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 60 94 github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= 61 95 github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 62 96 github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= ··· 68 102 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 69 103 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 70 104 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 105 + github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 106 + github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 71 107 github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= 72 108 github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= 73 109 github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= ··· 110 146 github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 111 147 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 112 148 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 149 + github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 150 + github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 113 151 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 114 152 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 115 153 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 116 154 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 155 + github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 156 + github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 117 157 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 118 158 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 119 159 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= ··· 145 185 github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= 146 186 github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= 147 187 github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 188 + github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 189 + github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 148 190 github.com/lyricat/goutils v1.2.3 h1:bJCYygnCYwELtXrzeA/oW0Xl1aMRMutpzyWqfF5AvJI= 149 191 github.com/lyricat/goutils v1.2.3/go.mod h1:AscmPHLrB2accCEVP4gSI6y3ezcud3zHM1w3t7M/jNU= 150 192 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= ··· 156 198 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 157 199 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 158 200 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 201 + github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 202 + github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 203 + github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 159 204 github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU= 160 205 github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= 206 + github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 207 + github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 161 208 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 162 209 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 163 210 github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= 164 211 github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= 212 + github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 213 + github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 214 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 215 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 165 216 github.com/nickalie/go-binwrapper v0.0.0-20190114141239-525121d43c84 h1:/6MoQlTdk1eAi0J9O89ypO8umkp+H7mpnSF2ggSL62Q= 166 217 github.com/nickalie/go-binwrapper v0.0.0-20190114141239-525121d43c84/go.mod h1:Eeech2fhQ/E4bS8cdc3+SGABQ+weQYGyWBvZ/mNr5uY= 167 218 github.com/nickalie/go-webpbin v0.0.0-20220110095747-f10016bf2dc1 h1:9awJsNP+gYOGCr3pQu9i217bCNsVwoQCmD3h7CYwxOw= ··· 187 238 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 188 239 github.com/quailyquaily/uniai v0.1.21 h1:Z5LarPPOVXp5JjVVkA9foe3rTfjnZRW/2mGzswG308Q= 189 240 github.com/quailyquaily/uniai v0.1.21/go.mod h1:C3kQuLcZ+QvU1+uRmRXkHx3Jo8gaZxa6Da54aUI9nj4= 241 + github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 190 242 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 191 243 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 192 244 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= ··· 260 312 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 261 313 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= 262 314 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= 315 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 316 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 263 317 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 264 318 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 265 319 github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= 266 320 github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 321 + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 322 + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 267 323 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 268 324 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 269 325 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+328
internal/clifmt/diff.go
··· 1 + package clifmt 2 + 3 + import ( 4 + "fmt" 5 + "path/filepath" 6 + "strings" 7 + 8 + "github.com/sergi/go-diff/diffmatchpatch" 9 + ) 10 + 11 + // DiffLine represents a single line in a unified diff output. 12 + type diffLine struct { 13 + kind byte // ' ' context, '-' delete, '+' insert 14 + text string 15 + oldNum int // 0 if not present in old file 16 + newNum int // 0 if not present in new file 17 + } 18 + 19 + // splitLines splits a string into lines, discarding the final empty element 20 + // that strings.Split produces when text ends with a newline. 21 + func splitLines(s string) []string { 22 + if s == "" { 23 + return nil 24 + } 25 + lines := strings.Split(s, "\n") 26 + if len(lines) > 0 && lines[len(lines)-1] == "" { 27 + lines = lines[:len(lines)-1] 28 + } 29 + return lines 30 + } 31 + 32 + // lineDiff computes a line-level diff between oldContent and newContent. 33 + func lineDiff(oldContent, newContent string) []diffLine { 34 + oldLines := splitLines(oldContent) 35 + newLines := splitLines(newContent) 36 + 37 + // Encode each unique line as a single Unicode private-use-area character 38 + // so we can run diffmatchpatch at line granularity reliably. 39 + lineToChar := make(map[string]rune) 40 + charToLine := make(map[rune]string) 41 + nextChar := rune(0xE000) 42 + 43 + getChar := func(line string) rune { 44 + if c, ok := lineToChar[line]; ok { 45 + return c 46 + } 47 + c := nextChar 48 + nextChar++ 49 + lineToChar[line] = c 50 + charToLine[c] = line 51 + return c 52 + } 53 + 54 + var oldChars, newChars []rune 55 + for _, line := range oldLines { 56 + oldChars = append(oldChars, getChar(line)) 57 + } 58 + for _, line := range newLines { 59 + newChars = append(newChars, getChar(line)) 60 + } 61 + 62 + dmp := diffmatchpatch.New() 63 + diffs := dmp.DiffMain(string(oldChars), string(newChars), false) 64 + diffs = dmp.DiffCleanupSemantic(diffs) 65 + 66 + var result []diffLine 67 + oldNum := 0 68 + newNum := 0 69 + 70 + for _, diff := range diffs { 71 + for _, c := range diff.Text { 72 + line := charToLine[c] 73 + switch diff.Type { 74 + case diffmatchpatch.DiffEqual: 75 + oldNum++ 76 + newNum++ 77 + result = append(result, diffLine{kind: ' ', text: line, oldNum: oldNum, newNum: newNum}) 78 + case diffmatchpatch.DiffDelete: 79 + oldNum++ 80 + result = append(result, diffLine{kind: '-', text: line, oldNum: oldNum, newNum: 0}) 81 + case diffmatchpatch.DiffInsert: 82 + newNum++ 83 + result = append(result, diffLine{kind: '+', text: line, oldNum: 0, newNum: newNum}) 84 + } 85 + } 86 + } 87 + 88 + return result 89 + } 90 + 91 + // foldContext folds unchanged lines that are farther than `context` lines away 92 + // from any change, replacing them with a single fold marker line. 93 + func foldContext(lines []diffLine, context int) []diffLine { 94 + var changedIndices []int 95 + for i, dl := range lines { 96 + if dl.kind != ' ' { 97 + changedIndices = append(changedIndices, i) 98 + } 99 + } 100 + 101 + if len(changedIndices) == 0 { 102 + return nil 103 + } 104 + 105 + visible := make(map[int]bool, len(lines)) 106 + for _, idx := range changedIndices { 107 + for j := idx - context; j <= idx+context; j++ { 108 + if j >= 0 && j < len(lines) { 109 + visible[j] = true 110 + } 111 + } 112 + } 113 + 114 + var result []diffLine 115 + lastVisible := -1 116 + for i := 0; i < len(lines); i++ { 117 + if visible[i] { 118 + if (lastVisible != -1 && i > lastVisible+1) || (lastVisible == -1 && i > 0) { 119 + // Insert a single fold marker for the gap. 120 + result = append(result, diffLine{kind: 0}) 121 + } 122 + result = append(result, lines[i]) 123 + lastVisible = i 124 + } 125 + } 126 + 127 + return result 128 + } 129 + 130 + func extToLang(ext string) string { 131 + switch ext { 132 + case ".go": 133 + return "go" 134 + case ".py": 135 + return "python" 136 + case ".js": 137 + return "javascript" 138 + case ".ts": 139 + return "typescript" 140 + case ".jsx": 141 + return "jsx" 142 + case ".tsx": 143 + return "tsx" 144 + case ".rs": 145 + return "rust" 146 + case ".java": 147 + return "java" 148 + case ".c", ".h": 149 + return "c" 150 + case ".cpp", ".hpp", ".cc", ".cxx": 151 + return "cpp" 152 + case ".rb": 153 + return "ruby" 154 + case ".sh", ".bash": 155 + return "bash" 156 + case ".zsh": 157 + return "zsh" 158 + case ".json": 159 + return "json" 160 + case ".yaml", ".yml": 161 + return "yaml" 162 + case ".toml": 163 + return "toml" 164 + case ".md": 165 + return "markdown" 166 + case ".html", ".htm": 167 + return "html" 168 + case ".css": 169 + return "css" 170 + case ".sql": 171 + return "sql" 172 + case ".php": 173 + return "php" 174 + case ".swift": 175 + return "swift" 176 + case ".kt": 177 + return "kotlin" 178 + case ".scala": 179 + return "scala" 180 + case ".r": 181 + return "r" 182 + case ".lua": 183 + return "lua" 184 + case ".vim": 185 + return "vim" 186 + case ".dockerfile": 187 + return "dockerfile" 188 + default: 189 + return "" 190 + } 191 + } 192 + 193 + // RenderDiff renders a terminal-friendly unified diff between oldContent and newContent. 194 + // It shows a single line-number column, highlights additions/deletions with full-width 195 + // background color, applies syntax highlighting to code, and folds long stretches of 196 + // unchanged context. 197 + func RenderDiff(path, oldContent, newContent string) string { 198 + lines := lineDiff(oldContent, newContent) 199 + if len(lines) == 0 { 200 + return "" 201 + } 202 + 203 + folded := foldContext(lines, 3) 204 + 205 + // Compute gutter width based on the largest line number. 206 + maxNum := 0 207 + for _, dl := range lines { 208 + if dl.oldNum > maxNum { 209 + maxNum = dl.oldNum 210 + } 211 + if dl.newNum > maxNum { 212 + maxNum = dl.newNum 213 + } 214 + } 215 + gutterWidth := len(fmt.Sprintf("%d", maxNum)) 216 + if gutterWidth < 3 { 217 + gutterWidth = 3 218 + } 219 + 220 + color := useColor() 221 + 222 + // Syntax highlighting by language (only when color is enabled). 223 + lang := extToLang(filepath.Ext(path)) 224 + oldHL := make(map[int]string) 225 + newHL := make(map[int]string) 226 + if color && lang != "" { 227 + if oldContent != "" { 228 + if highlighted, err := highlightCode(oldContent, lang); err == nil { 229 + hlLines := strings.Split(strings.TrimRight(highlighted, "\n"), "\n") 230 + for i, line := range hlLines { 231 + oldHL[i+1] = line 232 + } 233 + } 234 + } 235 + if newContent != "" { 236 + if highlighted, err := highlightCode(newContent, lang); err == nil { 237 + hlLines := strings.Split(strings.TrimRight(highlighted, "\n"), "\n") 238 + for i, line := range hlLines { 239 + newHL[i+1] = line 240 + } 241 + } 242 + } 243 + } 244 + 245 + var b strings.Builder 246 + 247 + gray := "" 248 + if color { 249 + gray = "\x1b[38;5;250m" 250 + } 251 + 252 + // File header 253 + if color { 254 + b.WriteString(fmt.Sprintf("%s%s\x1b[0m\n", gray, path)) 255 + } else { 256 + b.WriteString(path + "\n") 257 + } 258 + 259 + for _, dl := range folded { 260 + if dl.kind == 0 { 261 + continue 262 + } 263 + 264 + // Determine line number and highlighted text. 265 + var lineNum int 266 + var text string 267 + switch dl.kind { 268 + case '-': 269 + lineNum = dl.oldNum 270 + if hl, ok := oldHL[dl.oldNum]; ok { 271 + text = hl 272 + } else { 273 + text = dl.text 274 + } 275 + case '+': 276 + lineNum = dl.newNum 277 + if hl, ok := newHL[dl.newNum]; ok { 278 + text = hl 279 + } else { 280 + text = dl.text 281 + } 282 + default: 283 + lineNum = dl.newNum 284 + if hl, ok := newHL[dl.newNum]; ok { 285 + text = hl 286 + } else { 287 + text = dl.text 288 + } 289 + } 290 + 291 + switch dl.kind { 292 + case '-': 293 + if color { 294 + bg := "\x1b[48;5;52m" 295 + fg := "\x1b[38;5;210m" 296 + b.WriteString(bg) 297 + b.WriteString(fmt.Sprintf("%s%*d%s - ", gray, gutterWidth, lineNum, fg)) 298 + safeText := strings.ReplaceAll(text, "\x1b[0m", "\x1b[39m"+bg+fg) 299 + b.WriteString(safeText) 300 + b.WriteString("\x1b[K\x1b[0m") 301 + } else { 302 + b.WriteString(fmt.Sprintf("%*d - %s", gutterWidth, lineNum, text)) 303 + } 304 + case '+': 305 + if color { 306 + bg := "\x1b[48;5;22m" 307 + fg := "\x1b[38;5;150m" 308 + b.WriteString(bg) 309 + b.WriteString(fmt.Sprintf("%s%*d%s + ", gray, gutterWidth, lineNum, fg)) 310 + safeText := strings.ReplaceAll(text, "\x1b[0m", "\x1b[39m"+bg+fg) 311 + b.WriteString(safeText) 312 + b.WriteString("\x1b[K\x1b[0m") 313 + } else { 314 + b.WriteString(fmt.Sprintf("%*d + %s", gutterWidth, lineNum, text)) 315 + } 316 + default: 317 + if color { 318 + b.WriteString(fmt.Sprintf("%s%*d\x1b[0m ", gray, gutterWidth, lineNum)) 319 + } else { 320 + b.WriteString(fmt.Sprintf("%*d ", gutterWidth, lineNum)) 321 + } 322 + b.WriteString(text) 323 + } 324 + b.WriteByte('\n') 325 + } 326 + 327 + return b.String() 328 + }
+157
internal/clifmt/diff_test.go
··· 1 + package clifmt 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestLineDiff_Basic(t *testing.T) { 9 + old := "line1\nline2\nline3\n" 10 + new := "line1\nline2modified\nline3\n" 11 + 12 + lines := lineDiff(old, new) 13 + if len(lines) == 0 { 14 + t.Fatal("expected non-empty diff") 15 + } 16 + 17 + var hasDelete, hasInsert bool 18 + for _, dl := range lines { 19 + if dl.kind == '-' && strings.Contains(dl.text, "line2") { 20 + hasDelete = true 21 + } 22 + if dl.kind == '+' && strings.Contains(dl.text, "line2modified") { 23 + hasInsert = true 24 + } 25 + } 26 + if !hasDelete { 27 + t.Error("expected a deleted line containing 'line2'") 28 + } 29 + if !hasInsert { 30 + t.Error("expected an inserted line containing 'line2modified'") 31 + } 32 + } 33 + 34 + func TestLineDiff_LineNumbers(t *testing.T) { 35 + old := "a\nb\nc\n" 36 + new := "a\nB\nc\n" 37 + 38 + lines := lineDiff(old, new) 39 + 40 + for _, dl := range lines { 41 + if dl.kind == ' ' { 42 + if dl.oldNum == 0 || dl.newNum == 0 { 43 + t.Errorf("context line should have both old and new line numbers, got old=%d new=%d", dl.oldNum, dl.newNum) 44 + } 45 + } 46 + if dl.kind == '-' { 47 + if dl.oldNum == 0 { 48 + t.Error("deleted line should have old line number") 49 + } 50 + if dl.newNum != 0 { 51 + t.Error("deleted line should not have new line number") 52 + } 53 + } 54 + if dl.kind == '+' { 55 + if dl.newNum == 0 { 56 + t.Error("inserted line should have new line number") 57 + } 58 + if dl.oldNum != 0 { 59 + t.Error("inserted line should not have old line number") 60 + } 61 + } 62 + } 63 + } 64 + 65 + func TestFoldContext(t *testing.T) { 66 + lines := []diffLine{ 67 + {kind: ' ', text: "a", oldNum: 1, newNum: 1}, 68 + {kind: ' ', text: "b", oldNum: 2, newNum: 2}, 69 + {kind: ' ', text: "c", oldNum: 3, newNum: 3}, 70 + {kind: ' ', text: "d", oldNum: 4, newNum: 4}, 71 + {kind: ' ', text: "e", oldNum: 5, newNum: 5}, 72 + {kind: ' ', text: "f", oldNum: 6, newNum: 6}, 73 + {kind: ' ', text: "g", oldNum: 7, newNum: 7}, 74 + {kind: ' ', text: "h", oldNum: 8, newNum: 8}, 75 + {kind: ' ', text: "i", oldNum: 9, newNum: 9}, 76 + {kind: '-', text: "j", oldNum: 10, newNum: 0}, 77 + {kind: '+', text: "J", oldNum: 0, newNum: 10}, 78 + {kind: ' ', text: "k", oldNum: 11, newNum: 11}, 79 + {kind: ' ', text: "l", oldNum: 12, newNum: 12}, 80 + } 81 + 82 + folded := foldContext(lines, 2) 83 + 84 + if len(folded) == 0 { 85 + t.Fatal("expected non-empty folded result") 86 + } 87 + 88 + var hasFoldMarker bool 89 + var hasJ bool 90 + for _, dl := range folded { 91 + if dl.kind == 0 { 92 + hasFoldMarker = true 93 + } 94 + if dl.kind == '-' && dl.text == "j" { 95 + hasJ = true 96 + } 97 + } 98 + if !hasFoldMarker { 99 + t.Error("expected a fold marker for distant context lines") 100 + } 101 + if !hasJ { 102 + t.Error("expected the change (line 'j') to be present") 103 + } 104 + } 105 + 106 + func TestFoldContext_NoChange(t *testing.T) { 107 + lines := []diffLine{ 108 + {kind: ' ', text: "a", oldNum: 1, newNum: 1}, 109 + {kind: ' ', text: "b", oldNum: 2, newNum: 2}, 110 + } 111 + folded := foldContext(lines, 2) 112 + if folded != nil { 113 + t.Error("expected nil when there are no changes to fold around") 114 + } 115 + } 116 + 117 + func TestRenderDiff_WorksWithoutColor(t *testing.T) { 118 + // In test environments stdout is not a terminal, so useColor() is false. 119 + // RenderDiff should still produce a plain-text diff (no ANSI codes). 120 + result := RenderDiff("hello.go", "a\nb\nc\n", "a\nB\nc\n") 121 + if result == "" { 122 + t.Fatal("expected non-empty diff even when color is disabled") 123 + } 124 + if strings.Contains(result, "\x1b[") { 125 + t.Error("expected plain-text diff without ANSI codes when color is disabled") 126 + } 127 + if !strings.Contains(result, "-") { 128 + t.Error("expected diff to contain deletion marker") 129 + } 130 + if !strings.Contains(result, "+") { 131 + t.Error("expected diff to contain insertion marker") 132 + } 133 + } 134 + 135 + func TestSplitLines(t *testing.T) { 136 + tests := []struct { 137 + input string 138 + expected []string 139 + }{ 140 + {"a\nb\nc\n", []string{"a", "b", "c"}}, 141 + {"a\nb\nc", []string{"a", "b", "c"}}, 142 + {"", nil}, 143 + {"single", []string{"single"}}, 144 + } 145 + for _, tt := range tests { 146 + got := splitLines(tt.input) 147 + if len(got) != len(tt.expected) { 148 + t.Errorf("splitLines(%q) = %v, want %v", tt.input, got, tt.expected) 149 + continue 150 + } 151 + for i := range got { 152 + if got[i] != tt.expected[i] { 153 + t.Errorf("splitLines(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.expected[i]) 154 + } 155 + } 156 + } 157 + }
+274
internal/clifmt/syntax.go
··· 1 + package clifmt 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "regexp" 7 + "strings" 8 + 9 + "github.com/alecthomas/chroma/v2" 10 + "github.com/mattn/go-runewidth" 11 + "github.com/alecthomas/chroma/v2/formatters" 12 + "github.com/alecthomas/chroma/v2/lexers" 13 + "github.com/alecthomas/chroma/v2/styles" 14 + ) 15 + 16 + var codeBlockRe = regexp.MustCompile("(?s)```(\\w*)\\n(.*?)```") 17 + var ansiRe = regexp.MustCompile("(?s)\x1b\\[[0-9;]*[mK]") 18 + 19 + // HighlightCodeBlocks finds markdown code blocks in text and applies syntax highlighting, 20 + // borders, and line numbers. It also handles plain code (without markdown fences) by 21 + // auto-detecting code-like content. 22 + func HighlightCodeBlocks(text string) string { 23 + if !useColor() { 24 + return text 25 + } 26 + 27 + // First, handle explicit markdown code blocks 28 + hasCodeBlocks := codeBlockRe.MatchString(text) 29 + if hasCodeBlocks { 30 + return codeBlockRe.ReplaceAllStringFunc(text, func(block string) string { 31 + matches := codeBlockRe.FindStringSubmatch(block) 32 + if len(matches) != 3 { 33 + return block 34 + } 35 + lang := strings.TrimSpace(matches[1]) 36 + code := matches[2] 37 + highlighted, err := highlightCode(code, lang) 38 + if err != nil { 39 + return block 40 + } 41 + return "\n" + wrapInBox(highlighted, lang) + "\n" 42 + }) 43 + } 44 + 45 + // No markdown code blocks found - check if the entire text looks like code 46 + if looksLikeCode(text) { 47 + highlighted, err := highlightCode(text, "") 48 + if err != nil { 49 + return text 50 + } 51 + return "\n" + wrapInBox(highlighted, "") + "\n" 52 + } 53 + 54 + return text 55 + } 56 + 57 + // looksLikeCodeBlock checks if a specific text segment looks like source code. 58 + // It is stricter than looksLikeCode and requires at least 3 lines. 59 + func looksLikeCodeBlock(text string) bool { 60 + lines := strings.Split(text, "\n") 61 + if len(lines) < 3 { 62 + return false 63 + } 64 + return looksLikeCode(text) 65 + } 66 + 67 + func highlightCode(src, language string) (string, error) { 68 + var lexer chroma.Lexer 69 + if language != "" { 70 + lexer = lexers.Get(language) 71 + } 72 + if lexer == nil { 73 + lexer = lexers.Analyse(src) 74 + } 75 + if lexer == nil { 76 + lexer = lexers.Fallback 77 + } 78 + lexer = chroma.Coalesce(lexer) 79 + 80 + style := styles.Get("monokai") 81 + if style == nil { 82 + style = styles.Fallback 83 + } 84 + 85 + formatter := formatters.Get("terminal16m") 86 + if formatter == nil { 87 + formatter = formatters.Fallback 88 + } 89 + 90 + iterator, err := lexer.Tokenise(nil, src) 91 + if err != nil { 92 + return "", err 93 + } 94 + 95 + var buf bytes.Buffer 96 + if err := formatter.Format(&buf, style, iterator); err != nil { 97 + return "", err 98 + } 99 + return buf.String(), nil 100 + } 101 + 102 + func wrapInBox(highlighted string, lang string) string { 103 + lines := strings.Split(strings.TrimRight(highlighted, "\n"), "\n") 104 + if len(lines) == 0 { 105 + return "" 106 + } 107 + 108 + // Calculate max width for the box 109 + maxWidth := 0 110 + for _, line := range lines { 111 + w := visibleWidth(line) 112 + if w > maxWidth { 113 + maxWidth = w 114 + } 115 + } 116 + 117 + // Line number gutter width 118 + gutterWidth := len(fmt.Sprintf("%d", len(lines))) 119 + if gutterWidth < 2 { 120 + gutterWidth = 2 121 + } 122 + 123 + header := lang 124 + if header == "" { 125 + header = "code" 126 + } 127 + 128 + // Total inner width of the box (excluding the outer 1-char borders on each side) 129 + // | gutter | content | 130 + // totalInnerWidth = (2 for left padding) + (gutterWidth) + (3 for gutter divider separator) + (maxWidth) + (2 for right padding) 131 + totalInnerWidth := 2 + gutterWidth + 3 + maxWidth + 2 132 + 133 + var b strings.Builder 134 + 135 + // Top border 136 + b.WriteString("\x1b[90m┌── \x1b[0m") 137 + b.WriteString("\x1b[1;36m" + header + "\x1b[0m") 138 + topLineLen := totalInnerWidth - 3 - visibleWidth(header) 139 + if topLineLen < 2 { 140 + topLineLen = 2 141 + } 142 + b.WriteString("\x1b[90m " + strings.Repeat("─", topLineLen) + "┐\x1b[0m\n") 143 + 144 + // Content with line numbers and side borders 145 + for i, line := range lines { 146 + lineNum := i + 1 147 + padding := maxWidth - visibleWidth(line) 148 + 149 + // Check for diff markers to color the gutter 150 + cleanLine := stripANSI(line) 151 + gutterColor := "\x1b[90m" // dim gray default 152 + if strings.HasPrefix(cleanLine, "+") { 153 + gutterColor = "\x1b[32m" // green 154 + } else if strings.HasPrefix(cleanLine, "-") { 155 + gutterColor = "\x1b[31m" // red 156 + } 157 + 158 + b.WriteString("\x1b[90m│ \x1b[0m") 159 + b.WriteString(fmt.Sprintf("%s%*d │ \x1b[0m", gutterColor, gutterWidth, lineNum)) 160 + b.WriteString(line) 161 + b.WriteString(strings.Repeat(" ", padding)) 162 + b.WriteString("\x1b[90m │\x1b[0m\n") 163 + } 164 + 165 + // Bottom border 166 + b.WriteString("\x1b[90m└" + strings.Repeat("─", totalInnerWidth) + "┘\x1b[0m") 167 + 168 + return b.String() 169 + } 170 + 171 + func isMarkdownHeader(line string) bool { 172 + if !strings.HasPrefix(line, "#") { 173 + return false 174 + } 175 + // Must be "# " or "## " etc. up to 6 levels 176 + for i := 1; i <= 6; i++ { 177 + prefix := strings.Repeat("#", i) + " " 178 + if strings.HasPrefix(line, prefix) { 179 + return true 180 + } 181 + } 182 + return false 183 + } 184 + 185 + func visibleWidth(s string) int { 186 + stripped := stripANSI(s) 187 + width := 0 188 + for _, r := range stripped { 189 + if r == '\t' { 190 + width += 8 - (width % 8) 191 + } else { 192 + width += runewidth.RuneWidth(r) 193 + } 194 + } 195 + return width 196 + } 197 + 198 + func stripANSI(s string) string { 199 + return ansiRe.ReplaceAllString(s, "") 200 + } 201 + 202 + // looksLikeCode heuristically detects if plain text is source code. 203 + func looksLikeCode(text string) bool { 204 + lines := strings.Split(text, "\n") 205 + if len(lines) < 2 { 206 + return false 207 + } 208 + 209 + codeIndicators := 0 210 + for _, line := range lines { 211 + trimmed := strings.TrimSpace(line) 212 + if trimmed == "" { 213 + continue 214 + } 215 + 216 + // Skip markdown headers (e.g., # Title, ## Subtitle) 217 + if isMarkdownHeader(trimmed) { 218 + continue 219 + } 220 + 221 + // Skip Chinese-only sentences (plain text explanation) 222 + if isChineseSentence(trimmed) { 223 + continue 224 + } 225 + 226 + if strings.HasPrefix(trimmed, "def ") || 227 + strings.HasPrefix(trimmed, "class ") || 228 + strings.HasPrefix(trimmed, "import ") || 229 + strings.HasPrefix(trimmed, "from ") || 230 + strings.HasPrefix(trimmed, "function ") || 231 + strings.HasPrefix(trimmed, "const ") || 232 + strings.HasPrefix(trimmed, "let ") || 233 + strings.HasPrefix(trimmed, "var ") || 234 + strings.HasPrefix(trimmed, "#") || 235 + strings.HasPrefix(trimmed, "//") || 236 + strings.HasPrefix(trimmed, "/*") || 237 + strings.HasPrefix(trimmed, "*") || 238 + strings.Contains(line, " ") || 239 + strings.Contains(line, "\t") || 240 + (strings.Contains(line, "(") && strings.Contains(line, ")")) || 241 + (strings.Contains(line, "{") && strings.Contains(line, "}")) || 242 + (strings.Contains(line, "=") && !strings.Contains(line, "==")) { 243 + codeIndicators++ 244 + } 245 + } 246 + 247 + nonEmpty := 0 248 + for _, line := range lines { 249 + if strings.TrimSpace(line) != "" { 250 + nonEmpty++ 251 + } 252 + } 253 + return nonEmpty > 0 && float64(codeIndicators)/float64(nonEmpty) > 0.3 254 + } 255 + 256 + // isChineseSentence checks if a line is a Chinese sentence (plain text, not code). 257 + func isChineseSentence(line string) bool { 258 + // Count Chinese characters and ASCII code symbols 259 + chineseCount := 0 260 + codeSymbolCount := 0 261 + for _, r := range line { 262 + if r >= 0x4e00 && r <= 0x9fff { 263 + chineseCount++ 264 + } 265 + if strings.ContainsRune("(){}[];:=+-*/%<>!&|", r) { 266 + codeSymbolCount++ 267 + } 268 + } 269 + // If mostly Chinese characters and few code symbols, it's a sentence 270 + if chineseCount >= 3 && codeSymbolCount == 0 { 271 + return true 272 + } 273 + return false 274 + }