Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .agents/agents/worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ timeout_seconds: 1800
---

Implement only the delegated change. First inspect the relevant code and project instructions, then make the smallest focused change that satisfies the task. Run the most relevant tests or checks when practical. Do not invoke or simulate nested subagents. Return a concise summary of the work, changed files, verification performed and results, and any blockers or follow-up concerns.

Use this subagent only for a self-contained, bounded task where delegation provides clear value (for example, an independent implementation or investigation). Do not delegate routine lookups, small edits, or work the main agent can efficiently complete itself.

Avoid unnecessary or obvious code comments. Add comments only when they explain non-obvious behavior, important constraints, or safety-critical reasoning.
16 changes: 16 additions & 0 deletions internal/cli/repl/ask_user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package repl

func (m *replModel) clearAskUser() {
if m.stream.handler != nil {
m.stream.handler.SetAskUser(nil)
}
m.askUser.Clear()
}

func (m *replModel) appendResolvedAskUserSegment() {
if !m.askUser.Completed || m.stream.handler == nil {
return
}
m.stream.handler.SetAskUser(m.askUser.Card())
m.clearAskUser()
}
155 changes: 155 additions & 0 deletions internal/cli/repl/askuser/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package askuser

import (
"strconv"
"strings"

"charm.land/bubbles/v2/textinput"
"charm.land/lipgloss/v2"

repltheme "github.com/mochow13/keen-code/internal/cli/repl/theme"
)

const HorizontalMargin = 4

const inputPrefixWidth = 4

func NewInput() textinput.Model {
input := textinput.New()
input.Prompt = ""
input.Placeholder = "Type your answer"
var styles textinput.Styles
styles.Focused.Text = repltheme.AskUserTypedStyle
styles.Focused.Placeholder = repltheme.AskUserCustomStyle
styles.Blurred = styles.Focused
styles.Cursor.Color = repltheme.TextDimColor
input.SetStyles(styles)
return input
}

type Card struct {
Active bool
state State
}

func (c *Card) Clone() *Card {
if c == nil {
return nil
}
cp := *c
cp.state.Answers = append([]string(nil), c.state.Answers...)
cp.state.Resolved = append([]Answer(nil), c.state.Resolved...)
return &cp
}

func (c *Card) IsActive() bool {
return c != nil && c.Active
}

func (c *Card) Render(width int) string {
if c == nil {
return ""
}
return c.state.Render(width)
}

func (s State) Render(width int) string {
if !s.Visible() {
return ""
}
contentWidth := max(width-HorizontalMargin*2, 1)
var content strings.Builder
if s.Active() {
renderActive(&content, s, contentWidth)
} else {
renderResolved(&content, s, contentWidth)
}
body := strings.TrimRight(content.String(), "\n")
margin := strings.Repeat(" ", HorizontalMargin)
body = margin + strings.ReplaceAll(body, "\n", "\n"+margin)
rule := repltheme.AskUserRuleStyle.Render(strings.Repeat("─", max(width, 1)))
return "\n" + rule + "\n" + body + "\n" + rule + "\n"
}

func renderActive(content *strings.Builder, s State, width int) {
question := s.Request.Questionnaire.Questions[s.Index]
for i, answer := range s.Answers {
content.WriteString(repltheme.AskUserResolvedStyle.Render("• " + s.Request.Questionnaire.Questions[i].Question + ": " + answer))
content.WriteString("\n")
}
if len(s.Answers) > 0 {
content.WriteString("\n")
}
content.WriteString(repltheme.AskUserProgressStyle.Render("Question " + strconv.Itoa(s.Index+1) + " of " + strconv.Itoa(len(s.Request.Questionnaire.Questions))))
content.WriteString("\n")
content.WriteString(wrapText(question.Question, repltheme.AskUserQuestionStyle, width, "", ""))
content.WriteString("\n\n")
for i, option := range question.Options {
badge := ""
if i == 0 {
badge = " " + repltheme.AskUserBadgeStyle.Render("(recommended)")
}
content.WriteString(renderOption(i == s.Selected, option+badge, repltheme.NormalStyle, true, width))
}
customRow := "Type your answer"
customStyle := repltheme.AskUserCustomStyle
if s.Editing || s.Input.Value() != "" {
input := s.Input
input.SetWidth(max(width-inputPrefixWidth, 1))
customRow = input.View()
customStyle = lipgloss.NewStyle()
}
content.WriteString(renderOption(s.Selected == len(question.Options), customRow, customStyle, false, width))
content.WriteString("\n")
hint := "↑/↓ navigate · Enter select · Esc cancel"
if s.Selected == len(question.Options) && s.Editing {
hint = "Enter submit · Esc cancel"
}
content.WriteString(repltheme.AskUserHintStyle.Render(hint))
content.WriteString("\n")
}

func renderResolved(content *strings.Builder, s State, width int) {
header := "Answers provided"
if s.Cancelled {
header = "↩ Questions cancelled"
}
content.WriteString(repltheme.AskUserProgressStyle.Render(header))
if len(s.Resolved) == 0 {
content.WriteString("\n")
return
}
content.WriteString("\n\n")
for _, answer := range s.Resolved {
content.WriteString(wrapText(answer.Question+": "+answer.Answer, repltheme.AskUserResolvedStyle, width, "• ", " "))
content.WriteString("\n")
}
}

func renderOption(selected bool, text string, style lipgloss.Style, highlightSelected bool, width int) string {
cursor := " "
if selected {
cursor = repltheme.AskUserSelectedStyle.Render("› ")
}
bulletStyle := style
if selected && highlightSelected {
style = repltheme.AskUserSelectedStyle
bulletStyle = repltheme.AskUserSelectedStyle
}
prefix := cursor + bulletStyle.Render("• ")
return wrapText(text, style, width, prefix, " ") + "\n"
}

func wrapText(text string, style lipgloss.Style, width int, prefix, continuation string) string {
available := max(width-lipgloss.Width(prefix), 1)
wrapped := lipgloss.NewStyle().Width(available).Render(style.Render(text))
lines := strings.Split(strings.TrimRight(wrapped, "\n"), "\n")
for i, line := range lines {
if i == 0 {
lines[i] = prefix + line
} else {
lines[i] = continuation + line
}
}
return strings.Join(lines, "\n")
}
129 changes: 129 additions & 0 deletions internal/cli/repl/askuser/state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package askuser

import (
"github.com/mochow13/keen-code/internal/agentcore"

"charm.land/bubbles/v2/textinput"
)

type Answer struct {
Question string
Answer string
}

type State struct {
Requester *Requester
Request *Request
Index int
Selected int
Answers []string
Input textinput.Model
Editing bool
Resolved []Answer
Cancelled bool
Completed bool
}

func NewState(requester *Requester) State {
return State{Requester: requester, Input: NewInput()}
}

func (s *State) Begin(request *Request) {
*s = State{Requester: s.Requester, Request: request, Input: NewInput()}
}

func (s State) Active() bool { return s.Request != nil }

func (s State) Visible() bool { return s.Active() || s.Completed }

func (s State) Clone() *State {
cloned := s
cloned.Requester = nil
cloned.Answers = append([]string(nil), s.Answers...)
cloned.Resolved = append([]Answer(nil), s.Resolved...)
return &cloned
}

func (s *State) Clear() {
requester := s.Requester
*s = State{Requester: requester}
}

func (s *State) SyncInputFocus() {
if s.Editing {
s.Input.Focus()
} else {
s.Input.Blur()
}
}

func (s *State) Move(delta int) {
if !s.Active() {
return
}
rows := len(s.Request.Questionnaire.Questions[s.Index].Options) + 1
s.Selected = (s.Selected + delta + rows) % rows
s.Editing = s.Selected == len(s.Request.Questionnaire.Questions[s.Index].Options)
s.SyncInputFocus()
}

func (s *State) Answer(value string) bool {
s.Answers = append(s.Answers, value)
s.Index++
s.Selected, s.Editing = 0, false
s.Input.SetValue("")
s.Input.Blur()
return s.Index == len(s.Request.Questionnaire.Questions)
}

func (s *State) Resolve(requester *Requester, cancelled bool) {
if !s.Active() {
return
}
result := agentcore.AskUserResult{
Answers: append([]string(nil), s.Answers...),
Cancelled: cancelled,
}
if requester != nil {
requester.Respond(s.Request, result)
}
s.Resolved = make([]Answer, len(s.Answers))
for i, answer := range s.Answers {
s.Resolved[i] = Answer{
Question: s.Request.Questionnaire.Questions[i].Question,
Answer: answer,
}
}
s.Request = nil
s.Editing = false
s.Cancelled = cancelled
s.Completed = true
}

func NewResolvedState(request agentcore.AskUserRequest, result agentcore.AskUserResult) *State {
state := &State{Completed: true, Cancelled: result.Cancelled}
for i, answer := range result.Answers {
if i >= len(request.Questions) {
break
}
state.Resolved = append(state.Resolved, Answer{
Question: request.Questions[i].Question,
Answer: answer,
})
}
return state
}

func (s *State) Card() *Card {
if s == nil {
return nil
}
cloned := s.Clone()
if !cloned.Visible() {
return nil
}
return &Card{
Active: cloned.Active(),
state: *cloned,
}
}
4 changes: 2 additions & 2 deletions internal/cli/repl/command_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,12 +440,12 @@ func (m *replModel) handleShowThinkingCommand(input string) replModel {
switch arg {
case "on":
m.showThinking = true
m.stream.handler.showThinking = true
m.stream.handler.SetShowThinking(true)
m.saveShowThinking(true)
m.output.AddStyledLine(" ✓ Thinking tokens shown", repltheme.HighlightStyle)
case "off":
m.showThinking = false
m.stream.handler.showThinking = false
m.stream.handler.SetShowThinking(false)
m.saveShowThinking(false)
m.output.AddStyledLine(" ✓ Thinking tokens hidden", repltheme.HighlightStyle)
default:
Expand Down
Loading
Loading