Skip to content

Commit 4ecbe9e

Browse files
committed
Refactor personas
- Replace `Anonymous` boolean with `InspiredBy` string field to explicitly track who inspired each persona - Add automatic sorting of personas by category then name via `init()` - Implement text wrapping for persona descriptions using `wordwrap.String()` - Update persona list rendering to account for wrapped description height when scrolling - Rename "(Critical Only)" to "(Critical Issues Only)" for clarity - Add `InspirationSuffix()` helper for consistent attribution display - Move `muesli/reflow` to direct dependency (now explicitly imported)
1 parent c84668a commit 4ecbe9e

7 files changed

Lines changed: 279 additions & 260 deletions

File tree

cmd/root.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,10 @@ func run(cmd *cobra.Command, args []string) error {
137137

138138
sys := claude.DefaultSystemPrompt
139139
if persona != nil {
140-
if persona.Anonymous {
141-
sys += fmt.Sprintf("\n%s", persona.Description)
142-
} else {
140+
if persona.InspiredBy != "" {
143141
sys += fmt.Sprintf("\nAdopt the voice, opinions, and reviewing style of %s. %s. Review as they would — with their known priorities, pet peeves, and communication style.", persona.Name, persona.Description)
142+
} else {
143+
sys += fmt.Sprintf("\n%s%s", persona.Description, tui.InspirationSuffix(persona))
144144
}
145145
}
146146
fullPrompt := claude.BuildPrompt(sys, prompt)

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/charmbracelet/glamour v0.10.0
1010
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
1111
github.com/charmbracelet/x/ansi v0.11.6
12+
github.com/muesli/reflow v0.3.0
1213
github.com/spf13/cobra v1.10.2
1314
)
1415

@@ -34,7 +35,6 @@ require (
3435
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
3536
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3637
github.com/muesli/cancelreader v0.2.2 // indirect
37-
github.com/muesli/reflow v0.3.0 // indirect
3838
github.com/muesli/termenv v0.16.0 // indirect
3939
github.com/rivo/uniseg v0.4.7 // indirect
4040
github.com/spf13/pflag v1.0.9 // indirect

internal/tui/handlers.go

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/charmbracelet/bubbles/viewport"
1010
tea "github.com/charmbracelet/bubbletea"
1111
"github.com/charmbracelet/lipgloss"
12+
"github.com/muesli/reflow/wordwrap"
1213
)
1314

1415
// handleGitRangeInput processes keyboard input when the TUI is in git range input mode.
@@ -178,28 +179,31 @@ func (m Model) renderLibraryList() string {
178179
return b.String()
179180
}
180181

181-
// handlePersonaInput processes keyboard input when the TUI is in persona selection mode.
182-
// It enables navigation through a list of available review personas using arrow keys
183-
// (j/k or up/down) and selection via Enter. The function handles three built-in options:
184-
// "(None)" (no persona), "(Critical Only)" (critical issues only), and "(Terse)" (brief output),
185-
// as well as any custom personas. When a persona is selected, it updates the model's persona
186-
// field and either displays existing review content or initiates a new review stream if diff
187-
// content is present. Pressing Escape cancels persona selection and returns to normal mode.
182+
// handlePersonaInput processes keyboard input when the TUI is in persona
183+
// selection mode. It enables navigation through a list of available review
184+
// personas using arrow keys (j/k or up/down) and selection via Enter. The
185+
// function handles three built-in options: "(None)" (no persona), "(Critical
186+
// Issues Only)" (critical issues only), and "(Terse)" (brief output), as well
187+
// as any custom personas. When a persona is selected, it updates the model's
188+
// persona field and either displays existing review content or initiates a new
189+
// review stream if diff content is present. Pressing Escape cancels persona
190+
// selection and returns to normal mode.
188191
func (m Model) handlePersonaInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
189-
// Index 0 = "(None)", 1 = "(Critical Only)", 2 = "(Terse)", 3..len(Personas)+2 = personas
190-
maxIndex := len(Personas) + 2
192+
personas := SortedPersonasByCategory()
193+
// Index 0 = "(None)", 1 = "(Critical Issues Only)", 2 = "(Terse)", 3..len(Personas)+2 = personas
194+
maxIndex := len(personas) + 2
191195
switch msg.String() {
192196
case keyEnter:
193197
m.mode = modeNormal
194198
switch m.personaIndex {
195199
case 0:
196200
m.persona = nil
197201
case 1:
198-
m.persona = &Persona{Name: "(Critical Only)", Description: "Only report critical issues — bugs, security vulnerabilities, data loss risks, and correctness problems. Skip style, naming, and minor suggestions."}
202+
m.persona = &Persona{Name: "(Critical Issues Only)", Description: "Only report critical issues — bugs, security vulnerabilities, data loss risks, and correctness problems. Skip style, naming, and minor suggestions."}
199203
case 2:
200204
m.persona = &Persona{Name: "(Terse)", Description: "Extremely brief and concise, bullet points only, no fluff"}
201205
default:
202-
m.persona = &Personas[m.personaIndex-3]
206+
m.persona = &personas[m.personaIndex-3]
203207
}
204208
saveCmd := saveProfileCmd(m)
205209
if m.diffContent == "" {
@@ -216,30 +220,30 @@ func (m Model) handlePersonaInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
216220
if m.personaIndex < maxIndex {
217221
m.personaIndex++
218222
}
219-
content, sel := m.renderPersonaList()
223+
content, sel, extra := m.renderPersonaList()
220224
m.reviewViewport.SetContent(content)
221-
scrollPersonaViewport(&m.reviewViewport, sel)
225+
scrollPersonaViewport(&m.reviewViewport, sel, extra)
222226
return m, nil
223227
case keyScrollUp, keyUp:
224228
if m.personaIndex > 0 {
225229
m.personaIndex--
226230
}
227-
content, sel := m.renderPersonaList()
231+
content, sel, extra := m.renderPersonaList()
228232
m.reviewViewport.SetContent(content)
229-
scrollPersonaViewport(&m.reviewViewport, sel)
233+
scrollPersonaViewport(&m.reviewViewport, sel, extra)
230234
return m, nil
231235
default:
232236
return m, nil
233237
}
234238
}
235239

236240
// renderPersonaList renders the persona selection list view for the TUI.
237-
// It displays special filter options ("None", "Critical Only", "Terse") at the top,
241+
// It displays special filter options ("None", "Critical Issues Only", "Terse") at the top,
238242
// followed by all available personas with their names and descriptions.
239243
// The currently selected item is highlighted using the selected style.
240244
// Navigation hints are shown at the top of the list.
241245
// Returns the rendered string and the line number of the selected item.
242-
func (m Model) renderPersonaList() (string, int) {
246+
func (m Model) renderPersonaList() (string, int, int) {
243247
var b strings.Builder
244248
lineCount := 0
245249
selectedLine := 0
@@ -254,7 +258,7 @@ func (m Model) renderPersonaList() (string, int) {
254258

255259
// Special options at the top
256260
write(libraryCategoryStyle.Render("Filters") + "\n")
257-
specialOptions := []string{"(None)", "(Critical Only)", "(Terse)"}
261+
specialOptions := []string{"(None)", "(Critical Issues Only)", "(Terse)"}
258262
for i, label := range specialOptions {
259263
if i == m.personaIndex {
260264
selectedLine = lineCount
@@ -265,31 +269,47 @@ func (m Model) renderPersonaList() (string, int) {
265269
}
266270

267271
offset := len(specialOptions)
268-
for _, cat := range PersonaCategories {
272+
273+
// Leave room for the "> " prefix and some right margin.
274+
wrapWidth := m.reviewViewport.Width - 4
275+
if wrapWidth < 20 {
276+
wrapWidth = 20
277+
}
278+
279+
selectedExtraLines := 0
280+
281+
for _, cat := range SortedCategories() {
269282
write(libraryCategoryStyle.Render(cat) + "\n")
270-
for i, p := range Personas {
283+
284+
for i, p := range SortedPersonasByCategory() {
271285
if p.Category != cat {
272286
continue
273287
}
274-
label := fmt.Sprintf("%s — %s", p.Name, p.Description)
288+
nameLine := fmt.Sprintf("%s — inspired by %s", p.Name, p.InspiredBy)
289+
desc := wordwrap.String(p.Description, wrapWidth)
290+
275291
if i+offset == m.personaIndex {
276292
selectedLine = lineCount
277-
write(librarySelectedStyle.Render("> "+label) + "\n")
293+
selectedExtraLines = strings.Count(desc, "\n") + 1 // desc lines + blank line
294+
label := nameLine + "\n" + desc
295+
write(librarySelectedStyle.Render("> "+label) + "\n\n")
278296
} else {
297+
label := nameLine
279298
write(libraryItemStyle.Render(" "+label) + "\n")
280299
}
281300
}
282301
}
283302

284-
return b.String(), selectedLine
303+
return b.String(), selectedLine, selectedExtraLines
285304
}
286305

287-
// scrollPersonaViewport adjusts vp's YOffset so that selectedLine is visible.
288-
func scrollPersonaViewport(vp *viewport.Model, selectedLine int) {
306+
// scrollPersonaViewport adjusts vp's YOffset so that selectedLine is visible,
307+
// accounting for extraLines of content below the selected line (e.g. wrapped descriptions).
308+
func scrollPersonaViewport(vp *viewport.Model, selectedLine, extraLines int) {
289309
if selectedLine < vp.YOffset {
290310
vp.SetYOffset(selectedLine)
291-
} else if selectedLine >= vp.YOffset+vp.Height {
292-
vp.SetYOffset(selectedLine - vp.Height + 1)
311+
} else if selectedLine+extraLines >= vp.YOffset+vp.Height {
312+
vp.SetYOffset(selectedLine + extraLines - vp.Height + 1)
293313
}
294314
}
295315

internal/tui/model.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,10 +313,10 @@ func fetchDiffUpstreamCmd(ctx context.Context, gen int) tea.Cmd {
313313
func (m Model) buildFullPrompt() string {
314314
sys := claude.DefaultSystemPrompt
315315
if m.persona != nil && !m.promptNoPersona {
316-
if m.persona.Anonymous {
317-
sys += fmt.Sprintf("\n%s", m.persona.Description)
318-
} else {
316+
if m.persona.InspiredBy != "" {
319317
sys += fmt.Sprintf("\nAdopt the voice, opinions, and reviewing style of %s. %s. Review as they would — with their known priorities, pet peeves, and communication style.", m.persona.Name, m.persona.Description)
318+
} else {
319+
sys += fmt.Sprintf("\n%s%s", m.persona.Description, InspirationSuffix(m.persona))
320320
}
321321
}
322322
return claude.BuildPrompt(sys, m.prompt)

0 commit comments

Comments
 (0)