mirror of
https://github.com/usestrix/strix.git
synced 2026-08-21 18:52:47 +02:00
* 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>
671 lines
19 KiB
Go
671 lines
19 KiB
Go
package app
|
|
|
|
import (
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
switch key.String() {
|
|
case "f1":
|
|
m.openModal(modalHelp)
|
|
return m, nil
|
|
case "ctrl+c", "ctrl+q":
|
|
// Nothing to lose on the start screen; quit without confirmation.
|
|
if m.snapshot.SetupMode {
|
|
m.quitting = true
|
|
return m, tea.Batch(send(m.client, "app.quit", map[string]any{}), tea.Quit)
|
|
}
|
|
m.modalChoice = 1
|
|
m.openModal(modalQuit)
|
|
return m, nil
|
|
case "ctrl+o":
|
|
return m, send(m.client, "viewer.open", map[string]any{})
|
|
case "tab":
|
|
m.cycleFocus(1)
|
|
return m, nil
|
|
case "shift+tab":
|
|
m.cycleFocus(-1)
|
|
return m, nil
|
|
case "esc":
|
|
if !m.snapshot.SetupMode && m.selectedAgentCanStop() {
|
|
m.modalChoice = 1
|
|
m.openModal(modalStop)
|
|
}
|
|
return m, nil
|
|
case "up", "down":
|
|
if m.focus == focusAgents && len(m.snapshot.Agents) > 0 {
|
|
delta := 1
|
|
if key.String() == "up" {
|
|
delta = -1
|
|
}
|
|
entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)
|
|
row := selectedAgentRow(entries, m.selectedAgent)
|
|
row = max(0, min(len(entries)-1, row+delta))
|
|
m.selectedAgent = entries[row].index
|
|
m.ensureAgentVisible()
|
|
m.refreshViewport()
|
|
return m, nil
|
|
}
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
delta := 1
|
|
if key.String() == "up" {
|
|
delta = -1
|
|
}
|
|
m.moveVulnerabilitySelection(delta)
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
case "enter", " ":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
if key.String() == "enter" {
|
|
m.openModal(modalVulnerability)
|
|
return m, nil
|
|
}
|
|
}
|
|
if m.focus == focusAgents {
|
|
if m.selectedAgent < len(m.snapshot.Agents) {
|
|
agentID := m.snapshot.Agents[m.selectedAgent].ID
|
|
if hasAgentChildren(agentID, m.snapshot.Agents) {
|
|
if m.collapsedAgents == nil {
|
|
m.collapsedAgents = map[string]bool{}
|
|
}
|
|
m.collapsedAgents[agentID] = !m.collapsedAgents[agentID]
|
|
m.ensureAgentVisible()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
if key.String() == "enter" && m.focus == focusInput {
|
|
value := strings.TrimSpace(m.input.Value())
|
|
m.input.SetValue("")
|
|
m.resizeViewport()
|
|
if value != "" {
|
|
return m.submit(value)
|
|
}
|
|
return m, nil
|
|
}
|
|
case "pgup":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
m.moveVulnerabilitySelection(-m.vulnerabilityPageItems())
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.followOutput = false
|
|
m.viewport.HalfViewUp()
|
|
return m, nil
|
|
case "pgdown":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
m.moveVulnerabilitySelection(m.vulnerabilityPageItems())
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.viewport.HalfViewDown()
|
|
return m, nil
|
|
case "home":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
m.selectedVuln = 0
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
case "end":
|
|
if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 {
|
|
m.selectedVuln = len(m.snapshot.Vulnerabilities) - 1
|
|
m.ensureVulnerabilityVisible()
|
|
return m, nil
|
|
}
|
|
m.viewport.GotoBottom()
|
|
m.followOutput = true
|
|
return m, nil
|
|
}
|
|
if m.focus == focusChat {
|
|
var cmd tea.Cmd
|
|
m.viewport, cmd = m.viewport.Update(key)
|
|
return m, cmd
|
|
}
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(key)
|
|
// Typing changes how far the composer wraps, so refit it.
|
|
m.resizeViewport()
|
|
return m, cmd
|
|
}
|
|
|
|
// updateMouse routes wheel and click events to the pane under the pointer.
|
|
func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
if m.modal != modalNone {
|
|
return m.updateModalMouse(msg)
|
|
}
|
|
if m.snapshot.SetupMode {
|
|
return m.updateSetupMouse(msg)
|
|
}
|
|
showSidebar, _, chatWidth, chatHeight := m.layout()
|
|
viewerHeight := m.viewerHeight()
|
|
_, vulnHeight, agentHeight := m.sidebarHeights()
|
|
x, y := msg.X, msg.Y
|
|
if m.updateMainScrollbarMouse(
|
|
msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight,
|
|
) {
|
|
return m, nil
|
|
}
|
|
if m.selection.dragging {
|
|
switch msg.Action {
|
|
case tea.MouseActionMotion:
|
|
// Clamp to the owning pane so dragging past an edge keeps
|
|
// extending the selection.
|
|
if m.selection.region == regionInput {
|
|
top := m.inputTop()
|
|
cx := min(max(x, 2+inputPromptWidth), max(2+inputPromptWidth, chatWidth-2))
|
|
cy := min(max(y, top+1), top+m.input.Height())
|
|
if line, col, ok := m.inputContentCell(cx, cy); ok {
|
|
m.extendSelection(line, col)
|
|
}
|
|
return m, nil
|
|
}
|
|
traceHeight := chatHeight - 2
|
|
cx := min(max(x, 1), max(1, chatWidth-2))
|
|
cy := min(max(y, 1), max(1, traceHeight))
|
|
if line, col, ok := m.chatContentCell(cx, cy); ok {
|
|
m.extendSelection(line, col)
|
|
}
|
|
return m, nil
|
|
case tea.MouseActionRelease:
|
|
return m, m.finishSelection()
|
|
}
|
|
}
|
|
switch msg.Button {
|
|
case tea.MouseButtonWheelUp:
|
|
if showSidebar && x >= chatWidth+1 {
|
|
switch {
|
|
case y < viewerHeight:
|
|
return m, nil
|
|
case y < viewerHeight+agentHeight:
|
|
m.focus = focusAgents
|
|
m.input.Blur()
|
|
m.agentOffset = max(0, m.agentOffset-3)
|
|
m.keepAgentSelectionInWindow()
|
|
m.refreshViewport()
|
|
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
|
m.focus = focusVulnerabilities
|
|
m.input.Blur()
|
|
m.vulnOffset = max(0, m.vulnOffset-3)
|
|
m.keepVulnerabilitySelectionInWindow()
|
|
}
|
|
return m, nil
|
|
}
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.followOutput = false
|
|
m.viewport.LineUp(3)
|
|
return m, nil
|
|
case tea.MouseButtonWheelDown:
|
|
if showSidebar && x >= chatWidth+1 {
|
|
switch {
|
|
case y < viewerHeight:
|
|
return m, nil
|
|
case y < viewerHeight+agentHeight:
|
|
m.focus = focusAgents
|
|
m.input.Blur()
|
|
rows := m.agentPageSize()
|
|
m.agentOffset = min(max(0, len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents))-rows), m.agentOffset+3)
|
|
m.keepAgentSelectionInWindow()
|
|
m.refreshViewport()
|
|
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
|
m.focus = focusVulnerabilities
|
|
m.input.Blur()
|
|
totalRows, _ := m.vulnerabilityScrollRows()
|
|
m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), m.vulnOffset+3)
|
|
m.keepVulnerabilitySelectionInWindow()
|
|
}
|
|
return m, nil
|
|
}
|
|
m.viewport.LineDown(3)
|
|
if m.viewport.AtBottom() {
|
|
m.followOutput = true
|
|
}
|
|
return m, nil
|
|
}
|
|
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
|
return m, nil
|
|
}
|
|
statusH := 0
|
|
if m.statusVisible() {
|
|
statusH = 1
|
|
}
|
|
inputTop := chatHeight + statusH
|
|
// Chat column: chat box on top, input box below the (optional) status row.
|
|
if x < chatWidth {
|
|
switch {
|
|
case y >= inputTop:
|
|
m.focus = focusInput
|
|
m.input.Focus()
|
|
if line, col, ok := m.inputContentCell(x, y); ok {
|
|
m.beginSelection(regionInput, line, col)
|
|
} else {
|
|
m.selection.active = false
|
|
}
|
|
case y < chatHeight:
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
if line, col, ok := m.chatContentCell(x, y); ok {
|
|
m.beginSelection(regionChat, line, col)
|
|
} else {
|
|
m.selection.active = false
|
|
}
|
|
default:
|
|
m.selection.active = false
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
if !showSidebar || x < chatWidth+1 {
|
|
return m, nil
|
|
}
|
|
// Sidebar: viewer, agents, vulnerabilities, then stats.
|
|
switch {
|
|
case y < viewerHeight:
|
|
return m, send(m.client, "viewer.open", map[string]any{})
|
|
case y < viewerHeight+agentHeight:
|
|
m.focus = focusAgents
|
|
m.input.Blur()
|
|
// Content starts after the top border (1) and vertical padding (1).
|
|
entries := agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)
|
|
start := windowStart(m.agentOffset, len(entries), max(1, agentHeight-4))
|
|
localY := y - viewerHeight
|
|
if row := start + localY - 2; localY >= 2 && localY < agentHeight-2 && row < len(entries) {
|
|
m.selectedAgent = entries[row].index
|
|
agentID := m.snapshot.Agents[m.selectedAgent].ID
|
|
if hasAgentChildren(agentID, m.snapshot.Agents) {
|
|
m.collapsedAgents[agentID] = !m.collapsedAgents[agentID]
|
|
m.ensureAgentVisible()
|
|
}
|
|
m.refreshViewport()
|
|
}
|
|
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
|
|
m.focus = focusVulnerabilities
|
|
m.input.Blur()
|
|
// Content starts after the top border (1); clicking a row opens its detail.
|
|
row := y - viewerHeight - agentHeight - 1
|
|
if idx := m.vulnerabilityIndexAtRow(row); row >= 0 && row < vulnHeight-2 && idx >= 0 {
|
|
m.selectedVuln = idx
|
|
m.openModal(modalVulnerability)
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) updateMainScrollbarMouse(
|
|
msg tea.MouseMsg,
|
|
showSidebar bool,
|
|
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int,
|
|
) bool {
|
|
if msg.Action == tea.MouseActionRelease {
|
|
if m.draggingScrollbar == scrollbarNone {
|
|
return false
|
|
}
|
|
m.draggingScrollbar = scrollbarNone
|
|
return true
|
|
}
|
|
if msg.Action == tea.MouseActionMotion && m.draggingScrollbar != scrollbarNone {
|
|
m.scrollFromMouse(m.draggingScrollbar, msg.Y, chatHeight, viewerHeight, agentHeight)
|
|
return true
|
|
}
|
|
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
|
return false
|
|
}
|
|
target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight)
|
|
if target == scrollbarNone {
|
|
return false
|
|
}
|
|
m.draggingScrollbar = target
|
|
m.scrollFromMouse(target, msg.Y, chatHeight, viewerHeight, agentHeight)
|
|
return true
|
|
}
|
|
|
|
// scrollbarGrab is how far either side of the bar still counts as grabbing it. A
|
|
// one column target is unreasonable to hit with a mouse, and nothing else lives
|
|
// in the column beside it.
|
|
const scrollbarGrab = 1
|
|
|
|
func nearColumn(x, column int) bool {
|
|
return x >= column-scrollbarGrab && x <= column+scrollbarGrab
|
|
}
|
|
|
|
// scrollbarAt reports which scrollbar, if any, the pointer is over.
|
|
func (m Model) scrollbarAt(
|
|
msg tea.MouseMsg,
|
|
showSidebar bool,
|
|
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int,
|
|
) scrollbarTarget {
|
|
switch {
|
|
case nearColumn(msg.X, chatWidth-2) && msg.Y >= 1 && msg.Y < chatHeight-1 &&
|
|
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
|
|
return scrollbarTrace
|
|
case showSidebar && nearColumn(msg.X, m.width-3) && msg.Y >= viewerHeight+2 &&
|
|
msg.Y < viewerHeight+agentHeight-2 &&
|
|
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
|
|
return scrollbarAgents
|
|
case showSidebar && vulnHeight > 0 && nearColumn(msg.X, m.width-3) &&
|
|
msg.Y >= viewerHeight+agentHeight+1 &&
|
|
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
|
|
totalRows, _ := m.vulnerabilityScrollRows()
|
|
if totalRows > m.vulnerabilityPageSize() {
|
|
return scrollbarFindings
|
|
}
|
|
}
|
|
return scrollbarNone
|
|
}
|
|
|
|
func (m *Model) scrollFromMouse(
|
|
target scrollbarTarget,
|
|
y, chatHeight, viewerHeight, agentHeight int,
|
|
) {
|
|
switch target {
|
|
case scrollbarTrace:
|
|
height := max(1, chatHeight-2)
|
|
offset := scrollbarOffset(y-1, height, m.viewport.TotalLineCount(), m.viewport.VisibleLineCount())
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.viewport.SetYOffset(offset)
|
|
m.followOutput = m.viewport.AtBottom()
|
|
case scrollbarAgents:
|
|
height := m.agentPageSize()
|
|
total := len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents))
|
|
m.focus = focusAgents
|
|
m.input.Blur()
|
|
m.agentOffset = scrollbarOffset(y-viewerHeight-2, height, total, height)
|
|
m.keepAgentSelectionInWindow()
|
|
m.refreshViewport()
|
|
case scrollbarFindings:
|
|
height := m.vulnerabilityPageSize()
|
|
totalRows, _ := m.vulnerabilityScrollRows()
|
|
m.focus = focusVulnerabilities
|
|
m.input.Blur()
|
|
// The offset is a row, so dragging moves the list continuously.
|
|
m.vulnOffset = scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
|
|
m.keepVulnerabilitySelectionInWindow()
|
|
}
|
|
}
|
|
|
|
func scrollbarOffset(row, height, total, visible int) int {
|
|
maxOffset := max(0, total-visible)
|
|
if height <= 1 || maxOffset == 0 {
|
|
return 0
|
|
}
|
|
return maxOffset * min(max(0, row), height-1) / (height - 1)
|
|
}
|
|
|
|
func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
switch msg.Button {
|
|
case tea.MouseButtonWheelUp:
|
|
m.focus = focusChat
|
|
m.input.Blur()
|
|
m.followOutput = false
|
|
m.viewport.LineUp(3)
|
|
return m, nil
|
|
case tea.MouseButtonWheelDown:
|
|
m.viewport.LineDown(3)
|
|
if m.viewport.AtBottom() {
|
|
m.followOutput = true
|
|
}
|
|
return m, nil
|
|
}
|
|
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
|
m.focus = focusInput
|
|
m.input.Focus()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// pressReportButton performs a button of the report row, however it was reached.
|
|
func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) {
|
|
switch button {
|
|
case reportPrev:
|
|
m.showVulnerability(m.selectedVuln - 1)
|
|
case reportNext:
|
|
m.showVulnerability(m.selectedVuln + 1)
|
|
case reportCopy:
|
|
m.reportFocus = reportCopy
|
|
return m, m.startVulnerabilityCopy()
|
|
default:
|
|
m.closeModal()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
if m.modal == modalVulnerability {
|
|
view := m.modalView()
|
|
left, top, _, _ := m.centeredViewBounds(view)
|
|
viewportLeft := left + 4 // border and three-cell dialog padding
|
|
viewportTop := top + 3 // border and two-cell dialog padding
|
|
insideViewport := msg.X >= viewportLeft && msg.X < viewportLeft+m.vulnViewport.Width+2 &&
|
|
msg.Y >= viewportTop && msg.Y < viewportTop+m.vulnViewport.Height
|
|
switch msg.Button {
|
|
case tea.MouseButtonWheelUp:
|
|
if insideViewport {
|
|
m.vulnViewport.LineUp(3)
|
|
}
|
|
return m, nil
|
|
case tea.MouseButtonWheelDown:
|
|
if insideViewport {
|
|
m.vulnViewport.LineDown(3)
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
|
|
return m, nil
|
|
}
|
|
view := m.modalView()
|
|
switch m.modal {
|
|
case modalQuit, modalStop:
|
|
if m.centeredLabelHit(view, "Yes", msg.X, msg.Y) {
|
|
m.modalChoice = 0
|
|
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
|
}
|
|
if m.centeredLabelHit(view, "No", msg.X, msg.Y) {
|
|
m.modalChoice = 1
|
|
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
|
|
}
|
|
case modalConfirmMount:
|
|
left, top, panel := m.mountPromptBounds()
|
|
if labelHitAt(panel, mountConfirmLabel, left, top, msg.X, msg.Y) {
|
|
m.modalChoice = 0
|
|
cmd := m.answerMountConfirmation(true)
|
|
return m, cmd
|
|
}
|
|
if labelHitAt(panel, mountCancelLabel, left, top, msg.X, msg.Y) {
|
|
m.modalChoice = 1
|
|
cmd := m.answerMountConfirmation(false)
|
|
return m, cmd
|
|
}
|
|
case modalVulnerability:
|
|
for _, button := range m.reportButtons() {
|
|
if button == reportCopy || button == reportDone {
|
|
continue
|
|
}
|
|
if m.centeredLabelHit(view, button, msg.X, msg.Y) {
|
|
m.reportFocus = button
|
|
return m.pressReportButton(button)
|
|
}
|
|
}
|
|
if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) {
|
|
m.reportFocus = reportCopy
|
|
cmd := m.startVulnerabilityCopy()
|
|
return m, cmd
|
|
}
|
|
if m.centeredLabelHit(view, "Done", msg.X, msg.Y) {
|
|
m.reportFocus = reportDone
|
|
m.closeModal()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) centeredViewBounds(view string) (left, top, width, height int) {
|
|
width = lipgloss.Width(view)
|
|
height = strings.Count(view, "\n") + 1
|
|
left = max(0, (m.width-width)/2)
|
|
top = max(0, (m.height-height)/2)
|
|
return
|
|
}
|
|
|
|
func (m Model) centeredLabelHit(view, label string, x, y int) bool {
|
|
left, top, _, _ := m.centeredViewBounds(view)
|
|
return labelHitAt(view, label, left, top, x, y)
|
|
}
|
|
|
|
// labelHitAt reports whether a click landed on a label drawn in a panel whose
|
|
// top-left corner is at (left, top). The mount prompt is docked in a corner
|
|
// rather than centered, so it cannot use the centered bounds.
|
|
func labelHitAt(panel, label string, left, top, x, y int) bool {
|
|
for row, line := range strings.Split(panel, "\n") {
|
|
plain := ansi.Strip(line)
|
|
index := strings.Index(plain, label)
|
|
if index < 0 || y != top+row {
|
|
continue
|
|
}
|
|
start := left + ansi.StringWidth(plain[:index])
|
|
return x >= start-1 && x < start+ansi.StringWidth(label)+1
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *Model) cycleFocus(delta int) {
|
|
available := []focusMode{focusInput, focusChat}
|
|
if m.width >= 120 {
|
|
available = append(available, focusAgents)
|
|
if len(m.snapshot.Vulnerabilities) > 0 {
|
|
available = append(available, focusVulnerabilities)
|
|
}
|
|
}
|
|
idx := 0
|
|
for i, focus := range available {
|
|
if focus == m.focus {
|
|
idx = i
|
|
}
|
|
}
|
|
m.focus = available[clampCycle(idx+delta, len(available))]
|
|
if m.focus == focusInput {
|
|
m.input.Focus()
|
|
} else {
|
|
m.input.Blur()
|
|
}
|
|
}
|
|
|
|
func clampCycle(value, length int) int {
|
|
if length <= 0 {
|
|
return 0
|
|
}
|
|
return (value%length + length) % length
|
|
}
|
|
|
|
func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.modal == modalHelp {
|
|
if key.String() != "" {
|
|
m.closeModal()
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.modal == modalVulnerability {
|
|
switch key.String() {
|
|
case "esc":
|
|
m.closeModal()
|
|
// The arrows step between reports directly; tab walks the button row.
|
|
case "left":
|
|
m.showVulnerability(m.selectedVuln - 1)
|
|
case "right":
|
|
m.showVulnerability(m.selectedVuln + 1)
|
|
case "tab":
|
|
m.stepReportFocus(1)
|
|
case "shift+tab":
|
|
m.stepReportFocus(-1)
|
|
case "enter":
|
|
return m.pressReportButton(m.focusedReportButton())
|
|
case "c":
|
|
m.reportFocus = reportCopy
|
|
cmd := m.startVulnerabilityCopy()
|
|
return m, cmd
|
|
case "up":
|
|
m.vulnViewport.LineUp(1)
|
|
case "down":
|
|
m.vulnViewport.LineDown(1)
|
|
case "pgup":
|
|
m.vulnViewport.HalfViewUp()
|
|
case "pgdown":
|
|
m.vulnViewport.HalfViewDown()
|
|
case "home":
|
|
m.vulnViewport.GotoTop()
|
|
case "end":
|
|
m.vulnViewport.GotoBottom()
|
|
}
|
|
return m, nil
|
|
}
|
|
switch key.String() {
|
|
case "esc":
|
|
if m.modal == modalConfirmMount {
|
|
// The backend is waiting on an answer; escape declines it.
|
|
cmd := m.answerMountConfirmation(false)
|
|
return m, cmd
|
|
}
|
|
m.closeModal()
|
|
return m, nil
|
|
case "left", "right", "up", "down", "tab":
|
|
m.modalChoice = 1 - m.modalChoice
|
|
return m, nil
|
|
case "enter":
|
|
modal, choice := m.modal, m.modalChoice
|
|
if modal == modalConfirmMount {
|
|
// The snapshot closes this prompt once the backend has the answer.
|
|
// Bound to a variable first: the call restores the held prompt into
|
|
// the composer, and that has to be in the model being returned.
|
|
cmd := m.answerMountConfirmation(choice == 0)
|
|
return m, cmd
|
|
}
|
|
m.closeModal()
|
|
if choice == 1 {
|
|
return m, nil
|
|
}
|
|
if modal == modalQuit {
|
|
m.quitting = true
|
|
return m, tea.Batch(send(m.client, "app.quit", map[string]any{}), tea.Quit)
|
|
}
|
|
if modal == modalStop && m.selectedAgentCanStop() {
|
|
agent := m.snapshot.Agents[m.selectedAgent]
|
|
return m, send(m.client, "agent.stop", map[string]any{"agent_id": agent.ID})
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) openModal(mode modalMode) {
|
|
m.modal = mode
|
|
m.input.Blur()
|
|
if mode == modalConfirmMount {
|
|
// A consent prompt defaults to declining.
|
|
m.modalChoice = 1
|
|
}
|
|
if mode == modalVulnerability {
|
|
m.reportFocus = reportDone
|
|
m.modalChoice = 1
|
|
m.vulnerabilityCopied = false
|
|
m.vulnerabilityCopyError = ""
|
|
m.resizeVulnerabilityViewport()
|
|
m.vulnViewport.GotoTop()
|
|
}
|
|
}
|
|
|
|
func (m *Model) closeModal() {
|
|
m.modal = modalNone
|
|
if m.focus == focusInput {
|
|
m.input.Focus()
|
|
}
|
|
}
|