Files
strix/strix/interface/tui/internal/app/setup.go
T
Ahmed AllamandClaude Opus 4.8 b260a4ee38 fix(tui): make the mount prompt clickable, and skip the mount instead of abandoning the scan (#1015)
* make the working-directory prompt answer the mouse

Its Confirm and Cancel were drawn as buttons and did nothing when clicked: the
modal mouse handler had a case for every dialog except this one, so a click fell
through and the scan sat waiting on an answer the user believed they had given.
Only the keyboard could answer it.

The prompt is docked in a corner rather than centered, so it also needs its own
bounds; the centered ones every other dialog uses would have put the buttons in
the wrong place. Those bounds now come from the same placement cornerOverlay
draws with.

Two returns that hand back the model alongside a call that mutates it are now
sequenced explicitly. They work, but only because the compiler happens to
evaluate the call first, and one of them is what puts the prompt back in the
composer when the mount is declined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* skip the mount instead of abandoning the scan

Declining the working-directory prompt threw the whole launch away and dropped
back to the start screen, which is a lot to lose for answering one question
about one directory. The two answers are now about the directory alone: mount it,
or run without it. The prompt is the whole of the input either way.

The buttons say which is which - Mount and Skip rather than Confirm and Cancel -
and the prompt says what skipping costs.

A run with neither target nor directory is a real run, so two things follow it.
It can be resumed: its instruction is what drives it, and that is in the run
record. And it tells the agent plainly that it has neither, because an agent
given no scope goes looking for the one it assumes it was meant to have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 22:34:02 +03:00

523 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. Either answer starts the scan - declining only means it runs
// without the directory - so the prompt stays with the run rather than coming
// back to the composer.
func (m *Model) answerMountConfirmation(approved bool) tea.Cmd {
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).
// The log budgets rows by entry, so a message is flattened to one line first: a
// wrapped exception would otherwise render as several rows and push the launch
// column past the bottom of the terminal.
func (m *Model) setupMsg(text string, style lipgloss.Style) {
m.setupLogAppend(style.Render(flattenStatus(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("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()
}
}