Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
529 lines
18 KiB
Go
529 lines
18 KiB
Go
package app
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"charm.land/bubbles/v2/textarea"
|
||
"charm.land/bubbles/v2/viewport"
|
||
tea "charm.land/bubbletea/v2"
|
||
"charm.land/lipgloss/v2"
|
||
|
||
"github.com/atomine-elektrine/tarakan-client/internal/agent"
|
||
"github.com/atomine-elektrine/tarakan-client/internal/api"
|
||
repoctx "github.com/atomine-elektrine/tarakan-client/internal/context"
|
||
"github.com/atomine-elektrine/tarakan-client/internal/session"
|
||
)
|
||
|
||
const (
|
||
minimumWidth = 48
|
||
minimumHeight = 14
|
||
)
|
||
|
||
var (
|
||
accent = lipgloss.Color("#E05A33")
|
||
muted = lipgloss.Color("#777777")
|
||
subtle = lipgloss.Color("#353535")
|
||
brandStyle = lipgloss.NewStyle().Bold(true).Foreground(accent)
|
||
mutedStyle = lipgloss.NewStyle().Foreground(muted)
|
||
systemStyle = lipgloss.NewStyle().Foreground(muted)
|
||
userStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#F2F2F2"))
|
||
agentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#D7D7D7"))
|
||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B"))
|
||
headerStyle = lipgloss.NewStyle().Padding(0, 1).BorderBottom(true).BorderStyle(lipgloss.NormalBorder()).BorderForeground(subtle)
|
||
inputStyle = lipgloss.NewStyle().Padding(0, 1).BorderTop(true).BorderStyle(lipgloss.NormalBorder()).BorderForeground(subtle)
|
||
footerStyle = lipgloss.NewStyle().Padding(0, 1).Foreground(muted)
|
||
)
|
||
|
||
type Model struct {
|
||
repository repoctx.Info
|
||
registry agent.Registry
|
||
selected agent.Provider
|
||
apiConfig api.Config
|
||
transcript session.Transcript
|
||
viewport viewport.Model
|
||
input textarea.Model
|
||
width int
|
||
height int
|
||
busy bool
|
||
// busyStatus is the live footer/status line while busy (clone, agent, …).
|
||
busyStatus string
|
||
// workEvents receives live progress from a background job; nil when idle.
|
||
workEvents <-chan workEvent
|
||
|
||
// startJobID, when set (CLI --job), auto-starts that job after mount.
|
||
// startPickup, when set (CLI --pickup / report --interactive without --job),
|
||
// claims the next open report job for this repository.
|
||
startJobID int64
|
||
startPickup bool
|
||
queueFilter api.QueueFilter
|
||
|
||
// Pending, human-reviewable artifacts awaiting an explicit submit command.
|
||
pendingEvidence *pendingEvidence
|
||
pendingScan *pendingScan
|
||
pendingJobReport *pendingJobReport
|
||
pendingVerdict *pendingVerdict
|
||
pendingLogin *pendingLogin
|
||
}
|
||
|
||
// SessionOpts configures auto-start behavior for the interactive UI.
|
||
type SessionOpts struct {
|
||
JobID int64 // claim+run this job on start (0 = none)
|
||
Pickup bool // claim+run the next open report job on start
|
||
APIConfig api.Config // host URL + token (--url/--token or env)
|
||
Filter api.QueueFilter // min stars / language / kind for pickup
|
||
}
|
||
|
||
func New(repository repoctx.Info, registry agent.Registry, selected agent.Provider) Model {
|
||
return NewSession(repository, registry, selected, SessionOpts{})
|
||
}
|
||
|
||
// NewWithJob builds the interactive session and optionally auto-starts a job.
|
||
func NewWithJob(repository repoctx.Info, registry agent.Registry, selected agent.Provider, jobID int64) Model {
|
||
return NewSession(repository, registry, selected, SessionOpts{JobID: jobID})
|
||
}
|
||
|
||
// NewSession builds the interactive session.
|
||
func NewSession(repository repoctx.Info, registry agent.Registry, selected agent.Provider, opts SessionOpts) Model {
|
||
input := textarea.New()
|
||
input.Placeholder = "Next: /login"
|
||
input.Prompt = "› "
|
||
input.ShowLineNumbers = false
|
||
input.DynamicHeight = true
|
||
input.MinHeight = 1
|
||
input.MaxHeight = 3
|
||
input.MaxContentHeight = 6
|
||
input.CharLimit = 4_000
|
||
input.SetVirtualCursor(true)
|
||
// Focus must be set on this value before it is stored. Init() receives a
|
||
// copy of the Model, so m.input.Focus() there would only focus a throwaway
|
||
// textarea and leave the real one ignoring every keypress.
|
||
_ = input.Focus()
|
||
|
||
view := viewport.New()
|
||
view.SoftWrap = true
|
||
view.FillHeight = true
|
||
|
||
// Explicit job wins over free-form pickup.
|
||
pickup := opts.Pickup && opts.JobID <= 0
|
||
|
||
apiConfig := opts.APIConfig
|
||
if apiConfig.BaseURL == "" && apiConfig.Token == "" {
|
||
apiConfig = api.LoadConfig("", "")
|
||
}
|
||
|
||
model := Model{
|
||
repository: repository,
|
||
registry: registry,
|
||
selected: selected,
|
||
apiConfig: apiConfig,
|
||
viewport: view,
|
||
input: input,
|
||
width: 80,
|
||
height: 24,
|
||
startJobID: opts.JobID,
|
||
startPickup: pickup,
|
||
queueFilter: opts.Filter,
|
||
}
|
||
model.transcript.Append(session.RoleSystem, startupContextLine(repository))
|
||
model.transcript.Append(session.RoleSystem, "API "+apiConfig.Summary()+" (/login to sign in; /config to inspect)")
|
||
model.appendDetectionStatus()
|
||
switch {
|
||
case opts.JobID > 0:
|
||
model.transcript.Append(session.RoleSystem, fmt.Sprintf(
|
||
"Starting job #%d: claim, run agent, then /submit-report when you accept the findings.", opts.JobID))
|
||
case pickup:
|
||
model.transcript.Append(session.RoleSystem,
|
||
"Auto-pickup: will claim the next open report job from the global queue (preferring this repo), run the agent, then wait for /submit-report.")
|
||
default:
|
||
model.appendWorkflowGuide()
|
||
}
|
||
model.updateInputHint()
|
||
model.resize(model.width, model.height)
|
||
model.refreshTranscript()
|
||
return model
|
||
}
|
||
|
||
func (m Model) Init() tea.Cmd {
|
||
// Cursor blink (and re-assert focus). Focus on the stored model was set in New.
|
||
cmds := []tea.Cmd{m.input.Focus(), m.viewport.Init()}
|
||
switch {
|
||
case m.startJobID > 0:
|
||
id := m.startJobID
|
||
cmds = append(cmds, func() tea.Msg { return startJobMsg{id: id} })
|
||
case m.startPickup:
|
||
cmds = append(cmds, func() tea.Msg { return startPickupMsg{} })
|
||
}
|
||
return tea.Batch(cmds...)
|
||
}
|
||
|
||
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch message := message.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.resize(message.Width, message.Height)
|
||
m.refreshTranscript()
|
||
return m, nil
|
||
case tea.KeyPressMsg:
|
||
switch message.String() {
|
||
case "ctrl+c":
|
||
return m, quit
|
||
case "enter":
|
||
if !m.busy {
|
||
return m.submit()
|
||
}
|
||
}
|
||
case workEventMsg:
|
||
return m.handleWorkEvent(message)
|
||
case startJobMsg:
|
||
return m.beginReportJob(message.id)
|
||
case startPickupMsg:
|
||
return m.beginPickup()
|
||
case loginStartedMsg:
|
||
return m.handleLoginStarted(message)
|
||
case loginPollMsg:
|
||
return m.handleLoginPoll(message)
|
||
case loginPollTickMsg:
|
||
if m.pendingLogin == nil {
|
||
return m, nil
|
||
}
|
||
return m, pollLogin(m.pendingLogin)
|
||
case pickedJobMsg, noticeMsg, evidenceReadyMsg, reviewReadyMsg, jobReportReadyMsg, verdictReadyMsg:
|
||
return m.handleWorkMessage(message)
|
||
}
|
||
|
||
var commands []tea.Cmd
|
||
var command tea.Cmd
|
||
m.viewport, command = m.viewport.Update(message)
|
||
commands = append(commands, command)
|
||
m.input, command = m.input.Update(message)
|
||
commands = append(commands, command)
|
||
m.resize(m.width, m.height)
|
||
return m, tea.Batch(commands...)
|
||
}
|
||
|
||
func (m Model) View() tea.View {
|
||
content := lipgloss.JoinVertical(
|
||
lipgloss.Left,
|
||
m.renderHeader(),
|
||
m.viewport.View(),
|
||
inputStyle.Width(m.width-3).Render(m.input.View()),
|
||
m.renderFooter(),
|
||
)
|
||
view := tea.NewView(content)
|
||
view.AltScreen = true
|
||
view.WindowTitle = "Tarakan - " + m.repository.Name
|
||
return view
|
||
}
|
||
|
||
func (m Model) submit() (tea.Model, tea.Cmd) {
|
||
value := strings.TrimSpace(m.input.Value())
|
||
if value == "" {
|
||
return m, nil
|
||
}
|
||
m.input.Reset()
|
||
|
||
if command, ok := parseCommand(value); ok {
|
||
return m.executeCommand(command)
|
||
}
|
||
|
||
next := "/pickup to claim the next report job"
|
||
if m.apiConfig.Token == "" {
|
||
next = "/login to sign in"
|
||
}
|
||
m.transcript.Append(session.RoleSystem,
|
||
"Tarakan uses a guided review workflow; ordinary text does not run an agent. Use "+next+". Use /review only when you intentionally want to review the current repository.")
|
||
m.updateInputHint()
|
||
m.refreshTranscript()
|
||
return m, nil
|
||
}
|
||
|
||
func (m Model) executeCommand(command command) (tea.Model, tea.Cmd) {
|
||
switch command.name {
|
||
case "help":
|
||
m.transcript.Append(session.RoleSystem, strings.Join([]string{
|
||
"API",
|
||
" /login sign in through the Tarakan web app",
|
||
" /url <host> set Tarakan base URL (default https://tarakan.lol)",
|
||
" /token <secret> set API token (shown masked only)",
|
||
" /config show current url + masked token",
|
||
"Backend",
|
||
" /agent [name] list backends, or choose claude|codex|grok|ollama|openrouter",
|
||
" /model <name> set the model for an HTTP backend (ollama, openrouter)",
|
||
" /context show repository context",
|
||
"Jobs (preferred)",
|
||
" /jobs open jobs for this repository",
|
||
" /pickup next open report job from the global queue + run agent",
|
||
" /report same as /pickup (prefers jobs for this repo when present)",
|
||
" /report <id> claim that job, run agent (Review Format)",
|
||
" /submit-report publish pending job Report (Findings on the repo)",
|
||
" /task <id> show a job",
|
||
" /claim <id> claim only · /release <id> release",
|
||
" /run <id> agent prose evidence (legacy) · /submit <id> <summary>",
|
||
"Reviews & verification",
|
||
" /queue repositories awaiting review",
|
||
" /scans reviews of this repository (findings if authorized)",
|
||
" /review ad-hoc agent review of this repo → pending scan",
|
||
" /submit-review submit the pending ad-hoc review",
|
||
" /verify <scan id> run your agent to verify a review → pending verdict",
|
||
" /submit-verdict submit the pending verdict + proof of concept",
|
||
"Session",
|
||
" /clear clear transcript · /quit exit",
|
||
}, "\n"))
|
||
case "login":
|
||
return m.beginLogin()
|
||
case "url":
|
||
if len(command.args) == 0 {
|
||
m.transcript.Append(session.RoleSystem, "Usage: /url https://tarakan.lol (current: "+m.apiConfig.BaseURL+")")
|
||
break
|
||
}
|
||
candidate := m.apiConfig.WithOverrides(command.args[0], "")
|
||
// Validate URL even when token is not set yet.
|
||
checkToken := candidate.Token
|
||
if checkToken == "" {
|
||
checkToken = "placeholder-for-url-check"
|
||
}
|
||
if _, err := api.New(candidate.BaseURL, checkToken, nil); err != nil {
|
||
m.transcript.Append(session.RoleSystem, "Invalid URL: "+err.Error())
|
||
break
|
||
}
|
||
m.apiConfig = candidate
|
||
m.transcript.Append(session.RoleSystem, "API url set to "+m.apiConfig.BaseURL)
|
||
case "token":
|
||
if len(command.args) == 0 {
|
||
m.transcript.Append(session.RoleSystem, "Usage: /token <api-token> (current: "+m.apiConfig.MaskedToken()+")")
|
||
break
|
||
}
|
||
m.apiConfig = m.apiConfig.WithOverrides("", strings.Join(command.args, " "))
|
||
m.transcript.Append(session.RoleSystem, "API token set ("+m.apiConfig.MaskedToken()+").")
|
||
case "config":
|
||
m.transcript.Append(session.RoleSystem, "API "+m.apiConfig.Summary())
|
||
case "agent":
|
||
if len(command.args) == 0 {
|
||
providers := m.registry.Providers()
|
||
if len(providers) == 0 {
|
||
m.transcript.Append(session.RoleSystem, "No review backends detected.")
|
||
break
|
||
}
|
||
names := make([]string, 0, len(providers))
|
||
for _, provider := range providers {
|
||
label := provider.Name
|
||
if provider.Kind == agent.KindHTTP && provider.Model != "" {
|
||
label += " (" + provider.Model + ")"
|
||
}
|
||
if provider.Name == m.selected.Name {
|
||
label += " (selected)"
|
||
}
|
||
names = append(names, label)
|
||
}
|
||
m.transcript.Append(session.RoleSystem, "Available backends: "+strings.Join(names, ", "))
|
||
break
|
||
}
|
||
provider, ok := m.registry.Find(command.args[0])
|
||
if !ok {
|
||
m.transcript.Append(session.RoleSystem, fmt.Sprintf("Backend %q is not installed or configured.", command.args[0]))
|
||
break
|
||
}
|
||
m.selected = provider
|
||
m.transcript.Append(session.RoleSystem, provider.Description+" selected.")
|
||
case "model":
|
||
if len(command.args) == 0 {
|
||
m.transcript.Append(session.RoleSystem, "Usage: /model <name> (applies to ollama or openrouter)")
|
||
break
|
||
}
|
||
if m.selected.Kind != agent.KindHTTP {
|
||
m.transcript.Append(session.RoleSystem, "The selected backend uses its own model; /model applies to ollama or openrouter.")
|
||
break
|
||
}
|
||
m.selected = m.selected.WithModel(command.args[0])
|
||
m.transcript.Append(session.RoleSystem, m.selected.Description+" model set to "+m.selected.Model+".")
|
||
case "context":
|
||
context := fmt.Sprintf("Repository: %s\nRoot: %s", m.repository.Name, m.repository.Root)
|
||
if m.repository.IsGit {
|
||
context += fmt.Sprintf("\nBranch: %s\nCommit: %s", valueOr(m.repository.Branch, "detached"), valueOr(m.repository.Commit, "unborn"))
|
||
}
|
||
m.transcript.Append(session.RoleSystem, context)
|
||
case "clear":
|
||
m.transcript.Clear()
|
||
m.transcript.Append(session.RoleSystem, "Transcript cleared.")
|
||
case "quit", "exit":
|
||
return m, quit
|
||
case "jobs", "task", "claim", "release", "report", "pickup", "submit-report", "run", "submit",
|
||
"queue", "scans", "review", "submit-review", "verify", "submit-verdict":
|
||
return m.executeWorkCommand(command)
|
||
default:
|
||
m.transcript.Append(session.RoleSystem, fmt.Sprintf("Unknown command /%s. Type /help.", command.name))
|
||
}
|
||
m.refreshTranscript()
|
||
return m, nil
|
||
}
|
||
|
||
func (m *Model) appendDetectionStatus() {
|
||
providers := m.registry.Providers()
|
||
if len(providers) == 0 {
|
||
m.transcript.Append(session.RoleSystem, "No review backend detected.")
|
||
return
|
||
}
|
||
names := make([]string, 0, len(providers))
|
||
for _, provider := range providers {
|
||
names = append(names, provider.Name)
|
||
}
|
||
status := "Detected: " + strings.Join(names, ", ") + "."
|
||
if m.selected.Name != "" {
|
||
status += " Using " + m.selected.Name + "."
|
||
}
|
||
m.transcript.Append(session.RoleSystem, status)
|
||
}
|
||
|
||
func (m *Model) appendWorkflowGuide() {
|
||
if m.apiConfig.Token == "" {
|
||
m.transcript.Append(session.RoleSystem, strings.Join([]string{
|
||
"Workflow",
|
||
" 1. /login sign in through tarakan.lol ← next",
|
||
" 2. /pickup claim a public review job and run the selected agent",
|
||
" 3. inspect review the structured findings",
|
||
" 4. /submit-report publish only when you approve the result",
|
||
}, "\n"))
|
||
return
|
||
}
|
||
m.transcript.Append(session.RoleSystem, strings.Join([]string{
|
||
"Workflow",
|
||
" 1. signed in ✓",
|
||
" 2. /pickup claim a public review job and run the selected agent ← next",
|
||
" 3. inspect review the structured findings",
|
||
" 4. /submit-report publish only when you approve the result",
|
||
}, "\n"))
|
||
}
|
||
|
||
func (m *Model) updateInputHint() {
|
||
switch {
|
||
case m.apiConfig.Token == "":
|
||
m.input.Placeholder = "Next: /login"
|
||
case m.pendingJobReport != nil:
|
||
m.input.Placeholder = "Next: /submit-report (after reviewing findings)"
|
||
case m.pendingVerdict != nil:
|
||
m.input.Placeholder = "Next: /submit-verdict (after reviewing checks)"
|
||
case m.pendingScan != nil:
|
||
m.input.Placeholder = "Next: /submit-review (after reviewing findings)"
|
||
default:
|
||
m.input.Placeholder = "Next: /pickup · /help for other actions"
|
||
}
|
||
}
|
||
|
||
func (m *Model) resize(width, height int) {
|
||
m.width = max(width, minimumWidth)
|
||
m.height = max(height, minimumHeight)
|
||
m.input.SetWidth(m.width - 4)
|
||
headerHeight := lipgloss.Height(m.renderHeader())
|
||
inputHeight := lipgloss.Height(inputStyle.Width(m.width - 3).Render(m.input.View()))
|
||
footerHeight := lipgloss.Height(m.renderFooter())
|
||
m.viewport.SetWidth(m.width)
|
||
m.viewport.SetHeight(max(3, m.height-headerHeight-inputHeight-footerHeight))
|
||
}
|
||
|
||
func (m *Model) refreshTranscript() {
|
||
wasAtBottom := m.viewport.AtBottom()
|
||
var builder strings.Builder
|
||
for index, message := range m.transcript.Messages() {
|
||
if index > 0 {
|
||
builder.WriteString("\n\n")
|
||
}
|
||
label := string(message.Role)
|
||
style := agentStyle
|
||
switch message.Role {
|
||
case session.RoleSystem:
|
||
style = systemStyle
|
||
case session.RoleUser:
|
||
style = userStyle
|
||
}
|
||
if strings.HasPrefix(message.Content, "Error:") {
|
||
style = errorStyle
|
||
}
|
||
builder.WriteString(style.Render(label + "\n" + message.Content))
|
||
}
|
||
m.viewport.SetContent(lipgloss.NewStyle().Padding(1, 2).Width(max(1, m.width-4)).Render(builder.String()))
|
||
if wasAtBottom || m.viewport.TotalLineCount() <= m.viewport.Height() {
|
||
m.viewport.GotoBottom()
|
||
}
|
||
}
|
||
|
||
func (m Model) renderHeader() string {
|
||
repository := m.repository.Name
|
||
if m.repository.IsGit {
|
||
repository += " " + valueOr(m.repository.Branch, "detached")
|
||
if m.repository.Commit != "" {
|
||
repository += "@" + m.repository.Commit
|
||
}
|
||
}
|
||
agentName := "no agent"
|
||
if m.selected.Name != "" {
|
||
agentName = m.selected.Name
|
||
}
|
||
left := brandStyle.Render("TARAKAN") + " " + mutedStyle.Render(repository)
|
||
right := mutedStyle.Render(agentName)
|
||
space := strings.Repeat(" ", max(1, m.width-lipgloss.Width(left)-lipgloss.Width(right)-3))
|
||
return headerStyle.Width(m.width - 1).Render(left + space + right)
|
||
}
|
||
|
||
func (m Model) renderFooter() string {
|
||
status := "enter run command · /help commands · ctrl+c quit"
|
||
if m.busy {
|
||
if m.busyStatus != "" {
|
||
status = truncateRunes(m.busyStatus, max(20, m.width-18)) + " · ctrl+c quits"
|
||
} else if m.selected.Name != "" {
|
||
status = m.selected.Description + " is working · ctrl+c quits"
|
||
} else {
|
||
status = "Working… · ctrl+c quits"
|
||
}
|
||
}
|
||
return footerStyle.Width(m.width - 3).Render(status)
|
||
}
|
||
|
||
func truncateRunes(s string, maxLen int) string {
|
||
if maxLen <= 0 {
|
||
return ""
|
||
}
|
||
runes := []rune(s)
|
||
if len(runes) <= maxLen {
|
||
return s
|
||
}
|
||
if maxLen <= 1 {
|
||
return string(runes[:maxLen])
|
||
}
|
||
return string(runes[:maxLen-1]) + "…"
|
||
}
|
||
|
||
func valueOr(value, fallback string) string {
|
||
if value == "" {
|
||
return fallback
|
||
}
|
||
return value
|
||
}
|
||
|
||
// startupContextLine explains what directory Tarakan is attached to. This is
|
||
// only local cwd discovery - not "you already claimed a job" or "API is ready".
|
||
func startupContextLine(repository repoctx.Info) string {
|
||
if !repository.IsGit {
|
||
return "Working directory: " + repository.Root + " (not a git repo). /pickup can still clone a job elsewhere."
|
||
}
|
||
line := "Local git: " + repository.Root
|
||
if owner, name, ok := repository.RemoteSlug(); ok {
|
||
if repository.Host != "" {
|
||
line += " · origin " + repository.Host + "/" + owner + "/" + name
|
||
} else {
|
||
line += " · origin " + owner + "/" + name
|
||
}
|
||
} else {
|
||
line += " · no origin remote"
|
||
}
|
||
if repository.Branch != "" || repository.Commit != "" {
|
||
line += " · " + valueOr(repository.Branch, "detached")
|
||
if repository.Commit != "" {
|
||
line += "@" + repository.Commit
|
||
}
|
||
}
|
||
return line
|
||
}
|
||
|
||
func quit() tea.Msg {
|
||
return tea.Quit()
|
||
}
|