Files
strix/strix/interface/tui/internal/app/setup.go
T

524 lines
18 KiB
Go

package app
import (
"fmt"
"net"
"regexp"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/usestrix/strix/tui/internal/render"
)
func (m Model) submit(value string) (tea.Model, tea.Cmd) {
if m.snapshot.SetupMode {
return m.submitSetupPrompt(value)
}
if len(m.snapshot.Agents) == 0 {
m.errorText = "No agent is available"
return m, nil
}
if m.selectedAgent >= len(m.snapshot.Agents) {
m.selectedAgent = 0
}
return m, send(m.client, "agent.send_message", map[string]any{"agent_id": m.snapshot.Agents[m.selectedAgent].ID, "message": value})
}
// submitSetupPrompt handles free text the way a coding agent's prompt does:
// anything that looks like a target is added, the rest becomes the scan
// instruction, and the prompt alone is enough to launch. With no target, the
// backend scans the current working directory.
func (m *Model) submitSetupPrompt(value string) (tea.Model, tea.Cmd) {
var commands []tea.Cmd
fields := strings.Fields(value)
targets := 0
for _, field := range fields {
token := strings.Trim(field, ",;")
if !looksLikeTarget(token) || m.hasTarget(token) {
continue
}
targets++
commands = append(commands, send(m.client, "setup.add_target", map[string]any{"target": token}))
}
if len(fields) > targets {
commands = append(commands, send(m.client, "setup.set_instruction", map[string]any{"instruction": value}))
}
// With a target, verify the model connection before the scan commits to it.
// A bare prompt launches optimistically, like a coding agent, and mounts the
// working directory - the backend asks about that from the live view, so the
// prompt is held here in case it is declined.
verify := targets > 0 || len(m.snapshot.Targets) > 0
payload := map[string]any{"verify": verify}
if verify {
m.setupMsg("Verifying model connection...", render.Col(amber))
} else {
m.pendingPrompt = value
payload["mount_working_dir"] = true
}
commands = append(commands, send(m.client, "setup.start", payload))
// Ordered, not batched: setup.start leaves setup mode, so it must be the
// last command to reach the backend. Batched sends race, and once the
// preflight is skipped setup.start wins, making the target and instruction
// commands land after the guard closes and fail with a red error.
return *m, tea.Sequence(commands...)
}
// answerMountConfirmation replies to the working-directory mount the backend is
// waiting on. Declining returns to the start screen, so the prompt goes back in
// the composer to be edited or given a target instead.
func (m *Model) answerMountConfirmation(approved bool) tea.Cmd {
if !approved && m.pendingPrompt != "" {
m.input.SetValue(m.pendingPrompt)
m.resizeViewport()
}
m.pendingPrompt = ""
return send(m.client, "setup.confirm_mount", map[string]any{"approved": approved})
}
func (m Model) hasTarget(candidate string) bool {
for _, target := range m.snapshot.Targets {
if target == candidate {
return true
}
}
return false
}
// looksLikeTarget reports whether a whitespace-delimited token names something
// scannable: a URL, repo, filesystem path, domain, or IP address.
func looksLikeTarget(token string) bool {
if token == "" {
return false
}
if strings.Contains(token, "://") || strings.HasSuffix(token, ".git") {
return true
}
if strings.HasPrefix(token, "/") || strings.HasPrefix(token, "./") || strings.HasPrefix(token, "~/") || strings.HasPrefix(token, "../") {
return true
}
if ip := net.ParseIP(token); ip != nil {
return true
}
host := token
if at := strings.LastIndex(host, "@"); at >= 0 {
host = host[at+1:]
}
host = strings.SplitN(host, "/", 2)[0]
host = strings.SplitN(host, ":", 2)[0]
if !domainPattern.MatchString(host) {
return false
}
tld := host[strings.LastIndex(host, ".")+1:]
return len(tld) >= 2 && !isNumeric(tld)
}
var domainPattern = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9]{2,}$`)
func isNumeric(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}
// statusVisible mirrors #agent_status_display: shown only when an agent is
// selected during a scan; hidden (display:none) in setup mode.
func (m Model) statusVisible() bool {
return !m.snapshot.SetupMode && len(m.snapshot.Agents) > 0
}
func (m Model) layout() (showSidebar bool, sidebarWidth, chatWidth, chatHeight int) {
showSidebar = m.width >= 120
if showSidebar {
sidebarWidth = max(24, m.width/5)
chatWidth = m.width - sidebarWidth - 1
} else {
chatWidth = m.width
}
statusH := 0
if m.statusVisible() {
statusH = 1
}
chatHeight = max(4, m.height-statusH-(m.input.Height()+2))
return
}
// resizeViewport refits the composer and the scrollback to the terminal. The
// composer is sized width first: how far its content wraps, and so how tall it
// needs to be, depends on the width it is given.
func (m *Model) resizeViewport() {
if m.snapshot.SetupMode {
contentWidth := setupColumnWidth(m.width)
// The composer's border and padding each take a column per side.
m.input.SetWidth(max(3, contentWidth-4))
// A clipped placeholder reads as an unfinished sentence, so a narrow
// composer gets the short prompt instead.
m.input.Placeholder = setupPlaceholder
if contentWidth-6 < lipgloss.Width(setupPlaceholder) {
m.input.Placeholder = setupPlaceholderShort
}
m.syncInputHeight()
m.viewport.Width = max(10, contentWidth)
m.viewport.Height = max(1, setupLogRows(m.setupLog))
m.refreshViewport()
return
}
_, _, chatWidth, _ := m.layout()
// The accent bar and its padding each take a column.
m.input.SetWidth(max(3, chatWidth-3))
m.syncInputHeight()
_, _, _, chatHeight := m.layout()
// Reserve two columns inside the border for the scrollbar gap and track.
m.viewport.Width = max(10, chatWidth-4)
m.viewport.Height = max(3, chatHeight-2)
m.refreshViewport()
}
func (m *Model) refreshViewport() {
wasBottom := m.viewport.AtBottom()
content := m.setupContent()
if !m.snapshot.SetupMode {
content = m.chatContent()
}
m.viewportContent = content
m.viewport.SetContent(content)
if m.followOutput && wasBottom {
m.viewport.GotoBottom()
}
}
func (m Model) setupContent() string {
var b strings.Builder
for _, line := range m.setupLog {
b.WriteString(line + "\n")
}
return strings.TrimSuffix(b.String(), "\n")
}
// setupLogAppend records a chronological line in the setup scrollback. A line
// that is already there moves to the end instead of being repeated: retrying a
// launch that cannot succeed yet - no model configured, no target - would
// otherwise push the same pair of lines until they were all the log held.
func (m *Model) setupLogAppend(line string) {
for i, existing := range m.setupLog {
if existing == line {
m.setupLog = append(m.setupLog[:i], m.setupLog[i+1:]...)
break
}
}
m.setupLog = append(m.setupLog, line)
}
// setupMsg appends a styled feedback line (success green, error red, notice dim).
func (m *Model) setupMsg(text string, style lipgloss.Style) {
m.setupLogAppend(style.Render(text))
}
// setupLogRows is how many feedback lines the launch column shows before the
// fit starts trimming them. It is a launch pad, not a scrollback.
func setupLogRows(log []string) int { return min(len(log), 6) }
// Logo treatments, largest last. The launch column steps down through them as
// the terminal runs out of room.
const (
logoNone = iota
logoCompact
logoFull
)
// setupColumnWidth is the width of the centered launch column. It widens to
// the banner rather than lose it, as long as the terminal can still spare a
// margin either side.
func setupColumnWidth(terminal int) int {
width := min(72, max(24, terminal-8))
if terminal >= wordmarkWidth()+2 {
width = max(width, wordmarkWidth())
}
return width
}
// setupFit records how much of the launch column survives at the current
// terminal size: the wordmark treatment, whether the tagline is shown, and how
// many feedback-log rows fit.
type setupFit struct {
width int
logo int
tagline bool
logRows int
}
// setupFit picks the richest layout that still fits the terminal. Sections are
// surrendered in the order of shrink below - never the composer, which is the
// only thing on this screen the user has to reach.
func (m Model) setupFit() setupFit {
fit := setupFit{
width: setupColumnWidth(m.width),
logo: logoFull,
tagline: true,
logRows: setupLogRows(m.setupLog),
}
if m.width < wordmarkWidth()+2 {
fit.logo = logoCompact
}
if m.height < 18 {
fit.logo, fit.tagline = min(fit.logo, logoCompact), false
}
shrink := []func(*setupFit) bool{
func(f *setupFit) bool { return trimTo(&f.logRows, 3) },
func(f *setupFit) bool { return clearFlag(&f.tagline) },
func(f *setupFit) bool { return trimTo(&f.logRows, 0) },
func(f *setupFit) bool { return trimTo(&f.logo, logoCompact) },
func(f *setupFit) bool { return trimTo(&f.logo, logoNone) },
}
for step := 0; step < len(shrink) && lipgloss.Height(m.setupBody(fit)) > m.height; {
if !shrink[step](&fit) {
step++
}
}
return fit
}
func trimTo(value *int, floor int) bool {
if *value <= floor {
return false
}
*value--
return true
}
func clearFlag(flag *bool) bool {
if !*flag {
return false
}
*flag = false
return true
}
func (m Model) setupView() string {
fit := m.setupFit()
rows := strings.Split(m.setupBody(fit), "\n")
if len(rows) > m.height {
rows = rows[:max(0, m.height)]
}
// Anchor the column on its resting height rather than its current one, so a
// growing composer and new feedback both push downward.
// Centering on the live height walks the whole page up under the cursor,
// one row at a time, as the prompt wraps.
top := (m.height - m.setupRestingHeight(fit, len(rows))) / 2
top = min(max(top, 0), max(0, m.height-len(rows)))
left := max(0, (m.width-fit.width)/2)
frame := make([]string, m.height)
for row := range frame {
line := ""
if index := row - top; index >= 0 && index < len(rows) {
line = strings.Repeat(" ", left) + rows[index]
}
frame[row] = padToWidth(line, m.width)
}
return strings.Join(frame, "\n")
}
// setupRestingHeight is the column's height with the composer at its opening
// size and the transient sections closed: the layout the screen sits at when
// idle. Anchoring on this keeps the column still as the composer grows.
func (m Model) setupRestingHeight(fit setupFit, height int) int {
floor, _ := m.composerBounds()
height -= max(0, m.input.Height()-floor)
if fit.logRows > 0 && len(m.setupLog) > 0 {
height -= fit.logRows + 1
}
return height
}
// setupBody stacks the launch column: wordmark, composer with its scan summary,
// the target list, feedback and the key hints. Sections
// are separated by a blank line; the composer and its summary read as one unit.
func (m Model) setupBody(fit setupFit) string {
parts := make([]string, 0, 6)
if header := m.setupHeaderView(fit); header != "" {
parts = append(parts, header)
}
parts = append(parts, m.setupComposer(fit.width))
if log := m.setupLogView(fit); log != "" {
parts = append(parts, log)
}
parts = append(parts, m.setupHintsView(fit.width))
// Every row is padded to the column width: lipgloss.Place centers each line
// on its own, which would otherwise stagger the short rows.
rows := strings.Split(strings.Join(parts, "\n\n"), "\n")
for index, row := range rows {
rows[index] = padToWidth(row, fit.width)
}
return strings.Join(rows, "\n")
}
// setupHeaderView centers the wordmark over the tagline.
func (m Model) setupHeaderView(fit setupFit) string {
center := lipgloss.NewStyle().Width(fit.width).Align(lipgloss.Center)
var rows []string
switch fit.logo {
case logoFull:
// The banner is tall enough to want air under it.
rows = append(rows, center.Render(wordmark()))
if fit.tagline {
rows = append(rows, "")
}
case logoCompact:
rows = append(rows, center.Render(lipgloss.NewStyle().Bold(true).Foreground(brightGreen).Render("STRIX")))
}
if fit.tagline {
rows = append(rows, center.Render(render.Dim().Render("Open-source AI hackers for your apps")))
}
return strings.Join(rows, "\n")
}
// banner is the Strix wordmark: block letters with a bevelled edge.
const banner = ` ███████╗████████╗██████╗ ██╗██╗ ██╗
██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝
███████╗ ██║ ██████╔╝██║ ╚███╔╝
╚════██║ ██║ ██╔══██╗██║ ██╔██╗
███████║ ██║ ██║ ██║██║██╔╝ ██╗
╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝`
// wordmark renders the banner in solid brand green. Every row is padded out to
// the full block so centering cannot ripple the letterforms out of alignment.
var wordmarkOnce = sync.OnceValue(func() string {
green := lipgloss.NewStyle().Foreground(green)
lines := strings.Split(banner, "\n")
rows := make([]string, len(lines))
for index, line := range lines {
rows[index] = green.Render(line + strings.Repeat(" ", wordmarkWidth()-lipgloss.Width(line)))
}
return strings.Join(rows, "\n")
})
func wordmark() string { return wordmarkOnce() }
// wordmarkWidth is the cell width of the widest banner row.
var wordmarkWidth = sync.OnceValue(func() int {
block := 0
for _, line := range strings.Split(banner, "\n") {
block = max(block, lipgloss.Width(line))
}
return block
})
// setupComposer draws the prompt as a rounded panel that lights up green while
// it holds focus. The scan meta and targets live inside the panel, flush under
// the input, so everything shares one left edge - the way opencode aligns its
// home prompt.
func (m Model) setupComposer(width int) string {
border := dark
if m.focus == focusInput {
border = green
}
// Width covers the padding but not the border, so a box of the given total
// width sets width-2 here and hands the interior the width-4 that is left.
inner := max(1, width-4)
body := m.highlightInputSelection(m.input.View())
body += "\n\n" + m.setupSummaryView(inner)
if targets := m.setupTargetsView(inner); targets != "" {
body += "\n" + targets
}
return lipgloss.NewStyle().Width(max(1, width-2)).Padding(0, 1).
Border(lipgloss.RoundedBorder()).BorderForeground(border).
Render(body)
}
// setupSummaryView is the quiet meta line inside the panel: what the scan will
// run as, or what is still missing before it can run.
func (m Model) setupSummaryView(width int) string {
chips := []string{}
if model := strings.TrimSpace(m.snapshot.Model); model != "" {
name, provider := model, ""
if slash := strings.LastIndex(model, "/"); slash >= 0 {
provider, name = model[:slash], model[slash+1:]
}
chip := render.Col(green).Render("● ") + render.Col(white).Render(name)
if provider != "" {
chips = append(chips, chip, render.Dim().Render(provider))
} else {
chips = append(chips, chip)
}
} else {
chips = append(chips, render.Col(amber).Render("○ no model")+
render.Dim().Render(" · set STRIX_LLM or configure one in your config"))
}
if m.snapshot.MaxBudgetUSD != nil {
chips = append(chips, render.Dim().Render(fmt.Sprintf("$%.2f budget", *m.snapshot.MaxBudgetUSD)))
}
return truncate(strings.Join(chips, render.Dim().Render(" · ")), max(1, width))
}
// setupTargetsView lists what the scan is pointed at, once anything is queued.
func (m Model) setupTargetsView(width int) string {
if len(m.snapshot.Targets) == 0 {
return ""
}
const visible = 4
total := max(m.snapshot.TargetCount, len(m.snapshot.Targets))
rows := []string{render.Bold(green).Render("Targets") + render.Dim().Render(fmt.Sprintf(" %d", total))}
for _, target := range m.snapshot.Targets[:min(visible, len(m.snapshot.Targets))] {
rows = append(rows, render.Col(dim).Render("▸ ")+render.Col(white).Render(truncate(target, max(1, width-2))))
}
if hidden := total - visible; hidden > 0 {
rows = append(rows, render.Dim().Render(fmt.Sprintf("+%d more", hidden)))
}
return strings.Join(rows, "\n")
}
// setupLogView shows the tail of the feedback log. The launch screen is a
// launch pad, not a scrollback, so only the most recent lines are kept.
func (m Model) setupLogView(fit setupFit) string {
if fit.logRows <= 0 || len(m.setupLog) == 0 {
return ""
}
tail := m.setupLog[max(0, len(m.setupLog)-fit.logRows):]
rows := make([]string, 0, len(tail))
for _, line := range tail {
// Align with the panel interior [2, width-2].
rows = append(rows, " "+truncate(line, max(1, fit.width-4)))
}
return strings.Join(rows, "\n")
}
// setupHintsView is the closing key hint row, aligned to the panel's inner
// edges: keys flush under the input, the version at the far right.
func (m Model) setupHintsView(width int) string {
// The panel's interior spans [2, width-2]; match it so the row reads as a
// footer under the input rather than a stray line.
const pad = " "
inner := max(1, width-4)
key := lipgloss.NewStyle().Foreground(white).Render
label := render.Dim().Render
hint := func(k, text string) string { return key(k) + label(" "+text) }
left := hint("enter", "launch scan") + label(" ") + hint("shift+enter", "newline") +
label(" ") + hint("ctrl+c", "quit")
if lipgloss.Width(left) > inner {
left = hint("enter", "launch scan")
}
right := label("v" + appVersion)
gap := inner - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 2 {
return pad + left
}
return pad + left + strings.Repeat(" ", gap) + right
}
// syncMountPrompt raises or clears the working-directory prompt to match the
// backend, which asks for it from the live view once a target-less scan is
// waiting on the answer. Following the snapshot rather than the keystroke keeps
// the prompt right across redraws and reconnects.
func (m *Model) syncMountPrompt() {
switch {
case m.snapshot.PendingMount != "" && m.modal != modalConfirmMount:
m.openModal(modalConfirmMount)
case m.snapshot.PendingMount == "" && m.modal == modalConfirmMount:
m.closeModal()
}
}