feat: Phase 2 — chat templates, reasoning parser, collapsible TUI, mouse support

- internal/llama/template.go: ChatML/Llama3 template engine with stop tokens
- internal/agent/reasoning.go: parse <think>/<reasoning> tags from LLM output
- companion/promptbuilder.go: output structured BuildResult, add date/time context
- agent/agent.go: wire templates + reasoning split into message loop
- storage: add reasoning TEXT column to messages table + migration
- channel/tui.go: collapsible reasoning (tab toggle), mouse scroll, polished dimmed style
- config: CHAT_TEMPLATE env var (chatml|llama3|none)
- plan.md: mark Phase 2 complete, add themes TODO
This commit is contained in:
2026-06-10 09:42:45 +03:30
parent f57b42fd6e
commit 3895ca68c0
14 changed files with 327 additions and 144 deletions

View File

@@ -23,6 +23,22 @@ var (
Foreground(lipgloss.Color("#10B981")).
Bold(true)
reasoningLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
Background(lipgloss.Color("#1F2937")).
Padding(0, 1).
Italic(true)
reasoningContentStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
Italic(true)
reasoningHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6B7280")).
Background(lipgloss.Color("#1F2937")).
Padding(0, 1).
Bold(true)
inputStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#374151"))
@@ -40,20 +56,23 @@ type model struct {
ctx context.Context
cancel context.CancelFunc
viewport viewport.Model
input string
state sessionState
messages []types.Message
ready bool
viewport viewport.Model
input string
state sessionState
messages []types.Message
reasoningExpanded map[string]bool
reasoningOrder []string
ready bool
}
func NewTUIModel(a *agent.Agent) tea.Model {
ctx, cancel := context.WithCancel(context.Background())
return &model{
agent: a,
ctx: ctx,
cancel: cancel,
state: stateReady,
agent: a,
ctx: ctx,
cancel: cancel,
state: stateReady,
reasoningExpanded: make(map[string]bool),
}
}
@@ -74,6 +93,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.MouseMsg:
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
return m, cmd
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "ctrl+q":
@@ -88,6 +112,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.sendMessage(input)
}
case "tab":
for i := len(m.messages) - 1; i >= 0; i-- {
if m.messages[i].Role == types.RoleAssistant && m.messages[i].Reasoning != "" {
id := m.messages[i].ID
m.reasoningExpanded[id] = !m.reasoningExpanded[id]
break
}
}
case "backspace":
if len(m.input) > 0 {
m.input = m.input[:len(m.input)-1]
@@ -107,7 +140,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Content: fmt.Sprintf("error: %v", msg.err),
})
} else {
m.messages = m.agent.Messages()
newMsgs := m.agent.Messages()
for _, msg := range newMsgs {
if msg.Role == types.RoleAssistant && msg.Reasoning != "" {
found := false
for _, id := range m.reasoningOrder {
if id == msg.ID {
found = true
break
}
}
if !found {
m.reasoningOrder = append(m.reasoningOrder, msg.ID)
}
}
}
m.messages = newMsgs
}
m.viewport.GotoBottom()
}
@@ -134,14 +182,26 @@ func (m *model) View() string {
default:
prefix = "system"
}
b.WriteString(style.Render(prefix+": ") + msg.Content + "\n\n")
b.WriteString(style.Render(prefix+": ") + msg.Content + "\n")
if msg.Role == types.RoleAssistant && msg.Reasoning != "" {
if m.reasoningExpanded[msg.ID] {
b.WriteString(reasoningHeaderStyle.Render(" reasoning ") + "\n")
b.WriteString(reasoningContentStyle.Render(msg.Reasoning) + "\n")
} else {
label := reasoningLabelStyle.Render(" ` reasoning (tab)")
b.WriteString(label + "\n")
}
}
b.WriteString("\n")
}
m.viewport.SetContent(b.String())
prompt := "> "
if m.state == stateWaiting {
prompt = "waiting... "
prompt = "... "
}
return fmt.Sprintf("%s\n%s%s", m.viewport.View(), inputStyle.Render(prompt), m.input)