Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions specstory-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,7 @@ func main() {
debugDir = utils.ExpandTilde(cfg.GetDebugDir())
}
localTimeZone = cfg.IsLocalTimeZoneEnabled()
sessionpkg.SetUserTurnColor(cfg.GetUserTurnColor())
noVersionCheck = !cfg.IsVersionCheckEnabled()
noCloudSync = !cfg.IsCloudSyncEnabled()
onlyCloudSync = !cfg.IsLocalSyncEnabled()
Expand Down
22 changes: 22 additions & 0 deletions specstory-cli/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ const defaultConfigTemplate = `# SpecStory CLI Configuration
# Default: true
# enabled = false # equivalent to --no-version-check

[markdown]
# Tint the πŸ‘€ user-turn heading with a CSS color, for readers that render inline
# HTML (e.g. VS Code preview). Leave unset for plain Markdown β€” clean in every
# reader (GitHub, Quick Look, etc.). To enable, set a CSS color, e.g.:
# user_turn_color = "#2563eb"

[analytics]
# Send anonymous product usage analytics to help improve SpecStory.
# Default: true
Expand Down Expand Up @@ -126,6 +132,16 @@ type Config struct {
Analytics AnalyticsConfig `toml:"analytics"`
Telemetry TelemetryConfig `toml:"telemetry"`
Providers ProvidersConfig `toml:"providers"`
Markdown MarkdownConfig `toml:"markdown"`
}

// MarkdownConfig holds Markdown rendering options.
type MarkdownConfig struct {
// UserTurnColor, when set to a CSS color (e.g. "#2563eb"), wraps the πŸ‘€
// user-turn heading in an inline-color span for readers that render inline
// HTML (e.g. VS Code preview). Empty (default) means plain Markdown, which
// renders cleanly in every reader (incl. GitHub and Quick Look).
UserTurnColor string `toml:"user_turn_color"`
}

// VersionCheckConfig holds version check settings
Expand Down Expand Up @@ -725,6 +741,12 @@ func (c *Config) IsLocalTimeZoneEnabled() bool {
return false
}

// GetUserTurnColor returns the CSS color for user-turn headings, or "" to
// disable (plain Markdown). Defaults to "" if not set.
func (c *Config) GetUserTurnColor() string {
return c.Markdown.UserTurnColor
}

// GetProviderCmd returns the custom execution command for a provider, or empty
// string if none is configured. The providerID should match a registered
// provider ID (e.g., "claude", "codex", "cursor", "droid", "gemini").
Expand Down
48 changes: 37 additions & 11 deletions specstory-cli/pkg/session/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,33 @@ func renderMessage(msg Message, prevRole string, includeMessageIDs bool, useUTC
return markdown.String()
}

// renderRoleHeader creates the role header for a message
// userTurnColor is an optional CSS color for the user-turn heading. Empty (the
// default) means plain Markdown. It's set once at startup via SetUserTurnColor
// from the loaded config and only read during rendering.
var userTurnColor string

// SetUserTurnColor configures an optional CSS color for user-turn headings.
// Pass "" (default) for plain Markdown β€” clean in every reader. Pass a CSS color
// (e.g. "#2563eb") to tint the heading via an inline <span> for readers that
// render inline HTML (e.g. VS Code preview); readers that don't (GitHub, Quick
// Look) may show the raw tag, which is why this is opt-in. Call once at startup.
func SetUserTurnColor(color string) { userTurnColor = color }

// renderRoleHeader creates the role header for a message.
//
// User turns render as a level-3 heading with a πŸ‘€ marker so they stand out as
// the natural segmentation between conversation rounds and populate the document
// outline for quick navigation. Agent turns stay quiet (italic-bold) with a πŸ€–
// marker so the user turns remain the visual anchors. Color is opt-in (off by
// default) via SetUserTurnColor.
//
// ⚠️ PARSING CONTRACT β€” read before changing these strings. The header text
// produced here is what downstream consumers grep on to split a transcript into
// turns (turn counts, round segmentation, indexers/search tools, SpecStory
// Cloud). They typically match "πŸ‘€ User" / "πŸ€– Agent". If you rename a marker or
// change the shape, you will SILENTLY break those consumers β€” update them in
// lockstep and bump the "Markdown vX.Y.Z" version in GeneratedBySpecStory so the
// change is detectable.
func renderRoleHeader(msg Message, useUTC bool) string {
// Check if this is a sidechain message (subagent conversation)
sidechainMarker := ""
Expand All @@ -140,28 +166,28 @@ func renderRoleHeader(msg Message, useUTC bool) string {
}

if msg.Role == "user" {
// User message - include timestamp if available
label := "πŸ‘€ User" + sidechainMarker
if msg.Timestamp != "" {
formattedTimestamp := formatTimestamp(msg.Timestamp, useUTC)
return fmt.Sprintf("_**User%s (%s)**_\n\n", sidechainMarker, formattedTimestamp)
label += fmt.Sprintf(" (%s)", formatTimestamp(msg.Timestamp, useUTC))
}
return fmt.Sprintf("_**User%s**_\n\n", sidechainMarker)
if userTurnColor != "" {
return fmt.Sprintf("### <span style=\"color:%s\">%s</span>\n\n", userTurnColor, label)
}
return "### " + label + "\n\n"
}

// Agent message - include model and timestamp if available
if msg.Model != "" && msg.Timestamp != "" {
formattedTimestamp := formatTimestamp(msg.Timestamp, useUTC)
return fmt.Sprintf("_**Agent%s (%s %s)**_\n\n", sidechainMarker, msg.Model, formattedTimestamp)
return fmt.Sprintf("_**πŸ€– Agent%s (%s %s)**_\n\n", sidechainMarker, msg.Model, formatTimestamp(msg.Timestamp, useUTC))
}
if msg.Model != "" {
return fmt.Sprintf("_**Agent%s (%s)**_\n\n", sidechainMarker, msg.Model)
return fmt.Sprintf("_**πŸ€– Agent%s (%s)**_\n\n", sidechainMarker, msg.Model)
}
if msg.Timestamp != "" {
formattedTimestamp := formatTimestamp(msg.Timestamp, useUTC)
return fmt.Sprintf("_**Agent%s (%s)**_\n\n", sidechainMarker, formattedTimestamp)
return fmt.Sprintf("_**πŸ€– Agent%s (%s)**_\n\n", sidechainMarker, formatTimestamp(msg.Timestamp, useUTC))
}

return fmt.Sprintf("_**Agent%s**_\n\n", sidechainMarker)
return fmt.Sprintf("_**πŸ€– Agent%s**_\n\n", sidechainMarker)
}

// renderContentParts renders all content parts in a message
Expand Down
43 changes: 43 additions & 0 deletions specstory-cli/pkg/session/user_heading_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package session

import (
"strings"
"testing"
)

func TestRenderRoleHeader_UserIsHeadingWithMarker(t *testing.T) {
user := Message{Role: "user", Timestamp: "2026-06-19T05:04:33Z"}
got := renderRoleHeader(user, true)

if want := "### πŸ‘€ User (2026-06-19 05:04:33Z)\n\n"; got != want {
t.Errorf("user header = %q, want %q", got, want)
}
// Default is plain Markdown (no inline HTML), which some readers (Quick Look)
// would otherwise leak as a stray bracket.
if strings.ContainsAny(got, "<>") {
t.Errorf("default user header should be plain Markdown (no HTML), got %q", got)
}
}

func TestRenderRoleHeader_UserColorOptIn(t *testing.T) {
SetUserTurnColor("#2563eb")
defer SetUserTurnColor("") // reset so other tests see the default

user := Message{Role: "user", Timestamp: "2026-06-19T05:04:33Z"}
got := renderRoleHeader(user, true)
want := "### <span style=\"color:#2563eb\">πŸ‘€ User (2026-06-19 05:04:33Z)</span>\n\n"
if got != want {
t.Errorf("colored user header = %q, want %q", got, want)
}
}

func TestRenderRoleHeader_AgentStaysQuiet(t *testing.T) {
agent := Message{Role: "agent", Model: "claude-opus-4-8", Timestamp: "2026-06-19T05:04:40Z"}
got := renderRoleHeader(agent, true)
if strings.HasPrefix(got, "#") {
t.Errorf("agent header should not be a heading (user turns are the anchors), got %q", got)
}
if !strings.Contains(got, "πŸ€– Agent") {
t.Errorf("agent header should carry the πŸ€– marker, got %q", got)
}
}