diff --git a/go.mod b/go.mod index 6ecfa18..9f98bf3 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,39 @@ require ( golang.org/x/term v0.40.0 ) -require golang.org/x/sys v0.41.0 // indirect +require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/glamour v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.30.0 // indirect +) diff --git a/go.sum b/go.sum index b5fef4e..bc521fe 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,93 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= +github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= diff --git a/internal/app/cli.go b/internal/app/cli.go index 7749709..91fc72f 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -25,6 +25,7 @@ type CLI struct { Info InfoCmd `cmd:"" help:"Show session metadata and first prompt"` Resume ResumeCmd `cmd:"" help:"Resume a session (auto-switches directory)"` Export ExportCmd `cmd:"" help:"Export session messages (with filtering)"` + View ViewCmd `cmd:"" help:"View session in interactive TUI"` Plans PlansCmd `cmd:"" help:"Browse and search plans"` Stats StatsCmd `cmd:"" help:"Session statistics"` Changelog ChangelogCmd `cmd:"" aliases:"log" help:"Show Claude Code changelog"` diff --git a/internal/app/export.go b/internal/app/export.go index 0347cd2..a7a3ec0 100644 --- a/internal/app/export.go +++ b/internal/app/export.go @@ -9,6 +9,7 @@ import ( "time" "github.com/andyhtran/cct/internal/output" + "github.com/andyhtran/cct/internal/render" "github.com/andyhtran/cct/internal/session" ) @@ -16,6 +17,7 @@ type ExportCmd struct { ID string `arg:"" help:"Session ID or prefix"` Full bool `help:"Show everything (no truncation, include tool results)"` Short bool `help:"Compact output (truncate messages to 500 chars)"` + Render bool `help:"Render with syntax highlighting (styled terminal output)"` Output string `short:"o" help:"Output file (default: stdout)"` Role string `short:"r" help:"Filter by role (comma-separated: user,assistant)" default:"user,assistant"` Limit int `short:"n" help:"Last N messages (0=all)" default:"0"` @@ -49,6 +51,15 @@ func (cmd *ExportCmd) Run(globals *Globals) error { return cmd.exportJSON(match, roles, maxChars, maxToolChars, includeToolResults, cmd.Search) } + if cmd.Render { + return render.RenderSession(match, render.Options{ + MaxChars: maxChars, + MaxToolChars: maxToolChars, + IncludeToolResults: includeToolResults, + Limit: cmd.Limit, + }) + } + md, stats, err := renderMarkdown(match, roles, maxChars, maxToolChars, cmd.Limit, includeToolResults, cmd.Search) if err != nil { return err diff --git a/internal/app/view.go b/internal/app/view.go new file mode 100644 index 0000000..1ca7262 --- /dev/null +++ b/internal/app/view.go @@ -0,0 +1,19 @@ +package app + +import ( + "github.com/andyhtran/cct/internal/session" + "github.com/andyhtran/cct/internal/tui" +) + +type ViewCmd struct { + ID string `arg:"" help:"Session ID or prefix"` +} + +func (cmd *ViewCmd) Run(globals *Globals) error { + s, err := session.FindByPrefixFull(cmd.ID) + if err != nil { + return err + } + + return tui.Run(s) +} diff --git a/internal/render/markdown.go b/internal/render/markdown.go new file mode 100644 index 0000000..6f1b17f --- /dev/null +++ b/internal/render/markdown.go @@ -0,0 +1,208 @@ +package render + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/andyhtran/cct/internal/session" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +type Options struct { + MaxChars int + MaxToolChars int + IncludeToolResults bool + Limit int +} + +func RenderSession(s *session.Session, opts Options) error { + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(0), + ) + if err != nil { + return fmt.Errorf("failed to create renderer: %w", err) + } + + f, err := os.Open(s.FilePath) + if err != nil { + return fmt.Errorf("cannot open session file: %w", err) + } + defer func() { _ = f.Close() }() + + header := renderHeader(s) + rendered, err := renderer.Render(header) + if err != nil { + return err + } + fmt.Print(rendered) + + messages := parseMessages(f, opts) + + if opts.Limit > 0 && len(messages) > opts.Limit { + messages = messages[len(messages)-opts.Limit:] + } + + userStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("12")). + Bold(true) + assistantStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("5")). + Bold(true) + separatorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + for _, msg := range messages { + var header string + if msg.role == "user" { + header = userStyle.Render("▌ User") + } else { + header = assistantStyle.Render("▌ Assistant") + } + fmt.Println(header) + fmt.Println() + + rendered, err := renderer.Render(msg.text) + if err != nil { + fmt.Print(msg.text) + } else { + fmt.Print(rendered) + } + fmt.Println(separatorStyle.Render(strings.Repeat("─", 80))) + fmt.Println() + } + + return nil +} + +func renderHeader(s *session.Session) string { + var b strings.Builder + fmt.Fprintf(&b, "# Session %s\n\n", s.ShortID) + if s.ProjectPath != "" { + fmt.Fprintf(&b, "- **Project**: %s\n", s.ProjectPath) + } + if s.GitBranch != "" { + fmt.Fprintf(&b, "- **Branch**: %s\n", s.GitBranch) + } + if !s.Created.IsZero() { + fmt.Fprintf(&b, "- **Created**: %s\n", s.Created.Local().Format("2006-01-02 15:04:05")) + } + fmt.Fprintf(&b, "- **Messages**: %d\n", s.MessageCount) + b.WriteString("\n---\n") + return b.String() +} + +type message struct { + role string + text string +} + +func parseMessages(r *os.File, opts Options) []message { + scanner := session.NewJSONLScanner(r) + var messages []message + + roles := map[string]bool{"user": true, "assistant": true} + + for scanner.Scan() { + line := scanner.Bytes() + lineType := session.FastExtractType(line) + + if !roles[lineType] { + continue + } + + var obj map[string]any + if err := json.Unmarshal(line, &obj); err != nil { + continue + } + + text := extractContent(obj, opts.IncludeToolResults, opts.MaxToolChars) + if text == "" { + continue + } + + if opts.MaxChars > 0 && len(text) > opts.MaxChars { + text = text[:opts.MaxChars] + fmt.Sprintf("\n\n... (%d chars truncated)", len(text)-opts.MaxChars) + } + + messages = append(messages, message{role: lineType, text: text}) + } + + return messages +} + +func extractContent(obj map[string]any, includeToolResults bool, maxToolChars int) string { + msg, ok := obj["message"].(map[string]any) + if !ok { + return "" + } + content := msg["content"] + if content == nil { + return "" + } + if str, ok := content.(string); ok { + return str + } + arr, ok := content.([]any) + if !ok { + return "" + } + + var parts []string + for _, item := range arr { + block, ok := item.(map[string]any) + if !ok { + continue + } + blockType, _ := block["type"].(string) + + if blockType == "thinking" || blockType == "redacted_thinking" { + continue + } + + isToolBlock := blockType == "tool_result" || blockType == "tool_use" + + if isToolBlock && !includeToolResults { + continue + } + + var text string + if blockType == "tool_use" { + text = formatToolUse(block) + } else if blockType == "tool_result" { + text = session.ExtractTextFromContent(item) + if maxToolChars > 0 && len(text) > maxToolChars { + text = text[:maxToolChars] + fmt.Sprintf("\n... (%d chars truncated)", len(text)-maxToolChars) + } + } else if t, ok := block["text"].(string); ok { + text = t + } + + if text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n\n") +} + +func formatToolUse(block map[string]any) string { + name, _ := block["name"].(string) + input, _ := block["input"].(map[string]any) + + var desc string + if d, ok := input["description"].(string); ok && d != "" { + desc = d + } else if cmd, ok := input["command"].(string); ok && cmd != "" { + desc = cmd + } else if path, ok := input["file_path"].(string); ok && path != "" { + desc = path + } + + if desc != "" { + return fmt.Sprintf("**%s**: %s", name, desc) + } + return fmt.Sprintf("**%s**", name) +} diff --git a/internal/tui/message.go b/internal/tui/message.go new file mode 100644 index 0000000..47ebcaa --- /dev/null +++ b/internal/tui/message.go @@ -0,0 +1,172 @@ +package tui + +import ( + "encoding/json" + "io" + "strings" + "time" + + "github.com/andyhtran/cct/internal/session" +) + +type MessageKind int + +const ( + KindUser MessageKind = iota + KindAssistant + KindToolCall + KindToolResult +) + +type Message struct { + Kind MessageKind + Text string + ToolName string + ToolInput map[string]any + Timestamp time.Time +} + +func ParseMessages(r io.Reader) []Message { + scanner := session.NewJSONLScanner(r) + var messages []Message + + for scanner.Scan() { + line := scanner.Bytes() + lineType := session.FastExtractType(line) + + if lineType != "user" && lineType != "assistant" { + continue + } + + var obj map[string]any + if json.Unmarshal(line, &obj) != nil { + continue + } + + ts := session.ParseTimestamp(obj) + extracted := extractMessages(obj, lineType, ts) + messages = append(messages, extracted...) + } + + return messages +} + +func extractMessages(obj map[string]any, role string, ts time.Time) []Message { + msg, ok := obj["message"].(map[string]any) + if !ok { + return nil + } + + content := msg["content"] + if content == nil { + return nil + } + + if str, ok := content.(string); ok { + kind := KindUser + if role == "assistant" { + kind = KindAssistant + } + return []Message{{Kind: kind, Text: str, Timestamp: ts}} + } + + arr, ok := content.([]any) + if !ok { + return nil + } + + var messages []Message + for _, item := range arr { + block, ok := item.(map[string]any) + if !ok { + continue + } + + blockType, _ := block["type"].(string) + + switch blockType { + case "text": + text, _ := block["text"].(string) + if text == "" { + continue + } + kind := KindUser + if role == "assistant" { + kind = KindAssistant + } + messages = append(messages, Message{Kind: kind, Text: text, Timestamp: ts}) + + case "thinking", "redacted_thinking": + continue + + case "tool_use": + name, _ := block["name"].(string) + input, _ := block["input"].(map[string]any) + desc := extractToolDescription(input) + messages = append(messages, Message{ + Kind: KindToolCall, + ToolName: name, + ToolInput: input, + Text: desc, + Timestamp: ts, + }) + + case "tool_result": + text := extractToolResultText(block) + if text != "" { + messages = append(messages, Message{ + Kind: KindToolResult, + Text: text, + Timestamp: ts, + }) + } + } + } + + return messages +} + +func extractToolDescription(input map[string]any) string { + if desc, ok := input["description"].(string); ok && desc != "" { + return desc + } + if cmd, ok := input["command"].(string); ok && cmd != "" { + if len(cmd) > 80 { + return cmd[:77] + "..." + } + return cmd + } + if path, ok := input["file_path"].(string); ok && path != "" { + return path + } + if pattern, ok := input["pattern"].(string); ok && pattern != "" { + return pattern + } + return "" +} + +func extractToolResultText(block map[string]any) string { + content := block["content"] + if content == nil { + return "" + } + + if str, ok := content.(string); ok { + return str + } + + arr, ok := content.([]any) + if !ok { + return "" + } + + var parts []string + for _, item := range arr { + if b, ok := item.(map[string]any); ok { + if text, ok := b["text"].(string); ok && text != "" { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "\n") +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..e1c375e --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,213 @@ +package tui + +import ( + "fmt" + "os" + "strings" + + "github.com/andyhtran/cct/internal/session" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +var ( + userStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("12")). + Bold(true) + + assistantStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("5")). + Bold(true) + + toolStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + toolNameStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("3")) + + separatorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("8")) +) + +type Model struct { + session *session.Session + messages []Message + viewport viewport.Model + renderer *glamour.TermRenderer + ready bool + width int + height int +} + +func NewModel(s *session.Session, messages []Message) Model { + renderer, _ := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(0), + ) + + return Model{ + session: s, + messages: messages, + renderer: renderer, + } +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + return m, tea.Quit + case "g": + m.viewport.GotoTop() + case "G": + m.viewport.GotoBottom() + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + headerHeight := 3 + footerHeight := 1 + + if !m.ready { + m.viewport = viewport.New(msg.Width, msg.Height-headerHeight-footerHeight) + m.viewport.YPosition = headerHeight + m.viewport.SetContent(m.renderContent()) + m.ready = true + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - headerHeight - footerHeight + m.viewport.SetContent(m.renderContent()) + } + } + + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m Model) View() string { + if !m.ready { + return "Loading..." + } + + header := m.renderHeader() + footer := m.renderFooter() + + return fmt.Sprintf("%s\n%s\n%s", header, m.viewport.View(), footer) +} + +func (m Model) renderHeader() string { + title := fmt.Sprintf(" Session %s ", m.session.ShortID) + if m.session.ProjectName != "" { + title += fmt.Sprintf("• %s ", m.session.ProjectName) + } + if m.session.GitBranch != "" { + title += fmt.Sprintf("(%s) ", m.session.GitBranch) + } + + line := strings.Repeat("─", max(0, m.width-len(title)-2)) + return separatorStyle.Render(fmt.Sprintf("─%s%s─", title, line)) +} + +func (m Model) renderFooter() string { + info := fmt.Sprintf(" %d messages ", len(m.messages)) + scroll := fmt.Sprintf(" %3.f%% ", m.viewport.ScrollPercent()*100) + help := " q: quit • j/k: scroll • g/G: top/bottom " + + gap := m.width - len(info) - len(scroll) - len(help) + if gap < 0 { + gap = 0 + } + + return helpStyle.Render(info + strings.Repeat(" ", gap) + help + scroll) +} + +func (m Model) renderContent() string { + var b strings.Builder + + for i, msg := range m.messages { + switch msg.Kind { + case KindUser: + b.WriteString(userStyle.Render("▌ User")) + b.WriteString("\n\n") + rendered := m.renderMarkdown(msg.Text) + b.WriteString(rendered) + + case KindAssistant: + b.WriteString(assistantStyle.Render("▌ Assistant")) + b.WriteString("\n\n") + rendered := m.renderMarkdown(msg.Text) + b.WriteString(rendered) + + case KindToolCall: + toolLine := fmt.Sprintf(" ▸ %s", toolNameStyle.Render(msg.ToolName)) + if msg.Text != "" { + toolLine += toolStyle.Render(fmt.Sprintf(" — %s", truncate(msg.Text, 60))) + } + b.WriteString(toolStyle.Render(toolLine)) + b.WriteString("\n") + continue + + case KindToolResult: + continue + } + + if i < len(m.messages)-1 { + b.WriteString("\n") + b.WriteString(separatorStyle.Render(strings.Repeat("─", m.width))) + b.WriteString("\n\n") + } + } + + return b.String() +} + +func (m Model) renderMarkdown(text string) string { + if m.renderer == nil { + return text + } + rendered, err := m.renderer.Render(text) + if err != nil { + return text + } + return strings.TrimSpace(rendered) +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +func Run(s *session.Session) error { + f, err := os.Open(s.FilePath) + if err != nil { + return fmt.Errorf("cannot open session file: %w", err) + } + defer func() { _ = f.Close() }() + + messages := ParseMessages(f) + if len(messages) == 0 { + return fmt.Errorf("no messages found in session") + } + + m := NewModel(s, messages) + p := tea.NewProgram(m, tea.WithAltScreen()) + + _, err = p.Run() + return err +}