Files
gnoma/internal/tui/app.go
vikingowl 4847421b17 feat: auto permission mode, edit diffs, truncated tool output
- Default permission mode changed to 'auto' (read-only auto-allows,
  writes prompt)
- fs.edit now shows diff-style output: line numbers, context ±3 lines,
  + for added (green), - for removed (red)
- Tool output truncated to 10 lines in TUI with "+N lines (Ctrl+O
  to expand)" indicator
- Mistral SDK bumped to v1.3.0
2026-04-03 18:57:13 +02:00

875 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/bubbles/v2/textarea"
"charm.land/glamour/v2"
"charm.land/bubbles/v2/key"
"charm.land/lipgloss/v2"
"somegit.dev/Owlibou/gnoma/internal/engine"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/permission"
"somegit.dev/Owlibou/gnoma/internal/router"
"somegit.dev/Owlibou/gnoma/internal/security"
"somegit.dev/Owlibou/gnoma/internal/session"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
const version = "v0.1.0-dev"
type streamEventMsg struct{ event stream.Event }
type turnDoneMsg struct{ err error }
type permReqMsg struct{ toolName string }
type chatMessage struct {
role string
content string
}
// Config holds optional dependencies for TUI features.
type Config struct {
Firewall *security.Firewall // for incognito toggle
Engine *engine.Engine // for model switching
Permissions *permission.Checker // for mode switching
Router *router.Router // for model listing
PermCh chan bool // TUI → engine: y/n response
PermReqCh <-chan string // engine → TUI: tool name needing approval
}
type Model struct {
session session.Session
config Config
width int
height int
messages []chatMessage
streaming bool
streamBuf *strings.Builder
currentRole string
input textarea.Model
mdRenderer *glamour.TermRenderer
cwd string
gitBranch string
scrollOffset int
incognito bool
permPending bool // waiting for user to approve/deny a tool
permToolName string // which tool is asking
}
func New(sess session.Session, cfg Config) Model {
ti := textarea.New()
ti.Placeholder = "Type a message... (Enter to send, Shift+Enter for newline)"
ti.ShowLineNumbers = false
ti.SetHeight(1)
ti.MaxHeight = 10
ti.SetWidth(80)
ti.CharLimit = 0
// Prompt only on first line, empty continuation
ti.SetPromptFunc(2, func(info textarea.PromptInfo) string {
if info.LineNumber == 0 {
return " "
}
return " "
})
// Remap: Shift+Enter/Ctrl+J for newline (not plain Enter)
km := ti.KeyMap
km.InsertNewline = key.NewBinding(key.WithKeys("shift+enter", "ctrl+j"))
ti.KeyMap = km
ti.Focus()
cwd, _ := os.Getwd()
gitBranch := detectGitBranch()
// Markdown renderer for chat output
mdRenderer, _ := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(80),
)
return Model{
session: sess,
config: cfg,
input: ti,
mdRenderer: mdRenderer,
cwd: cwd,
gitBranch: gitBranch,
streamBuf: &strings.Builder{},
}
}
func (m Model) Init() tea.Cmd {
return m.input.Focus()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.input.SetWidth(m.width - 4)
// Recreate markdown renderer with new width
m.mdRenderer, _ = glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(m.width-4),
)
return m, nil
case tea.KeyMsg:
// Handle permission prompt Y/N
if m.permPending {
switch strings.ToLower(msg.String()) {
case "y":
m.permPending = false
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("✓ %s approved", m.permToolName)})
m.config.PermCh <- true
return m, m.listenForEvents() // continue listening
case "n", "escape":
m.permPending = false
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("✗ %s denied", m.permToolName)})
m.config.PermCh <- false
return m, m.listenForEvents() // continue listening
}
return m, nil // ignore other keys while prompting
}
switch msg.String() {
case "ctrl+c":
if m.streaming {
m.session.Cancel()
return m, nil
}
return m, tea.Quit
case "escape":
if m.streaming {
m.session.Cancel()
return m, nil
}
case "ctrl+x":
// Toggle incognito
if m.config.Firewall != nil {
m.incognito = m.config.Firewall.Incognito().Toggle()
var msg string
if m.incognito {
msg = "🔒 incognito ON — no persistence, no learning, no logging"
} else {
msg = "🔓 incognito OFF"
}
m.messages = append(m.messages, chatMessage{role: "system", content: msg})
m.injectSystemContext(msg)
m.scrollOffset = 0
}
return m, nil
case "shift+tab":
// Cycle permission mode: bypass → default → plan → bypass
if m.config.Permissions != nil {
mode := m.config.Permissions.Mode()
var next permission.Mode
switch mode {
case permission.ModeBypass:
next = permission.ModeDefault
case permission.ModeDefault:
next = permission.ModePlan
case permission.ModePlan:
next = permission.ModeAcceptEdits
case permission.ModeAcceptEdits:
next = permission.ModeAuto
case permission.ModeAuto:
next = permission.ModeBypass
default:
next = permission.ModeBypass
}
m.config.Permissions.SetMode(next)
msg := fmt.Sprintf("permission mode changed to: %s — previous tool denials no longer apply, retry if asked", next)
m.messages = append(m.messages, chatMessage{role: "system", content: msg})
m.injectSystemContext(msg)
m.scrollOffset = 0
}
return m, nil
case "pgup", "shift+up":
m.scrollOffset += 5
return m, nil
case "pgdown", "shift+down":
m.scrollOffset -= 5
if m.scrollOffset < 0 {
m.scrollOffset = 0
}
return m, nil
case "enter":
if m.streaming {
return m, nil
}
input := strings.TrimSpace(m.input.Value())
if input == "" {
return m, nil
}
m.input.SetValue("")
return m.submitInput(input)
}
case tea.MouseWheelMsg:
if msg.Button == tea.MouseWheelUp {
m.scrollOffset += 3
} else if msg.Button == tea.MouseWheelDown {
m.scrollOffset -= 3
if m.scrollOffset < 0 {
m.scrollOffset = 0
}
}
return m, nil
case permReqMsg:
m.permPending = true
m.permToolName = msg.toolName
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("⚠ %s wants to execute. Allow? [y/n]", msg.toolName)})
m.scrollOffset = 0
return m, nil
case streamEventMsg:
return m.handleStreamEvent(msg.event)
case turnDoneMsg:
m.streaming = false
m.scrollOffset = 0 // snap to bottom on turn complete
if m.streamBuf.Len() > 0 {
m.messages = append(m.messages, chatMessage{
role: m.currentRole, content: m.streamBuf.String(),
})
m.streamBuf.Reset()
}
if msg.err != nil {
m.messages = append(m.messages, chatMessage{
role: "error", content: msg.err.Error(),
})
}
return m, nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m Model) submitInput(input string) (tea.Model, tea.Cmd) {
if strings.HasPrefix(input, "/") {
return m.handleCommand(input)
}
m.messages = append(m.messages, chatMessage{role: "user", content: input})
m.streaming = true
m.currentRole = "assistant"
m.streamBuf.Reset()
if err := m.session.Send(input); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.streaming = false
return m, nil
}
return m, m.listenForEvents()
}
func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
parts := strings.Fields(cmd)
command := parts[0]
args := ""
if len(parts) > 1 {
args = strings.Join(parts[1:], " ")
}
switch command {
case "/quit", "/exit", "/q":
return m, tea.Quit
case "/clear":
m.messages = nil
m.scrollOffset = 0
if m.config.Engine != nil {
m.config.Engine.Reset()
}
return m, nil
case "/compact":
if m.config.Engine != nil {
if w := m.config.Engine.ContextWindow(); w != nil {
compacted, err := w.CompactIfNeeded()
if err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: "compaction failed: " + err.Error()})
} else if compacted {
m.messages = append(m.messages, chatMessage{role: "system", content: "context compacted — older messages summarized"})
} else {
// Force compaction even if not at threshold
m.messages = append(m.messages, chatMessage{role: "system", content: "context usage within budget, no compaction needed"})
}
}
}
return m, nil
case "/incognito":
if m.config.Firewall != nil {
m.incognito = m.config.Firewall.Incognito().Toggle()
if m.incognito {
m.messages = append(m.messages, chatMessage{role: "system",
content: "🔒 incognito mode ON — no persistence, no learning, no content logging"})
} else {
m.messages = append(m.messages, chatMessage{role: "system",
content: "🔓 incognito mode OFF"})
}
} else {
m.messages = append(m.messages, chatMessage{role: "error",
content: "firewall not configured"})
}
return m, nil
case "/model":
if args == "" {
status := m.session.Status()
var b strings.Builder
fmt.Fprintf(&b, "current: %s/%s\n", status.Provider, status.Model)
if m.config.Router != nil {
b.WriteString("\nAvailable arms:\n")
for _, arm := range m.config.Router.Arms() {
marker := " "
if string(arm.ID) == status.Provider+"/"+status.Model {
marker = "→ "
}
caps := ""
if arm.Capabilities.ToolUse {
caps += "tools "
}
if arm.Capabilities.Thinking {
caps += "thinking "
}
if arm.Capabilities.Vision {
caps += "vision "
}
local := ""
if arm.IsLocal {
local = " (local)"
}
fmt.Fprintf(&b, "%s%s [%s]%s\n", marker, arm.ID, strings.TrimSpace(caps), local)
}
}
b.WriteString("\nUsage: /model <model-name>")
m.messages = append(m.messages, chatMessage{role: "system", content: b.String()})
return m, nil
}
if m.config.Engine != nil {
m.config.Engine.SetModel(args)
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("model switched to: %s", args)})
}
return m, nil
case "/config":
status := m.session.Status()
var b strings.Builder
b.WriteString("Current configuration:\n")
fmt.Fprintf(&b, " provider: %s\n", status.Provider)
fmt.Fprintf(&b, " model: %s\n", status.Model)
if m.config.Permissions != nil {
fmt.Fprintf(&b, " permission: %s\n", m.config.Permissions.Mode())
}
fmt.Fprintf(&b, " incognito: %v\n", m.incognito)
fmt.Fprintf(&b, " cwd: %s\n", m.cwd)
if m.gitBranch != "" {
fmt.Fprintf(&b, " git branch: %s\n", m.gitBranch)
}
b.WriteString("\nConfig files: ~/.config/gnoma/config.toml, .gnoma/config.toml")
m.messages = append(m.messages, chatMessage{role: "system", content: b.String()})
return m, nil
case "/shell":
m.messages = append(m.messages, chatMessage{role: "system",
content: "interactive shell not yet implemented\nFor now, use ! prefix in your terminal: ! sudo command"})
return m, nil
case "/permission", "/perm":
if m.config.Permissions == nil {
m.messages = append(m.messages, chatMessage{role: "error", content: "permission checker not configured"})
return m, nil
}
if args == "" {
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("permission mode: %s\nUsage: /permission <mode> (bypass, default, plan, accept_edits, deny, auto)\nOr press Shift+Tab to cycle", m.config.Permissions.Mode())})
return m, nil
}
mode := permission.Mode(args)
if !mode.Valid() {
m.messages = append(m.messages, chatMessage{role: "error",
content: fmt.Sprintf("invalid mode: %s (valid: bypass, default, plan, accept_edits, deny, auto)", args)})
return m, nil
}
m.config.Permissions.SetMode(mode)
msg := fmt.Sprintf("permission mode changed to: %s — previous tool denials no longer apply, retry if asked", mode)
m.messages = append(m.messages, chatMessage{role: "system", content: msg})
m.injectSystemContext(msg)
return m, nil
case "/provider":
if args == "" {
status := m.session.Status()
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("current provider: %s\nUsage: /provider <name> (mistral, anthropic, openai, google, ollama)", status.Provider)})
return m, nil
}
m.messages = append(m.messages, chatMessage{role: "system",
content: fmt.Sprintf("provider switching requires restart: gnoma --provider %s", args)})
return m, nil
case "/help":
m.messages = append(m.messages, chatMessage{role: "system",
content: "Commands:\n /clear clear chat\n /config show current config\n /incognito toggle incognito (Ctrl+X)\n /model [name] list/switch models\n /permission [mode] set permission mode (Shift+Tab to cycle)\n /provider show current provider\n /shell interactive shell (coming soon)\n /help show this help\n /quit exit gnoma"})
return m, nil
default:
m.messages = append(m.messages, chatMessage{role: "error",
content: fmt.Sprintf("unknown command: %s (try /help)", command)})
return m, nil
}
}
func (m Model) handleStreamEvent(evt stream.Event) (tea.Model, tea.Cmd) {
switch evt.Type {
case stream.EventTextDelta:
if evt.Text != "" {
m.streamBuf.WriteString(evt.Text)
}
case stream.EventThinkingDelta:
m.streamBuf.WriteString(evt.Text)
case stream.EventToolCallStart:
if m.streamBuf.Len() > 0 {
m.messages = append(m.messages, chatMessage{role: m.currentRole, content: m.streamBuf.String()})
m.streamBuf.Reset()
}
case stream.EventToolCallDone:
m.messages = append(m.messages, chatMessage{
role: "tool", content: fmt.Sprintf("⚙ [%s] running...", evt.ToolCallName),
})
case stream.EventToolResult:
m.messages = append(m.messages, chatMessage{
role: "toolresult", content: evt.ToolOutput,
})
}
return m, m.listenForEvents()
}
func (m Model) listenForEvents() tea.Cmd {
ch := m.session.Events()
permReqCh := m.config.PermReqCh
return func() tea.Msg {
// Listen for both stream events and permission requests
if permReqCh != nil {
select {
case evt, ok := <-ch:
if !ok {
_, err := m.session.TurnResult()
return turnDoneMsg{err: err}
}
return streamEventMsg{event: evt}
case toolName := <-permReqCh:
return permReqMsg{toolName: toolName}
}
}
evt, ok := <-ch
if !ok {
_, err := m.session.TurnResult()
return turnDoneMsg{err: err}
}
return streamEventMsg{event: evt}
}
}
// --- View ---
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("")
}
// Auto-size textarea to fit all content + 1 for cursor room
contentLines := strings.Count(m.input.Value(), "\n") + 2 // +1 for last line, +1 for cursor
if contentLines < 2 {
contentLines = 2
}
if contentLines > 12 {
contentLines = 12
}
m.input.SetHeight(contentLines)
status := m.renderStatus()
input := m.renderInput()
topLine, bottomLine := m.renderSeparators()
// Fixed: status bar + separator + input + separator = bottom area
statusH := lipgloss.Height(status)
inputH := lipgloss.Height(input)
chatH := m.height - statusH - inputH - 2
chat := m.renderChat(chatH)
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
chat,
topLine,
input,
bottomLine,
status,
))
v.MouseMode = tea.MouseModeCellMotion
v.AltScreen = true
return v
}
func (m Model) shortCwd() string {
dir := m.cwd
home, _ := os.UserHomeDir()
if strings.HasPrefix(dir, home) {
dir = "~" + dir[len(home):]
}
return dir
}
func (m Model) renderChat(height int) string {
var lines []string
// Header info — scrolls with content
status := m.session.Status()
lines = append(lines,
sHeaderBrand.Render(" gnoma ")+" "+sHeaderDim.Render("gnoma "+version),
" "+sHeaderModel.Render(fmt.Sprintf("%s/%s", status.Provider, status.Model))+
sHeaderDim.Render(" · ")+sHeaderDim.Render(m.shortCwd()),
"",
)
if len(m.messages) == 0 && !m.streaming {
lines = append(lines,
sHint.Render(" Type a message and press Enter."),
sHint.Render(" /help for commands, Ctrl+C to cancel or quit."),
"",
)
}
for _, msg := range m.messages {
lines = append(lines, m.renderMessage(msg)...)
}
// Streaming
if m.streaming && m.streamBuf.Len() > 0 {
// Stream raw text — markdown rendered only after completion
raw := m.streamBuf.String()
rLines := strings.Split(raw, "\n")
for i, line := range rLines {
if i == 0 {
lines = append(lines, styleAssistantLabel.Render("◆ ")+line)
} else {
lines = append(lines, " "+line)
}
}
} else if m.streaming {
lines = append(lines, styleAssistantLabel.Render("◆ ")+sCursor.Render("█"))
}
// Join all logical lines then split by newlines
raw := strings.Join(lines, "\n")
rawLines := strings.Split(raw, "\n")
// Hard-wrap each line to terminal width to get accurate physical line count
var physLines []string
for _, line := range rawLines {
// Strip ANSI to measure visible width, but keep original for rendering
visible := lipgloss.Width(line)
if visible <= m.width {
physLines = append(physLines, line)
} else {
// Line wraps — split into chunks of terminal width
// Use simple rune-based splitting (ANSI-aware wrapping is complex,
// so we just let it wrap naturally and count approximate lines)
wrappedCount := (visible + m.width - 1) / m.width
physLines = append(physLines, line) // the line itself
// Account for the extra wrapped lines
for i := 1; i < wrappedCount; i++ {
physLines = append(physLines, "") // placeholder for wrapped overflow
}
}
}
// Apply scroll: offset from bottom
if len(physLines) > height && height > 0 {
maxScroll := len(physLines) - height
offset := m.scrollOffset
if offset > maxScroll {
offset = maxScroll
}
end := len(physLines) - offset
start := end - height
if start < 0 {
start = 0
}
physLines = physLines[start:end]
}
// Hard truncate to exactly height lines — prevent overflow
if len(physLines) > height && height > 0 {
physLines = physLines[:height]
}
content := strings.Join(physLines, "\n")
// Pad to fill height if content is shorter
contentH := strings.Count(content, "\n") + 1
if contentH < height {
content += strings.Repeat("\n", height-contentH)
}
return content
}
func (m Model) renderMessage(msg chatMessage) []string {
var lines []string
indent := " " // 2-space indent for continuation lines
switch msg.role {
case "user":
// first line, indented continuation
msgLines := strings.Split(msg.content, "\n")
for i, line := range msgLines {
if i == 0 {
lines = append(lines, sUserLabel.Render(" ")+sUserLabel.Render(line))
} else {
lines = append(lines, sUserLabel.Render(indent+line))
}
}
lines = append(lines, "")
case "assistant":
// Render markdown with glamour
rendered := msg.content
if m.mdRenderer != nil {
if md, err := m.mdRenderer.Render(msg.content); err == nil {
rendered = strings.TrimSpace(md)
}
}
renderedLines := strings.Split(rendered, "\n")
for i, line := range renderedLines {
if i == 0 {
lines = append(lines, styleAssistantLabel.Render("◆ ")+line)
} else {
lines = append(lines, indent+line)
}
}
lines = append(lines, "")
case "tool":
lines = append(lines, indent+sToolOutput.Render(msg.content))
case "toolresult":
resultLines := strings.Split(msg.content, "\n")
maxShow := 10
for i, line := range resultLines {
if i >= maxShow {
remaining := len(resultLines) - maxShow
lines = append(lines, indent+indent+sHint.Render(
fmt.Sprintf("+%d lines (Ctrl+O to expand)", remaining)))
break
}
// Diff coloring for edit results
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "++") && len(trimmed) > 1 {
lines = append(lines, indent+indent+sDiffAdd.Render(line))
} else if strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "--") && len(trimmed) > 1 {
lines = append(lines, indent+indent+sDiffRemove.Render(line))
} else {
lines = append(lines, indent+indent+sToolResult.Render(line))
}
}
lines = append(lines, "")
case "system":
for i, line := range strings.Split(msg.content, "\n") {
if i == 0 {
lines = append(lines, sSystem.Render("• "+line))
} else {
lines = append(lines, sSystem.Render(indent+line))
}
}
lines = append(lines, "")
case "error":
lines = append(lines, sError.Render("✗ "+msg.content))
lines = append(lines, "")
}
return lines
}
func (m Model) renderSeparators() (string, string) {
lineColor := cSurface // default dim
modeLabel := ""
if m.config.Permissions != nil {
mode := m.config.Permissions.Mode()
lineColor = ModeColor(mode)
modeLabel = string(mode)
}
// Incognito adds amber overlay but keeps mode visible
if m.incognito {
lineColor = cYellow
modeLabel = "🔒 " + modeLabel
}
// Permission pending — flash the line
if m.permPending {
lineColor = cRed
modeLabel = "⚠ " + m.permToolName + " [y/n]"
}
lineStyle := lipgloss.NewStyle().Foreground(lineColor)
labelStyle := lipgloss.NewStyle().Foreground(lineColor).Bold(true)
// Top line: ─── with mode label on right ─── bypass ───
label := " " + modeLabel + " "
labelW := lipgloss.Width(labelStyle.Render(label))
lineW := m.width - labelW
if lineW < 4 {
lineW = 4
}
leftW := lineW - 2
rightW := 2
topLine := lineStyle.Render(strings.Repeat("─", leftW)) +
labelStyle.Render(label) +
lineStyle.Render(strings.Repeat("─", rightW))
// Bottom line: plain colored line
bottomLine := lineStyle.Render(strings.Repeat("─", m.width))
return topLine, bottomLine
}
func (m Model) renderInput() string {
return m.input.View()
}
func (m Model) renderStatus() string {
status := m.session.Status()
// Left: provider + model + incognito
provModel := fmt.Sprintf(" %s/%s", status.Provider, status.Model)
if m.incognito {
provModel += " " + sStatusIncognito.Render("🔒")
}
left := sStatusHighlight.Render(provModel)
// Center: cwd + git branch
dir := filepath.Base(m.cwd)
centerParts := []string{"📁 " + dir}
if m.gitBranch != "" {
centerParts = append(centerParts, sStatusBranch.Render(" "+m.gitBranch))
}
center := sStatusDim.Render(strings.Join(centerParts, ""))
// Right: tokens with state color + turns
tokenStr := fmt.Sprintf("tokens: %d", status.TokensUsed)
if status.TokenPercent > 0 {
tokenStr = fmt.Sprintf("tokens: %d (%d%%)", status.TokensUsed, status.TokenPercent)
}
var tokenStyle lipgloss.Style
switch status.TokenState {
case "warning":
tokenStyle = lipgloss.NewStyle().Foreground(cYellow)
case "critical":
tokenStyle = lipgloss.NewStyle().Foreground(cRed).Bold(true)
default:
tokenStyle = sStatusDim
}
right := tokenStyle.Render(tokenStr) + sStatusDim.Render(fmt.Sprintf(" │ turns: %d ", status.TurnCount))
if m.streaming {
right = sStatusStreaming.Render("● streaming ") + sStatusDim.Render("│ ") + right
}
// Compose with spacing
leftW := lipgloss.Width(left)
centerW := lipgloss.Width(center)
rightW := lipgloss.Width(right)
gap1 := (m.width-leftW-centerW-rightW)/2 - 1
if gap1 < 1 {
gap1 = 1
}
gap2 := m.width - leftW - gap1 - centerW - rightW
if gap2 < 0 {
gap2 = 0
}
bar := left + strings.Repeat(" ", gap1) + center + strings.Repeat(" ", gap2) + right
return sStatusBar.Width(m.width).Render(bar)
}
func wrapText(text string, width int) string {
if width <= 0 {
return text
}
var result strings.Builder
for i, line := range strings.Split(text, "\n") {
if i > 0 {
result.WriteByte('\n')
}
if len(line) <= width {
result.WriteString(line)
continue
}
words := strings.Fields(line)
lineLen := 0
for _, word := range words {
if lineLen+len(word)+1 > width && lineLen > 0 {
result.WriteByte('\n')
lineLen = 0
} else if lineLen > 0 {
result.WriteByte(' ')
lineLen++
}
result.WriteString(word)
lineLen += len(word)
}
}
return result.String()
}
// injectSystemContext adds a message to the engine's conversation history
// so the model sees it as context in subsequent turns.
func (m Model) injectSystemContext(text string) {
if m.config.Engine != nil {
m.config.Engine.InjectMessage(message.NewUserText("[system] " + text))
// Immediately follow with a synthetic assistant acknowledgment
// so the conversation stays in user→assistant alternation
m.config.Engine.InjectMessage(message.NewAssistantText("Understood."))
}
}
func detectGitBranch() string {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}