Files
2026-03-29 18:41:06 -07:00

6331 lines
175 KiB
Go

package ai
/*
Sliver Implant Framework
Copyright (C) 2026 Bishop Fox
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import (
"bytes"
"encoding/json"
"fmt"
"hash/fnv"
"image/color"
"sort"
"strings"
"time"
"unicode/utf8"
"charm.land/bubbles/v2/progress"
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
"github.com/atotto/clipboard"
"github.com/bishopfox/sliver/client/console"
consts "github.com/bishopfox/sliver/client/constants"
aithinking "github.com/bishopfox/sliver/client/spin/thinking"
clienttheme "github.com/bishopfox/sliver/client/theme"
"github.com/bishopfox/sliver/protobuf/clientpb"
"github.com/bishopfox/sliver/protobuf/commonpb"
"github.com/bishopfox/sliver/util/clientpbutil"
"github.com/charmbracelet/x/ansi"
"golang.org/x/term"
"google.golang.org/protobuf/proto"
)
const (
aiMinWidth = 72
aiMinHeight = 17
aiPaneHorizontalChrome = 4
aiPaneVerticalChrome = 2
aiToolCallSectionMaxLines = 15
aiTranscriptScrollbarWidth = 1
aiTranscriptMouseWheelStep = 3
aiModalDismissDelay = 250 * time.Millisecond
aiWindowPollInterval = 100 * time.Millisecond
aiToastDuration = 10 * time.Second
aiToastStackLimit = 4
)
type aiFocus int
type aiModalKind int
type aiModalFocusTarget int
const (
aiFocusSidebar aiFocus = iota
aiFocusTranscript
aiFocusComposer
)
const (
aiModalKindInfo aiModalKind = iota
aiModalKindDeleteConfirm
aiModalKindContext
aiModalKindNewConversation
aiModalKindThinkingLevel
aiModalKindTargetSelect
aiModalKindExperimentalWarning
)
const (
aiExperimentalWarningTitle = ">>> WARNING >>>"
aiExperimentalWarningBody = "This functionality is provided on an EXPERIMENTAL basis and may be UNSAFE or unstable. No guarantees are made regarding reliability or data integrity. Use at your own risk."
aiExperimentalWarningCancelLabel = "I can't read"
aiExperimentalWarningConfirmLabel = "I won't be surprised if the model nukes a machine"
)
const (
aiModalFocusInput aiModalFocusTarget = iota
aiModalFocusSystemPrompt
aiModalFocusCancel
aiModalFocusConfirm
)
type aiStyles struct {
badge lipgloss.Style
chip lipgloss.Style
chipMuted lipgloss.Style
subtleText lipgloss.Style
footerHint lipgloss.Style
pane lipgloss.Style
paneFocused lipgloss.Style
paneTitle lipgloss.Style
item lipgloss.Style
itemSelected lipgloss.Style
itemFocused lipgloss.Style
optionFocused lipgloss.Style
heading lipgloss.Style
cursor lipgloss.Style
inputText lipgloss.Style
inputPlaceholder lipgloss.Style
roleUser lipgloss.Style
warning lipgloss.Style
danger lipgloss.Style
selection lipgloss.Style
}
type aiLoadedMsg struct {
providers []*clientpb.AIProviderConfig
config *clientpb.AIConfigSummary
conversations []*clientpb.AIConversation
conversation *clientpb.AIConversation
selectedID string
status string
}
type aiConversationCreatedMsg struct {
conversationID string
status string
}
type aiPromptSubmittedMsg struct {
conversationID string
conversation *clientpb.AIConversation
message *clientpb.AIConversationMessage
status string
}
type aiConversationDeletedMsg struct {
conversationID string
selectedID string
status string
}
type aiConversationUpdatedMsg struct {
conversation *clientpb.AIConversation
status string
}
type aiConversationTargetUpdatedMsg struct {
conversation *clientpb.AIConversation
target aiTargetSummary
status string
}
type aiConversationEventMsg struct {
event *clientpb.AIConversationEvent
}
type aiListenerClosedMsg struct{}
type aiAsyncErrMsg struct {
err error
}
type aiToastMsg struct {
level string
message string
}
type aiToastExpiredMsg struct {
id uint64
}
type aiTranscriptRenderedMsg struct {
key string
rendered string
lines []string
content []aiTranscriptContentLine
}
type aiWindowPollMsg struct {
width int
height int
}
type aiStartupConfigInvalidMsg struct {
err string
}
type aiTargetOptionsLoadedMsg struct {
options []aiTargetSelectionOption
selectedOption int
status string
}
type aiModalState struct {
kind aiModalKind
title string
body string
dismissReadyAt time.Time
focus aiModalFocusTarget
input []rune
cursor int
systemPrompt []rune
systemCursor int
confirmDelete bool
selectedOption int
conversationID string
selectedID string
status string
}
type aiThinkingLevelOption struct {
label string
value string
description string
}
type aiTargetSelectionOption struct {
label string
metadata []string
target aiTargetSummary
session *clientpb.Session
beacon *clientpb.Beacon
active bool
}
type aiContextField struct {
label string
value string
muted bool
}
type aiContextSection struct {
title string
fields []aiContextField
}
type aiPaneRect struct {
x int
y int
width int
height int
}
func (r aiPaneRect) contains(x, y int) bool {
return r.width > 0 &&
r.height > 0 &&
x >= r.x &&
x < r.x+r.width &&
y >= r.y &&
y < r.y+r.height
}
type aiPaneRects struct {
sidebar aiPaneRect
transcript aiPaneRect
composer aiPaneRect
}
type aiToastState struct {
id uint64
level string
message string
createdAt time.Time
expiresAt time.Time
bar progress.Model
}
type aiTranscriptContentLine struct {
styled string
selectableStart int
selectableText string
selectablePrefix string
selectableAreaWidth int
}
func (l aiTranscriptContentLine) selectableWidth() int {
return ansi.StringWidth(l.selectableText)
}
func (l aiTranscriptContentLine) hasSelectableText() bool {
return l.selectableAreaWidth > 0 && l.selectableWidth() > 0
}
type aiTranscriptSelectionPoint struct {
line int
col int
}
type aiTranscriptSelection struct {
anchor aiTranscriptSelectionPoint
active aiTranscriptSelectionPoint
dragging bool
}
type aiModel struct {
width int
height int
focus aiFocus
ctx aiContext
con *console.SliverClient
listener <-chan *clientpb.Event
providers []*clientpb.AIProviderConfig
config *clientpb.AIConfigSummary
conversations []*clientpb.AIConversation
currentConversation *clientpb.AIConversation
selectedConversation int
input []rune
cursor int
status string
loading bool
awaitingResponse bool
submittingPrompt bool
pendingPrompt string
modal *aiModalState
thinkingAnim *aithinking.Anim
submitResults chan tea.Msg
styles aiStyles
transcriptVersion int
transcriptPendingKey string
transcriptCacheKey string
transcriptCache string
transcriptCacheLines []string
transcriptCacheContent []aiTranscriptContentLine
transcriptScroll int
transcriptFollow bool
transcriptSelection *aiTranscriptSelection
transcriptScrollbarDrag bool
targetSelectionOptions []aiTargetSelectionOption
toasts []*aiToastState
nextToastID uint64
}
func newAIModel(con *console.SliverClient, ctx aiContext, listener <-chan *clientpb.Event) *aiModel {
return &aiModel{
focus: aiFocusSidebar,
ctx: ctx,
con: con,
listener: listener,
status: ctx.status,
loading: true,
thinkingAnim: newAIThinkingAnim("Working"),
submitResults: make(chan tea.Msg),
styles: newAIStyles(),
transcriptFollow: true,
}
}
func newAIThinkingAnim(label string) *aithinking.Anim {
return aithinking.New(aithinking.Settings{
Size: 10,
Label: label,
LabelColor: clienttheme.DefaultMod(500),
GradColorA: clienttheme.Primary(),
GradColorB: clienttheme.Secondary(),
CycleColors: true,
SkipIntro: true,
})
}
func newAIStyles() aiStyles {
return aiStyles{
badge: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.Primary()).
Padding(0, 1),
chip: lipgloss.NewStyle().
Foreground(clienttheme.PrimaryMod(700)).
Background(clienttheme.PrimaryMod(50)).
Padding(0, 1),
chipMuted: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(700)).
Background(clienttheme.DefaultMod(50)).
Padding(0, 1),
subtleText: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(500)),
footerHint: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(700)),
pane: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(clienttheme.DefaultMod(300)).
Padding(0, 1),
paneFocused: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(clienttheme.Primary()).
Padding(0, 1),
paneTitle: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)),
item: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(800)),
itemSelected: lipgloss.NewStyle().
Foreground(clienttheme.Primary()).
Bold(true),
itemFocused: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.PrimaryMod(800)),
optionFocused: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.PrimaryMod(200)),
heading: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)),
cursor: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.Primary()),
inputText: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(900)),
inputPlaceholder: lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(400)),
roleUser: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.Warning()),
warning: lipgloss.NewStyle().
Foreground(clienttheme.Warning()).
Bold(true),
danger: lipgloss.NewStyle().
Foreground(clienttheme.Danger()).
Bold(true),
selection: lipgloss.NewStyle().
Bold(true).
Underline(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.WarningMod(200)),
}
}
func (m *aiModel) Init() tea.Cmd {
if m.width == 0 || m.height == 0 {
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
m.width = w
m.height = h
} else {
m.width = 100
m.height = 30
}
}
cmds := []tea.Cmd{aiWindowPollCmd()}
if m.modal == nil || m.modal.kind != aiModalKindExperimentalWarning {
cmds = append(cmds, m.startAIStartup())
}
return tea.Batch(cmds...)
}
func (m *aiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.modal != nil {
return m.handleModalMsg(msg)
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return m, m.applyWindowSize(msg.Width, msg.Height)
case aiWindowPollMsg:
return m, tea.Batch(m.windowSizeCmd(msg.width, msg.height), aiWindowPollCmd())
case aiStartupConfigInvalidMsg:
m.loading = false
m.status = msg.err
m.modal = &aiModalState{
kind: aiModalKindInfo,
title: "AI Configuration Error",
body: msg.err,
dismissReadyAt: time.Now().Add(aiModalDismissDelay),
}
return m, nil
case aiLoadedMsg:
previousConversationID := selectedConversationID(m.currentConversation, "")
m.loading = false
m.providers = msg.providers
m.config = msg.config
m.conversations = msg.conversations
m.currentConversation = msg.conversation
if selectedConversationID(msg.conversation, msg.selectedID) != previousConversationID {
m.pinTranscriptToLatest()
}
if conversationContainsUserPrompt(m.currentConversation, m.pendingPrompt) {
m.pendingPrompt = ""
}
m.selectedConversation = conversationIndexByID(m.conversations, selectedConversationID(msg.conversation, msg.selectedID))
if m.selectedConversation < 0 {
m.selectedConversation = 0
}
m.invalidateTranscriptCache()
if strings.TrimSpace(msg.status) != "" {
m.status = msg.status
} else if len(m.conversations) == 0 {
m.status = "No AI conversations yet. Create one or send a prompt below."
} else if m.status == "" ||
strings.HasPrefix(m.status, "Loading") ||
strings.HasPrefix(m.status, "Refreshing") ||
strings.HasPrefix(m.status, "Saved prompt to") ||
strings.HasPrefix(m.status, "Waiting for AI response") ||
strings.HasPrefix(m.status, "Deleted ") {
m.status = "Loaded AI conversations from the server."
}
awaitingCmd := m.syncAwaitingResponse()
m.syncTranscriptViewport()
return m, tea.Batch(
awaitingCmd,
m.scheduleTranscriptRender(),
)
case aiConversationCreatedMsg:
m.loading = true
m.status = msg.status
return m, loadAIStateCmd(m.con, m.ctx.target, msg.conversationID)
case aiConversationUpdatedMsg:
m.loading = false
if msg.conversation != nil {
m.applyConversationSnapshot(msg.conversation)
}
if strings.TrimSpace(msg.status) != "" {
m.status = msg.status
} else {
m.status = "Conversation updated."
}
m.invalidateTranscriptCache()
awaitingCmd := m.syncAwaitingResponse()
m.syncTranscriptViewport()
return m, tea.Batch(
awaitingCmd,
m.scheduleTranscriptRender(),
)
case aiConversationTargetUpdatedMsg:
m.loading = false
if msg.conversation != nil {
sessionID, beaconID := normalizedAITargetIDs(msg.target.SessionID, msg.target.BeaconID)
msg.conversation.TargetSessionID = sessionID
msg.conversation.TargetBeaconID = beaconID
m.applyConversationSnapshot(msg.conversation)
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == strings.TrimSpace(msg.conversation.GetID()) {
m.currentConversation.TargetSessionID = sessionID
m.currentConversation.TargetBeaconID = beaconID
}
if idx := conversationIndexByID(m.conversations, msg.conversation.GetID()); idx >= 0 && m.conversations[idx] != nil {
m.conversations[idx].TargetSessionID = sessionID
m.conversations[idx].TargetBeaconID = beaconID
}
}
if strings.TrimSpace(msg.status) != "" {
m.status = msg.status
} else {
m.status = "Active target updated."
}
return m, nil
case aiPromptSubmittedMsg:
m.loading = false
m.submittingPrompt = false
m.pendingPrompt = ""
m.status = msg.status
m.pinTranscriptToLatest()
m.applyOptimisticPrompt(msg.conversation, msg.message)
awaitingCmd := m.syncAwaitingResponse()
m.syncTranscriptViewport()
return m, tea.Batch(
awaitingCmd,
m.scheduleTranscriptRender(),
)
case aiConversationDeletedMsg:
m.loading = true
m.removeConversation(msg.conversationID)
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == strings.TrimSpace(msg.conversationID) {
m.currentConversation = nil
m.awaitingResponse = false
m.submittingPrompt = false
m.pendingPrompt = ""
}
m.pinTranscriptToLatest()
m.invalidateTranscriptCache()
m.syncTranscriptViewport()
m.status = msg.status
return m, loadAIStateWithStatusCmd(m.con, m.ctx.target, msg.selectedID, msg.status)
case aiConversationEventMsg:
m.applyConversationEvent(msg.event)
m.syncTranscriptViewport()
return m, tea.Batch(
waitForAIConversationEventCmd(m.listener),
m.syncAwaitingResponse(),
m.scheduleTranscriptRender(),
)
case aiToastMsg:
return m, tea.Batch(
waitForAIConversationEventCmd(m.listener),
m.showToast(msg.level, msg.message),
)
case aiToastExpiredMsg:
m.expireToast(msg.id)
return m, nil
case aiListenerClosedMsg:
m.status = "AI event stream closed. Reopen the AI TUI to resume live updates."
return m, nil
case aiAsyncErrMsg:
m.loading = false
if m.submittingPrompt {
m.submittingPrompt = false
m.awaitingResponse = false
m.pendingPrompt = ""
}
m.clearTranscriptSelection()
if msg.err != nil {
m.status = "AI sync failed: " + msg.err.Error()
}
m.syncTranscriptViewport()
return m, nil
case aiTranscriptRenderedMsg:
if msg.key != m.transcriptRenderKey(m.currentTranscriptWidth()) {
if m.transcriptPendingKey == msg.key {
m.transcriptPendingKey = ""
}
return m, nil
}
m.transcriptPendingKey = ""
m.transcriptCacheKey = msg.key
m.transcriptCache = msg.rendered
m.transcriptCacheLines = append([]string(nil), msg.lines...)
m.transcriptCacheContent = append([]aiTranscriptContentLine(nil), msg.content...)
m.syncTranscriptViewport()
return m, nil
case tea.PasteMsg:
if m.focus == aiFocusComposer {
return m.handleComposerPaste(msg)
}
return m, nil
case aithinking.StepMsg:
if !m.awaitingResponse || m.thinkingAnim == nil {
return m, nil
}
return m, m.thinkingAnim.Animate(msg)
case tea.KeyPressMsg:
key := msg.Key()
if key.Mod.Contains(tea.ModCtrl) && key.Code == 'c' {
return m, tea.Quit
}
if m.focus == aiFocusComposer {
return m.handleComposerKey(key)
}
return m.handleGlobalKey(key)
case tea.MouseClickMsg:
return m.handleMouseClick(tea.Mouse(msg))
case tea.MouseReleaseMsg:
return m.handleMouseRelease(tea.Mouse(msg))
case tea.MouseMotionMsg:
return m.handleMouseMotion(tea.Mouse(msg))
case tea.MouseWheelMsg:
return m.handleMouseWheel(tea.Mouse(msg))
}
return m, nil
}
func (m *aiModel) handleGlobalKey(key tea.Key) (tea.Model, tea.Cmd) {
if key.Mod.Contains(tea.ModCtrl) && key.Code == 's' {
return m.showTargetSelectModal()
}
switch key.Code {
case tea.KeyTab:
m.focus = (m.focus + 1) % (aiFocusComposer + 1)
return m, nil
case tea.KeyEsc:
return m, tea.Quit
case tea.KeyUp:
if m.focus == aiFocusSidebar {
return m, m.moveSelection(-1)
}
if m.focus == aiFocusTranscript {
m.scrollTranscript(-1)
}
return m, nil
case tea.KeyDown:
if m.focus == aiFocusSidebar {
return m, m.moveSelection(1)
}
if m.focus == aiFocusTranscript {
m.scrollTranscript(1)
}
return m, nil
case tea.KeyPgUp:
if m.focus == aiFocusTranscript {
m.scrollTranscriptPage(-1)
}
return m, nil
case tea.KeyPgDown:
if m.focus == aiFocusTranscript {
m.scrollTranscriptPage(1)
}
return m, nil
case tea.KeyHome:
if m.focus == aiFocusTranscript {
m.scrollTranscriptToTop()
}
return m, nil
case tea.KeyEnd:
if m.focus == aiFocusTranscript {
m.scrollTranscriptToBottom()
}
return m, nil
case tea.KeyEnter:
if m.focus == aiFocusSidebar {
m.focus = aiFocusTranscript
m.status = "Conversation opened."
}
return m, nil
case tea.KeyDelete:
return m.showDeleteConversationModal()
}
switch key.Text {
case "q":
return m, tea.Quit
case "j":
if m.focus == aiFocusSidebar {
return m, m.moveSelection(1)
}
if m.focus == aiFocusTranscript {
m.scrollTranscript(1)
}
case "k":
if m.focus == aiFocusSidebar {
return m, m.moveSelection(-1)
}
if m.focus == aiFocusTranscript {
m.scrollTranscript(-1)
}
case "g":
if m.focus == aiFocusTranscript {
m.scrollTranscriptToTop()
}
case "G":
if m.focus == aiFocusTranscript {
m.scrollTranscriptToBottom()
}
case "n":
return m.showNewConversationModal()
case "r":
m.loading = true
m.status = "Refreshing AI conversations..."
return m, loadAIStateCmd(m.con, m.ctx.target, m.selectedConversationID())
case "x":
return m.showDeleteConversationModal()
case "t":
return m.showThinkingLevelModal()
}
return m, nil
}
func (m *aiModel) handleMouseClick(mouse tea.Mouse) (tea.Model, tea.Cmd) {
if mouse.Button != tea.MouseLeft {
return m, nil
}
focus, ok := m.paneFocusAt(mouse.X, mouse.Y)
if !ok {
m.transcriptScrollbarDrag = false
return m, nil
}
m.focus = focus
switch focus {
case aiFocusSidebar:
m.transcriptScrollbarDrag = false
m.clearTranscriptSelection()
if idx, ok := m.sidebarConversationIndexAt(mouse.X, mouse.Y); ok {
return m, m.selectConversation(idx)
}
return m, nil
case aiFocusTranscript:
if row, ok := m.transcriptScrollbarRowAt(mouse.X, mouse.Y, false); ok {
m.transcriptScrollbarDrag = true
m.clearTranscriptSelection()
m.scrollTranscriptToScrollbarRow(row)
m.status = "Conversation focused. Drag the scrollbar, drag across message text to select it, or use the wheel to scroll."
return m, nil
}
m.transcriptScrollbarDrag = false
m.clearTranscriptSelection()
m.beginTranscriptSelection(mouse.X, mouse.Y)
if m.transcriptSelection != nil {
m.status = "Selecting message text. Release to copy the selection."
return m, nil
}
m.status = "Conversation focused. Drag across message text to select and copy it, drag the scrollbar, or use the wheel to scroll."
return m, nil
default:
m.transcriptScrollbarDrag = false
m.clearTranscriptSelection()
return m, nil
}
}
func (m *aiModel) handleMouseRelease(mouse tea.Mouse) (tea.Model, tea.Cmd) {
if mouse.Button != tea.MouseLeft {
return m, nil
}
if m.transcriptScrollbarDrag {
m.transcriptScrollbarDrag = false
return m, nil
}
if m.transcriptSelection == nil || !m.transcriptSelection.dragging {
return m, nil
}
m.updateTranscriptSelection(mouse.X, mouse.Y, true)
m.transcriptSelection.dragging = false
if m.isCollapsedTranscriptSelection() {
m.clearTranscriptSelection()
return m, nil
}
selected := m.selectedTranscriptText()
if selected == "" {
m.clearTranscriptSelection()
return m, nil
}
copyTranscriptSelectionToClipboard(selected)
m.status = fmt.Sprintf("Copied %d characters from the selected transcript text.", utf8.RuneCountInString(selected))
return m, tea.SetClipboard(selected)
}
func (m *aiModel) handleMouseMotion(mouse tea.Mouse) (tea.Model, tea.Cmd) {
if m.transcriptScrollbarDrag {
if mouse.Button != tea.MouseLeft {
return m, nil
}
if row, ok := m.transcriptScrollbarRowAt(mouse.X, mouse.Y, true); ok {
m.focus = aiFocusTranscript
m.scrollTranscriptToScrollbarRow(row)
}
return m, nil
}
if m.transcriptSelection == nil || !m.transcriptSelection.dragging {
return m, nil
}
if mouse.Button != tea.MouseLeft {
return m, nil
}
m.focus = aiFocusTranscript
m.updateTranscriptSelection(mouse.X, mouse.Y, true)
return m, nil
}
func (m *aiModel) handleMouseWheel(mouse tea.Mouse) (tea.Model, tea.Cmd) {
if !m.currentPaneRects().transcript.contains(mouse.X, mouse.Y) {
return m, nil
}
switch mouse.Button {
case tea.MouseWheelUp:
m.focus = aiFocusTranscript
m.scrollTranscript(-aiTranscriptMouseWheelStep)
case tea.MouseWheelDown:
m.focus = aiFocusTranscript
m.scrollTranscript(aiTranscriptMouseWheelStep)
}
return m, nil
}
func (m *aiModel) handleComposerKey(key tea.Key) (tea.Model, tea.Cmd) {
if key.Code == tea.KeyTab {
m.focus = aiFocusSidebar
return m, nil
}
if key.Mod.Contains(tea.ModCtrl) && key.Code == 'o' {
return m.showContextModal()
}
if key.Mod.Contains(tea.ModCtrl) && key.Code == 's' {
return m.showTargetSelectModal()
}
if key.Mod.Contains(tea.ModCtrl) && key.Code == 't' {
return m.showThinkingLevelModal()
}
if key.Mod.Contains(tea.ModCtrl) && key.Code == 'u' {
m.input = nil
m.cursor = 0
m.status = "Prompt cleared."
return m, nil
}
switch key.Code {
case tea.KeyEnter:
prompt := strings.TrimSpace(string(m.input))
if prompt == "" {
m.status = "Type a prompt first."
return m, nil
}
if cmd, handled := m.handleComposerSlashCommand(prompt); handled {
return m, cmd
}
if m.isBusy() {
m.status = "Wait for the current AI request to finish before sending another prompt."
return m, nil
}
m.clearTranscriptSelection()
m.submittingPrompt = true
m.pendingPrompt = prompt
m.pinTranscriptToLatest()
m.status = "Saving prompt to the server..."
m.input = nil
m.cursor = 0
submitResults := m.submitResults
go func() {
submitResults <- submitPromptMsg(m.con, m.ctx.target, m.currentConversation, m.defaultProvider(), m.defaultModel(), m.defaultSystemPrompt(), prompt)
}()
awaitingCmd := m.startAwaitingResponse()
m.syncTranscriptViewport()
return m, tea.Batch(
awaitingCmd,
waitForAISubmitResultCmd(submitResults),
)
case tea.KeyBackspace:
if m.cursor > 0 {
m.input = append(m.input[:m.cursor-1], m.input[m.cursor:]...)
m.cursor--
}
case tea.KeyDelete:
if m.cursor < len(m.input) {
m.input = append(m.input[:m.cursor], m.input[m.cursor+1:]...)
}
case tea.KeyLeft:
if m.cursor > 0 {
m.cursor--
}
case tea.KeyRight:
if m.cursor < len(m.input) {
m.cursor++
}
case tea.KeyHome:
m.cursor = 0
case tea.KeyEnd:
m.cursor = len(m.input)
default:
if key.Text != "" {
if looksLikeTerminalResponseFragment(key.Text) {
return m, nil
}
m.insertComposerRunes([]rune(key.Text))
}
}
return m, nil
}
func (m *aiModel) handleComposerPaste(msg tea.PasteMsg) (tea.Model, tea.Cmd) {
if looksLikeTerminalResponseFragment(msg.Content) {
return m, nil
}
if msg.Content == "" {
return m, nil
}
m.insertComposerRunes([]rune(msg.Content))
return m, nil
}
func (m *aiModel) insertComposerRunes(insert []rune) {
if len(insert) == 0 {
return
}
m.input = append(m.input[:m.cursor], append(insert, m.input[m.cursor:]...)...)
m.cursor += len(insert)
}
func (m *aiModel) handleComposerSlashCommand(input string) (tea.Cmd, bool) {
command, _, ok := parseComposerSlashCommand(input)
if !ok {
return nil, false
}
switch command {
case "/exit":
m.input = nil
m.cursor = 0
return tea.Quit, true
default:
m.status = fmt.Sprintf("Unknown composer command %q. Available: /exit.", command)
return nil, true
}
}
func parseComposerSlashCommand(input string) (string, []string, bool) {
fields := strings.Fields(strings.TrimSpace(input))
if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") {
return "", nil, false
}
return strings.ToLower(fields[0]), fields[1:], true
}
func (m *aiModel) handleModalMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
return m, m.applyWindowSize(msg.Width, msg.Height)
case aiWindowPollMsg:
return m, tea.Batch(m.windowSizeCmd(msg.width, msg.height), aiWindowPollCmd())
case aiConversationEventMsg:
m.applyConversationEvent(msg.event)
m.syncTranscriptViewport()
return m, tea.Batch(
waitForAIConversationEventCmd(m.listener),
m.syncAwaitingResponse(),
m.scheduleTranscriptRender(),
)
case aiToastMsg:
return m, tea.Batch(
waitForAIConversationEventCmd(m.listener),
m.showToast(msg.level, msg.message),
)
case aiToastExpiredMsg:
m.expireToast(msg.id)
return m, nil
case aiListenerClosedMsg:
m.status = "AI event stream closed. Reopen the AI TUI to resume live updates."
return m, nil
case aiTargetOptionsLoadedMsg:
if m.modal == nil || m.modal.kind != aiModalKindTargetSelect {
return m, nil
}
m.targetSelectionOptions = append([]aiTargetSelectionOption(nil), msg.options...)
if len(m.targetSelectionOptions) > 0 {
m.modal.selectedOption = clampInt(msg.selectedOption, 0, len(m.targetSelectionOptions)-1)
} else {
m.modal.selectedOption = 0
}
m.modal.status = strings.TrimSpace(msg.status)
return m, nil
case tea.KeyPressMsg:
if msg.Key().Mod.Contains(tea.ModCtrl) && msg.Key().Code == 'c' {
return m, tea.Quit
}
switch m.modal.kind {
case aiModalKindDeleteConfirm:
return m.handleDeleteConfirmModalKey(msg.Key())
case aiModalKindContext:
return m.handleContextModalKey(msg.Key())
case aiModalKindNewConversation:
return m.handleNewConversationModalKey(msg.Key())
case aiModalKindThinkingLevel:
return m.handleThinkingLevelModalKey(msg.Key())
case aiModalKindTargetSelect:
return m.handleTargetSelectModalKey(msg.Key())
case aiModalKindExperimentalWarning:
return m.handleExperimentalWarningModalKey(msg.Key())
default:
return m.handleInfoModalKey()
}
default:
return m, nil
}
}
func (m *aiModel) handleInfoModalKey() (tea.Model, tea.Cmd) {
if time.Now().Before(m.modal.dismissReadyAt) {
return m, nil
}
return m, tea.Quit
}
func (m *aiModel) handleDeleteConfirmModalKey(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.modal = nil
return m, nil
case tea.KeyLeft:
m.modal.confirmDelete = false
return m, nil
case tea.KeyRight:
m.modal.confirmDelete = true
return m, nil
case tea.KeyTab:
m.modal.confirmDelete = !m.modal.confirmDelete
return m, nil
case tea.KeyEnter:
if !m.modal.confirmDelete {
m.modal = nil
return m, nil
}
return m.confirmDeleteConversation()
}
switch key.Text {
case "h":
m.modal.confirmDelete = false
return m, nil
case "l":
m.modal.confirmDelete = true
return m, nil
case "n", "q":
m.modal = nil
return m, nil
case "y":
m.modal.confirmDelete = true
return m.confirmDeleteConversation()
}
return m, nil
}
func (m *aiModel) handleContextModalKey(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc, tea.KeyEnter, tea.KeyTab:
m.modal = nil
return m, nil
}
switch key.Text {
case "q", "c":
m.modal = nil
return m, nil
}
return m, nil
}
func (m *aiModel) handleThinkingLevelModalKey(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.modal = nil
m.status = "Canceled thinking level update."
return m, nil
case tea.KeyUp:
m.moveThinkingLevelSelection(-1)
return m, nil
case tea.KeyDown:
m.moveThinkingLevelSelection(1)
return m, nil
case tea.KeyHome:
m.modal.selectedOption = 0
return m, nil
case tea.KeyEnd:
options := m.thinkingLevelModalOptions()
if len(options) > 0 {
m.modal.selectedOption = len(options) - 1
}
return m, nil
case tea.KeyEnter:
return m.confirmThinkingLevelUpdate()
}
switch key.Text {
case "q":
m.modal = nil
m.status = "Canceled thinking level update."
return m, nil
case "j":
m.moveThinkingLevelSelection(1)
return m, nil
case "k":
m.moveThinkingLevelSelection(-1)
return m, nil
}
return m, nil
}
func (m *aiModel) handleTargetSelectModalKey(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
m.modal = nil
m.targetSelectionOptions = nil
m.status = "Canceled active target update."
return m, nil
case tea.KeyUp:
m.moveTargetSelection(-1)
return m, nil
case tea.KeyDown:
m.moveTargetSelection(1)
return m, nil
case tea.KeyHome:
m.modal.selectedOption = 0
return m, nil
case tea.KeyEnd:
if len(m.targetSelectionOptions) > 0 {
m.modal.selectedOption = len(m.targetSelectionOptions) - 1
}
return m, nil
case tea.KeyEnter:
return m.confirmTargetSelectionUpdate()
}
switch key.Text {
case "q":
m.modal = nil
m.targetSelectionOptions = nil
m.status = "Canceled active target update."
return m, nil
case "j":
m.moveTargetSelection(1)
return m, nil
case "k":
m.moveTargetSelection(-1)
return m, nil
}
return m, nil
}
func (m *aiModel) handleExperimentalWarningModalKey(key tea.Key) (tea.Model, tea.Cmd) {
switch key.Code {
case tea.KeyEsc:
return m, tea.Quit
case tea.KeyLeft:
m.modal.focus = aiModalFocusCancel
return m, nil
case tea.KeyRight:
m.modal.focus = aiModalFocusConfirm
return m, nil
case tea.KeyTab:
if m.modal.focus == aiModalFocusConfirm {
m.modal.focus = aiModalFocusCancel
} else {
m.modal.focus = aiModalFocusConfirm
}
return m, nil
case tea.KeyEnter:
if m.modal.focus == aiModalFocusConfirm {
return m.acceptExperimentalWarning()
}
return m, tea.Quit
}
switch key.Text {
case "h":
m.modal.focus = aiModalFocusCancel
return m, nil
case "l":
m.modal.focus = aiModalFocusConfirm
return m, nil
case "q", "n":
return m, tea.Quit
case "y":
m.modal.focus = aiModalFocusConfirm
return m.acceptExperimentalWarning()
}
return m, nil
}
func (m *aiModel) handleNewConversationModalKey(key tea.Key) (tea.Model, tea.Cmd) {
if key.Mod.Contains(tea.ModCtrl) {
switch key.Code {
case 'u':
return m.clearNewConversationInput()
case 'r':
return m.resetNewConversationSystemPrompt()
}
}
switch key.Code {
case tea.KeyEsc:
return m.cancelNewConversationModal()
case tea.KeyTab:
switch m.modal.focus {
case aiModalFocusSystemPrompt:
m.modal.focus = aiModalFocusInput
case aiModalFocusInput:
m.modal.focus = aiModalFocusCancel
case aiModalFocusCancel:
m.modal.focus = aiModalFocusConfirm
default:
m.modal.focus = aiModalFocusSystemPrompt
}
return m, nil
case tea.KeyEnter:
if m.modal.focus == aiModalFocusCancel {
return m.cancelNewConversationModal()
}
return m.confirmCreateConversation()
case tea.KeyUp:
switch m.modal.focus {
case aiModalFocusInput:
m.modal.focus = aiModalFocusSystemPrompt
case aiModalFocusCancel, aiModalFocusConfirm:
m.modal.focus = aiModalFocusInput
}
return m, nil
case tea.KeyDown:
switch m.modal.focus {
case aiModalFocusSystemPrompt:
m.modal.focus = aiModalFocusInput
case aiModalFocusInput:
m.modal.focus = aiModalFocusCancel
}
return m, nil
case tea.KeyLeft:
if input, cursor, _ := m.activeNewConversationField(); input != nil {
if *cursor > 0 {
*cursor--
}
return m, nil
}
m.modal.focus = aiModalFocusCancel
return m, nil
case tea.KeyRight:
if input, cursor, _ := m.activeNewConversationField(); input != nil {
if *cursor < len(*input) {
*cursor++
}
return m, nil
}
m.modal.focus = aiModalFocusConfirm
return m, nil
case tea.KeyHome:
if _, cursor, _ := m.activeNewConversationField(); cursor != nil {
*cursor = 0
}
return m, nil
case tea.KeyEnd:
if input, cursor, _ := m.activeNewConversationField(); input != nil {
*cursor = len(*input)
}
return m, nil
case tea.KeyBackspace:
if input, cursor, _ := m.activeNewConversationField(); input != nil && *cursor > 0 {
*input = append((*input)[:*cursor-1], (*input)[*cursor:]...)
*cursor--
}
return m, nil
case tea.KeyDelete:
if input, cursor, _ := m.activeNewConversationField(); input != nil && *cursor < len(*input) {
*input = append((*input)[:*cursor], (*input)[*cursor+1:]...)
}
return m, nil
}
if input, cursor, _ := m.activeNewConversationField(); input != nil {
if key.Text != "" {
if looksLikeTerminalResponseFragment(key.Text) {
return m, nil
}
insert := []rune(key.Text)
*input = append((*input)[:*cursor], append(insert, (*input)[*cursor:]...)...)
*cursor += len(insert)
}
return m, nil
}
switch key.Text {
case "q":
return m.cancelNewConversationModal()
case "h":
m.modal.focus = aiModalFocusCancel
return m, nil
case "l":
m.modal.focus = aiModalFocusConfirm
return m, nil
}
return m, nil
}
func (m *aiModel) activeNewConversationField() (*[]rune, *int, string) {
if m.modal == nil {
return nil, nil, ""
}
switch m.modal.focus {
case aiModalFocusInput:
return &m.modal.input, &m.modal.cursor, "Conversation name"
case aiModalFocusSystemPrompt:
return &m.modal.systemPrompt, &m.modal.systemCursor, "System prompt"
default:
return nil, nil, ""
}
}
func (m *aiModel) newConversationFieldValue(focus aiModalFocusTarget) ([]rune, int, bool) {
if m.modal == nil {
return nil, 0, false
}
switch focus {
case aiModalFocusInput:
return m.modal.input, m.modal.cursor, true
case aiModalFocusSystemPrompt:
return m.modal.systemPrompt, m.modal.systemCursor, true
default:
return nil, 0, false
}
}
func (m *aiModel) clearNewConversationInput() (tea.Model, tea.Cmd) {
if m.modal == nil {
return m, nil
}
input, cursor, label := m.activeNewConversationField()
if input == nil {
input = &m.modal.input
cursor = &m.modal.cursor
label = "Conversation name"
m.modal.focus = aiModalFocusInput
}
*input = nil
*cursor = 0
m.status = label + " cleared."
return m, nil
}
func (m *aiModel) resetNewConversationSystemPrompt() (tea.Model, tea.Cmd) {
if m.modal == nil {
return m, nil
}
systemPrompt := []rune(m.serverDefaultSystemPrompt())
m.modal.systemPrompt = systemPrompt
m.modal.systemCursor = len(systemPrompt)
m.modal.focus = aiModalFocusSystemPrompt
m.status = "System prompt reset to the server default."
return m, nil
}
func (m *aiModel) cancelNewConversationModal() (tea.Model, tea.Cmd) {
m.modal = nil
m.status = "Canceled new conversation creation."
return m, nil
}
func (m *aiModel) moveThinkingLevelSelection(delta int) {
if m.modal == nil {
return
}
options := m.thinkingLevelModalOptions()
if len(options) == 0 {
m.modal.selectedOption = 0
return
}
m.modal.selectedOption = clampInt(m.modal.selectedOption+delta, 0, len(options)-1)
}
func (m *aiModel) moveTargetSelection(delta int) {
if m.modal == nil {
return
}
if len(m.targetSelectionOptions) == 0 {
m.modal.selectedOption = 0
return
}
m.modal.selectedOption = clampInt(m.modal.selectedOption+delta, 0, len(m.targetSelectionOptions)-1)
}
func (m *aiModel) confirmThinkingLevelUpdate() (tea.Model, tea.Cmd) {
if m.modal == nil || m.currentConversation == nil {
return m, nil
}
options := m.thinkingLevelModalOptions()
if len(options) == 0 {
m.modal = nil
m.status = "No thinking level options are available."
return m, nil
}
selectedOption := clampInt(m.modal.selectedOption, 0, len(options)-1)
thinkingLevel := options[selectedOption].value
if normalizeAIThinkingLevel(m.currentConversation.GetThinkingLevel()) == thinkingLevel {
m.modal = nil
m.status = "Thinking level unchanged."
return m, nil
}
m.modal = nil
m.loading = true
m.status = "Saving thinking level..."
return m, updateConversationThinkingLevelCmd(m.con, m.currentConversation, thinkingLevel)
}
func (m *aiModel) confirmTargetSelectionUpdate() (tea.Model, tea.Cmd) {
if m.modal == nil {
return m, nil
}
if len(m.targetSelectionOptions) == 0 {
status := strings.TrimSpace(m.modal.status)
if status == "" {
status = "No sessions or beacons are currently available."
}
m.status = status
return m, nil
}
selectedOption := clampInt(m.modal.selectedOption, 0, len(m.targetSelectionOptions)-1)
option := m.targetSelectionOptions[selectedOption]
sameActiveTarget := sameAITargetSelectionSummary(m.ctx.target, option.target)
sameConversationTarget := conversationUsesTarget(m.currentConversation, option.target)
m.applyTargetSelectionOption(option)
m.modal = nil
m.targetSelectionOptions = nil
if sameActiveTarget && (m.currentConversation == nil || sameConversationTarget) {
m.status = "Active target unchanged."
return m, nil
}
targetLabel := fallback(option.target.Label, option.label)
if m.currentConversation == nil || strings.TrimSpace(m.currentConversation.GetID()) == "" || sameConversationTarget {
m.status = "Active target set to " + targetLabel + "."
return m, nil
}
m.loading = true
m.status = "Saving active target..."
return m, updateConversationTargetCmd(m.con, m.currentConversation, option.target)
}
func (m *aiModel) confirmCreateConversation() (tea.Model, tea.Cmd) {
if m.modal == nil {
return m, nil
}
title := strings.TrimSpace(string(m.modal.input))
systemPrompt := strings.TrimSpace(string(m.modal.systemPrompt))
if title == "" {
m.modal.focus = aiModalFocusInput
m.status = "Type a conversation name first."
return m, nil
}
m.modal = nil
m.loading = true
m.status = "Creating a new AI conversation..."
return m, createConversationCmd(m.con, m.ctx.target, m.defaultProvider(), m.defaultModel(), title, systemPrompt)
}
func (m *aiModel) confirmDeleteConversation() (tea.Model, tea.Cmd) {
if m.modal == nil {
return m, nil
}
conversationID := strings.TrimSpace(m.modal.conversationID)
selectedID := strings.TrimSpace(m.modal.selectedID)
status := strings.TrimSpace(m.modal.status)
m.modal = nil
if conversationID == "" {
m.status = "No AI conversation selected."
return m, nil
}
if status == "" {
status = "Deleted AI conversation."
}
m.loading = true
m.status = "Deleting AI conversation..."
return m, deleteConversationCmd(m.con, conversationID, selectedID, status)
}
func (m *aiModel) View() tea.View {
if m.width < aiMinWidth || m.height < aiMinHeight {
content := m.renderTooSmall()
if len(m.toasts) > 0 {
content = m.renderToastOverlay(content)
}
if m.modal != nil {
content = m.renderModalOverlay(content)
}
view := aiView(content)
return view
}
_, composerHeight, _, bodyHeight := m.layoutHeights()
header := m.renderHeader()
body := m.renderBody(bodyHeight)
composer := m.renderComposer(composerHeight)
footer := m.renderFooter()
frame := lipgloss.JoinVertical(lipgloss.Left, header, body, composer, footer)
frame = lipgloss.Place(m.width, m.height, lipgloss.Left, lipgloss.Top, frame)
if len(m.toasts) > 0 {
frame = m.renderToastOverlay(frame)
}
if m.modal != nil {
frame = m.renderModalOverlay(frame)
}
frame = clampANSIBlock(frame, m.width, m.height)
view := aiView(frame)
return view
}
func (m *aiModel) renderModal() string {
switch m.modal.kind {
case aiModalKindDeleteConfirm:
return m.renderDeleteConfirmModal()
case aiModalKindContext:
return m.renderContextModal()
case aiModalKindNewConversation:
return m.renderNewConversationModal()
case aiModalKindThinkingLevel:
return m.renderThinkingLevelModal()
case aiModalKindTargetSelect:
return m.renderTargetSelectModal()
case aiModalKindExperimentalWarning:
return m.renderExperimentalWarningModal()
default:
return m.renderInfoModal()
}
}
func (m *aiModel) renderInfoModal() string {
boxWidth := minInt(maxInt(20, m.width-4), 84)
bodyWidth := maxInt(20, boxWidth-6)
bodyLines := wrapText(m.modal.body, bodyWidth)
lines := []string{
m.styles.warning.Width(bodyWidth).Render(m.modal.title),
"",
}
for _, line := range bodyLines {
lines = append(lines, lipgloss.NewStyle().Width(bodyWidth).Render(line))
}
lines = append(lines,
"",
m.styles.subtleText.Width(bodyWidth).Render("Press any key to return to the REPL."),
)
box := lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(clienttheme.Warning()).
Padding(1, 2).
Render(strings.Join(lines, "\n"))
return box
}
func (m *aiModel) renderDeleteConfirmModal() string {
boxWidth := minInt(maxInt(20, m.width-4), 84)
bodyWidth := maxInt(20, boxWidth-6)
bodyLines := wrapText(m.modal.body, bodyWidth)
lines := []string{
m.styles.danger.Width(bodyWidth).Render(m.modal.title),
"",
}
for _, line := range bodyLines {
lines = append(lines, lipgloss.NewStyle().Width(bodyWidth).Render(line))
}
lines = append(lines, "", m.renderDeleteConfirmActions(bodyWidth))
box := lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(clienttheme.Danger()).
Padding(1, 2).
Render(strings.Join(lines, "\n"))
return box
}
func (m *aiModel) renderContextModal() string {
boxWidth := minInt(maxInt(44, m.width-6), 96)
bodyWidth := maxInt(24, boxWidth-6)
lines := []string{
lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.Primary()).
Width(bodyWidth).
Render(m.modal.title),
m.styles.subtleText.Width(bodyWidth).Render("Active target, connection, provider defaults, and thread metadata."),
"",
}
sectionBlocks := m.renderContextModalSections(bodyWidth)
for i, block := range sectionBlocks {
lines = append(lines, strings.Split(block, "\n")...)
if i < len(sectionBlocks)-1 {
lines = append(lines, "")
}
}
dismissHint := m.styles.chip.Width(bodyWidth).Render("esc / enter / q closes")
maxLines := maxInt(6, m.height-6)
if len(lines)+2 > maxLines {
lines = headLines(lines, maxInt(1, maxLines-3))
lines = append(lines, m.styles.subtleText.Width(bodyWidth).Render("Resize the terminal to view more context."))
}
lines = append(lines, "", dismissHint)
box := lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(clienttheme.Primary()).
Padding(1, 2).
Render(strings.Join(lines, "\n"))
return box
}
func (m *aiModel) renderNewConversationModal() string {
boxWidth := minInt(maxInt(36, m.width-6), 84)
bodyWidth := maxInt(24, boxWidth-6)
lines := []string{
lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.Primary()).
Width(bodyWidth).
Render(m.modal.title),
m.styles.subtleText.Width(bodyWidth).Render("Optionally configure the system prompt"),
"",
m.renderNewConversationSystemPromptInput(bodyWidth),
"",
m.renderNewConversationInput(bodyWidth),
"",
m.renderNewConversationActions(bodyWidth),
"",
m.styles.chip.Width(bodyWidth).Render("tab: focus up/down: fields ctrl+u: clear ctrl+r: server default enter: create esc: cancel"),
}
box := lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(clienttheme.Primary()).
Padding(1, 2).
Render(strings.Join(lines, "\n"))
return box
}
func (m *aiModel) renderThinkingLevelModal() string {
boxWidth := minInt(maxInt(52, m.width-6), 96)
bodyWidth := maxInt(28, boxWidth-6)
lines := []string{
lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.Primary()).
Width(bodyWidth).
Render(m.modal.title),
m.styles.subtleText.Width(bodyWidth).Render("Choose the reasoning effort hint for future turns in this conversation."),
m.styles.subtleText.Width(bodyWidth).Render("Drivers may map or ignore this setting depending on backend support."),
"",
}
for _, optionLine := range m.renderThinkingLevelModalOptions(bodyWidth) {
lines = append(lines, optionLine)
}
lines = append(lines,
"",
m.styles.chip.Width(bodyWidth).Render("j/k or up/down: select enter: apply esc/q: cancel"),
)
box := lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(clienttheme.Primary()).
Padding(1, 2).
Render(strings.Join(lines, "\n"))
return box
}
func (m *aiModel) renderTargetSelectModal() string {
boxWidth := minInt(maxInt(60, m.width-6), 112)
bodyWidth := maxInt(32, boxWidth-6)
description := "Select the session or beacon to make active in this AI view."
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) != "" {
description = "Select the session or beacon used as the active target and current thread target."
}
lines := []string{
lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.Primary()).
Width(bodyWidth).
Render(m.modal.title),
m.styles.subtleText.Width(bodyWidth).Render(description),
"",
}
if len(m.targetSelectionOptions) == 0 {
status := strings.TrimSpace(m.modal.status)
if status == "" {
status = "Loading sessions and beacons from the server..."
}
lines = append(lines, m.styles.subtleText.Width(bodyWidth).Render(status))
} else {
maxOptionLines := maxInt(6, m.height-16)
for _, optionLine := range m.renderTargetSelectModalOptions(bodyWidth, maxOptionLines) {
lines = append(lines, optionLine)
}
}
lines = append(lines,
"",
m.styles.chip.Width(bodyWidth).Render("j/k or up/down: select enter: apply esc/q: cancel"),
)
box := lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(clienttheme.Primary()).
Padding(1, 2).
Render(strings.Join(lines, "\n"))
return box
}
func (m *aiModel) renderExperimentalWarningModal() string {
boxWidth := minInt(maxInt(44, m.width-6), 100)
bodyWidth := maxInt(24, boxWidth-6)
bodyLines := wrapText(m.modal.body, bodyWidth)
lines := []string{
m.styles.danger.Width(bodyWidth).Render(m.modal.title),
"",
}
for _, line := range bodyLines {
lines = append(lines, lipgloss.NewStyle().
Foreground(clienttheme.Danger()).
Width(bodyWidth).
Render(line))
}
lines = append(lines,
"",
m.renderExperimentalWarningActions(bodyWidth),
"",
m.styles.subtleText.Width(bodyWidth).Render("tab/h/l: focus enter: select esc/q: cancel"),
)
box := lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
BorderForeground(clienttheme.Danger()).
Padding(1, 2).
Render(strings.Join(lines, "\n"))
return box
}
func (m *aiModel) renderThinkingLevelModalOptions(width int) []string {
options := m.thinkingLevelModalOptions()
lines := make([]string, 0, len(options)*2)
for idx, option := range options {
prefix := " "
style := m.styles.item
if m.modal != nil && idx == m.modal.selectedOption {
prefix = "> "
style = m.styles.optionFocused
}
lines = append(lines, style.Width(width).Render(truncateText(prefix+option.label, width)))
if option.description != "" {
for _, line := range wrapText(option.description, maxInt(1, width-3)) {
lines = append(lines, m.styles.subtleText.Width(width).Render(" "+line))
}
}
}
return lines
}
func (m *aiModel) renderTargetSelectModalOptions(width int, maxLines int) []string {
if len(m.targetSelectionOptions) == 0 || maxLines <= 0 {
return nil
}
blocks := make([][]string, 0, len(m.targetSelectionOptions))
for idx, option := range m.targetSelectionOptions {
blocks = append(blocks, m.renderTargetSelectModalOption(width, idx, option))
}
start, end := targetSelectionVisibleRange(blocks, maxLines, m.modal.selectedOption)
lines := make([]string, 0, maxLines)
for idx := start; idx < end; idx++ {
lines = append(lines, blocks[idx]...)
}
return lines
}
func (m *aiModel) renderTargetSelectModalOption(width int, idx int, option aiTargetSelectionOption) []string {
prefix := " "
style := m.styles.item
if m.modal != nil && idx == m.modal.selectedOption {
prefix = "> "
style = m.styles.optionFocused
}
label := option.label
if option.active {
label += " [active]"
}
lines := []string{
style.Width(width).Render(truncateText(prefix+label, width)),
}
for _, metadata := range option.metadata {
if strings.TrimSpace(metadata) == "" {
continue
}
for _, line := range wrapText(metadata, maxInt(1, width-3)) {
lines = append(lines, m.styles.subtleText.Width(width).Render(" "+line))
}
}
return lines
}
func (m *aiModel) renderContextModalSections(width int) []string {
sections := m.contextModalSections()
blocks := make([]string, 0, len(sections))
for _, section := range sections {
blocks = append(blocks, m.renderContextModalSection(width, section))
}
return blocks
}
func (m *aiModel) contextModalSections() []aiContextSection {
targetFields := []aiContextField{
{label: "Target", value: fallback(m.ctx.target.Label, "No active target")},
{label: "Host", value: fallback(m.ctx.target.Host, "<unknown host>")},
{label: "Platform", value: fallback(m.ctx.target.OS, "unknown") + "/" + fallback(m.ctx.target.Arch, "unknown")},
{label: "Mode", value: fallback(m.ctx.target.Mode, "<unknown mode>"), muted: true},
{label: "C2", value: fallback(m.ctx.target.C2, "unknown"), muted: true},
}
for _, detail := range m.ctx.target.Details {
if strings.TrimSpace(detail) == "" {
continue
}
targetFields = append(targetFields, aiContextField{label: "Detail", value: detail, muted: true})
}
connectionFields := []aiContextField{
{label: "Profile", value: fallback(m.ctx.connection.Profile, "<profile unavailable>")},
{label: "Server", value: fallback(m.ctx.connection.Server, "<unknown>")},
{label: "Operator", value: fallback(m.ctx.connection.Operator, "<unknown>"), muted: true},
{label: "State", value: fallback(m.ctx.connection.State, "<unknown>"), muted: true},
}
providerFields := []aiContextField{}
if len(m.providers) == 0 {
providerFields = append(providerFields, aiContextField{
label: "Available",
value: "No AI providers reported by the server.",
muted: true,
})
} else {
for _, provider := range m.providers {
if provider == nil {
continue
}
status := "not configured"
if provider.GetConfigured() {
status = "configured"
}
providerFields = append(providerFields, aiContextField{
label: fallback(provider.GetName(), "<unnamed>"),
value: status,
muted: true,
})
}
}
defaultFields := []aiContextField{}
if m.config == nil {
defaultFields = append(defaultFields, aiContextField{
label: "Status",
value: "AI defaults unavailable.",
muted: true,
})
} else {
defaultFields = append(defaultFields,
aiContextField{label: "Provider", value: fallback(m.config.GetProvider(), "<unset>")},
aiContextField{label: "Model", value: fallback(m.config.GetModel(), "provider default")},
aiContextField{label: "Thinking", value: defaultThinkingLevelSummary(m.config), muted: true},
)
}
threadFields := []aiContextField{}
if m.currentConversation == nil {
threadFields = append(threadFields, aiContextField{
label: "Status",
value: "No conversation selected yet.",
muted: true,
})
} else {
threadFields = append(threadFields,
aiContextField{label: "Title", value: conversationTitle(m.currentConversation)},
aiContextField{label: "ID", value: shortenID(m.currentConversation.GetID()), muted: true},
aiContextField{label: "Provider", value: fallback(m.currentConversation.GetProvider(), "<unset>")},
aiContextField{label: "Model", value: fallback(m.currentConversation.GetModel(), "<default>"), muted: true},
aiContextField{label: "Thinking", value: conversationThinkingLevelSummary(m.currentConversation, m.config), muted: true},
aiContextField{label: "Messages", value: fmt.Sprintf("%d", conversationMessageCount(m.currentConversation))},
aiContextField{label: "Context", value: conversationContextWindowSummary(m.currentConversation), muted: true},
aiContextField{label: "Updated", value: formatUnix(m.currentConversation.GetUpdatedAt()), muted: true},
)
}
return []aiContextSection{
{title: "Target", fields: targetFields},
{title: "Connection", fields: connectionFields},
{title: "Thread", fields: threadFields},
{title: "Defaults", fields: defaultFields},
{title: "Providers", fields: providerFields},
}
}
func (m *aiModel) renderContextModalSection(width int, section aiContextSection) string {
title := lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.PrimaryMod(200)).
Background(clienttheme.PrimaryMod(900)).
Padding(0, 1).
Render(section.title)
lines := []string{title}
for _, field := range section.fields {
lines = append(lines, m.renderContextModalFieldLines(width, field)...)
}
return lipgloss.NewStyle().Width(width).Render(strings.Join(lines, "\n"))
}
func (m *aiModel) renderContextModalFieldLines(width int, field aiContextField) []string {
labelWidth := clampInt(width/6, 8, 12)
valueWidth := maxInt(1, width-(labelWidth+4))
value := strings.TrimSpace(field.value)
if value == "" {
value = "<unset>"
}
labelStyle := lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(700))
valueStyle := lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(900))
if field.muted {
valueStyle = valueStyle.Foreground(clienttheme.DefaultMod(600))
}
label := truncateText(field.label, labelWidth)
wrapped := wrapText(value, valueWidth)
lines := make([]string, 0, len(wrapped))
for i, line := range wrapped {
labelText := strings.Repeat(" ", labelWidth)
if i == 0 {
labelText = labelStyle.Width(labelWidth).Render(label)
}
lines = append(lines, lipgloss.NewStyle().Width(width).Render(
lipgloss.NewStyle().Foreground(clienttheme.PrimaryMod(500)).Render("|")+
" "+
labelText+
" "+
valueStyle.Render(line),
))
}
return lines
}
func (m *aiModel) renderModalOverlay(base string) string {
box := m.renderModal()
boxWidth := lipgloss.Width(box)
boxHeight := lipgloss.Height(box)
left := maxInt(0, (m.width-boxWidth)/2)
top := maxInt(0, (m.height-boxHeight)/2)
return overlayContent(base, box, left, top, m.width)
}
func (m *aiModel) renderToastOverlay(base string) string {
box := m.renderToastStack()
if box == "" {
return base
}
boxWidth := lipgloss.Width(box)
left := maxInt(0, m.width-boxWidth-2)
return overlayContent(base, box, left, 1, m.width)
}
func (m *aiModel) renderToastStack() string {
if len(m.toasts) == 0 {
return ""
}
blocks := make([]string, 0, minInt(len(m.toasts), aiToastStackLimit))
for _, toast := range m.toasts {
if block := m.renderToast(toast); block != "" {
blocks = append(blocks, block)
}
if len(blocks) == aiToastStackLimit {
break
}
}
if len(blocks) == 0 {
return ""
}
return strings.Join(blocks, "\n")
}
func (m *aiModel) renderToast(toast *aiToastState) string {
if toast == nil || strings.TrimSpace(toast.message) == "" {
return ""
}
boxWidth := minInt(maxInt(28, m.width/3), 72)
bodyWidth := maxInt(20, boxWidth-6)
backgroundColor := clienttheme.DefaultMod(25)
title := "Event"
accentColor := clienttheme.Primary()
switch strings.ToLower(strings.TrimSpace(toast.level)) {
case "error", "warn", "warning":
title = "Warning"
accentColor = clienttheme.Danger()
case "success":
title = "Notice"
accentColor = clienttheme.Success()
}
lineStyle := lipgloss.NewStyle().
Width(bodyWidth).
Background(backgroundColor).
Foreground(clienttheme.DefaultMod(900))
titleStyle := lineStyle.Copy().
Bold(true).
Foreground(accentColor)
timeStyle := lineStyle.Copy().
Foreground(clienttheme.DefaultMod(600)).
Align(lipgloss.Right)
now := time.Now()
remaining := toast.remaining(now)
percentLeft := toast.fractionRemaining(now)
toast.bar.SetWidth(bodyWidth)
barLine := lineStyle.Render(toast.bar.ViewAs(percentLeft))
lines := []string{titleStyle.Render(truncateText(title, bodyWidth))}
for _, line := range wrapText(strings.TrimSpace(toast.message), bodyWidth) {
lines = append(lines, lineStyle.Render(line))
}
lines = append(lines,
barLine,
timeStyle.Render(formatAIToastTimeLeft(remaining)),
)
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(accentColor).
Background(backgroundColor).
Padding(0, 1).
Render(strings.Join(lines, "\n"))
}
func (m *aiModel) renderTooSmall() string {
lines := []string{
m.styles.badge.Render("SLIVER AI"),
"",
m.styles.warning.Render("Terminal too small for the AI conversation view."),
m.styles.subtleText.Render("Resize to at least 72x17 to view the sidebar, markdown transcript, and composer."),
}
return strings.Join(lines, "\n")
}
func (m *aiModel) renderHeader() string {
statusChip := "synced"
if m.loading {
statusChip = "syncing"
} else if m.awaitingResponse {
statusChip = strings.ToLower(m.pendingLabel())
}
pieces := []string{
m.styles.badge.Render("SLIVER AI"),
m.styles.chip.Render(statusChip),
m.styles.chipMuted.Render(m.ctx.connection.Operator),
m.styles.chipMuted.Render(m.ctx.target.Label),
}
return lipgloss.NewStyle().Width(m.width).Render(fitStyledPieces(m.width, pieces))
}
func (m *aiModel) renderBody(height int) string {
switch {
case m.width >= 78:
sidebarWidth := clampInt(m.width/4, 24, 28)
transcriptWidth := maxInt(40, m.width-sidebarWidth)
return lipgloss.JoinHorizontal(
lipgloss.Top,
m.renderSidebar(sidebarWidth, height),
m.renderTranscript(transcriptWidth, height),
)
default:
sidebarHeight := clampInt(height/4, 5, 7)
transcriptHeight := maxInt(6, height-sidebarHeight)
return lipgloss.JoinVertical(
lipgloss.Left,
m.renderSidebar(m.width, sidebarHeight),
m.renderTranscript(m.width, transcriptHeight),
)
}
}
func (m *aiModel) renderSidebar(width, height int) string {
innerWidth := innerPaneWidth(width)
innerHeight := innerPaneHeight(height)
lines := []string{
m.styles.paneTitle.Render("Conversations"),
}
if len(m.conversations) == 0 {
lines = append(lines,
"",
m.styles.subtleText.Width(innerWidth).Render(truncateText("No stored AI conversations yet.", innerWidth)),
m.styles.subtleText.Width(innerWidth).Render(truncateText("Create one or send a prompt below.", innerWidth)),
)
return m.renderPane(width, height, aiFocusSidebar, headLines(lines, innerHeight))
}
bodyHeight := maxInt(1, innerHeight-1)
for i, conversation := range m.conversations {
line := conversationListLabel(conversation, maxInt(1, innerWidth-2))
if i == m.selectedConversation {
line = "> " + line
style := m.styles.itemSelected
if m.focus == aiFocusSidebar {
style = m.styles.optionFocused
}
lines = append(lines, style.Width(innerWidth).Render(line))
} else {
lines = append(lines, m.styles.item.Width(innerWidth).Render(" "+line))
}
if len(lines)-1 >= bodyHeight {
break
}
}
return m.renderPane(width, height, aiFocusSidebar, lines)
}
func (m *aiModel) renderTranscript(width, height int) string {
innerWidth := innerPaneWidth(width)
innerHeight := innerPaneHeight(height)
contentWidth := m.transcriptContentWidth(innerWidth)
content := m.renderTranscriptDisplayContent(contentWidth)
contentLines := transcriptContentStyledLines(content)
bodyHeight := maxInt(1, innerHeight-m.transcriptHeaderLineCount())
headerLines := m.renderTranscriptHeaderLines(innerWidth, bodyHeight, contentLines)
lines := append([]string(nil), headerLines...)
lines = append(lines, m.visibleTranscriptLines(content, bodyHeight)...)
pane := m.renderPane(width, height, aiFocusTranscript, lines)
scrollbar := m.renderTranscriptScrollbar(bodyHeight, len(contentLines))
if scrollbar != "" {
pane = overlayContent(
pane,
scrollbar,
maxInt(0, width-4),
1+m.transcriptHeaderLineCount(),
width,
)
}
return overlayContent(
pane,
m.renderTranscriptRightEdge(height),
maxInt(0, width-1),
0,
width,
)
}
func (m *aiModel) renderComposer(height int) string {
innerWidth := innerPaneWidth(m.width)
innerHeight := innerPaneHeight(height)
lines := []string{
lipgloss.NewStyle().Width(innerWidth).Render(fitStyledPieces(innerWidth, []string{
m.styles.paneTitle.Render("Composer"),
m.styles.chipMuted.Render("ctrl+o context"),
m.styles.chipMuted.Render("ctrl+s target"),
m.styles.chipMuted.Render("ctrl+t thinking"),
})),
m.renderInputLine(innerWidth),
}
return m.renderPane(m.width, height, aiFocusComposer, headLines(lines, innerHeight))
}
func (m *aiModel) renderTranscriptContentLines(width int) []string {
return transcriptContentStyledLines(m.renderTranscriptContent(width))
}
func (m *aiModel) renderTranscriptDisplayContentLines(width int) []string {
return transcriptContentStyledLines(m.renderTranscriptDisplayContent(width))
}
func (m *aiModel) renderPendingPromptLines(width int) []string {
return transcriptContentStyledLines(m.renderPendingPromptContent(width))
}
func (m *aiModel) renderPendingPromptContent(width int) []aiTranscriptContentLine {
prompt := strings.TrimSpace(m.pendingPrompt)
if prompt == "" {
return nil
}
label := messageBlockLabel(m.currentConversation, &clientpb.AIConversationMessage{
OperatorName: m.ctx.connection.Operator,
Role: "user",
})
return renderTranscriptBoxBlockContent(width, label, "user", []string{"pending"}, wrapText(prompt, maxInt(1, width-4)), true)
}
func (m *aiModel) renderAwaitingResponseLines(width int) []string {
return transcriptContentStyledLines(m.renderAwaitingResponseContent(width))
}
func (m *aiModel) renderAwaitingResponseContent(width int) []aiTranscriptContentLine {
if !m.awaitingResponse || m.thinkingAnim == nil {
return nil
}
return renderTranscriptBoxBlockContent(width, "AI", "assistant", []string{strings.ToLower(m.pendingLabel())}, []string{m.thinkingAnim.Render()}, false)
}
func (m *aiModel) renderTranscriptContent(width int) []aiTranscriptContentLine {
content := renderConversationTranscript(width, m.currentConversation)
for _, block := range [][]aiTranscriptContentLine{
m.renderPendingPromptContent(width),
m.renderAwaitingResponseContent(width),
} {
content = appendTranscriptContentBlock(content, block)
}
return content
}
func (m *aiModel) renderTranscriptDisplayContent(width int) []aiTranscriptContentLine {
content := append([]aiTranscriptContentLine(nil), m.transcriptDisplayContent(width)...)
for _, block := range [][]aiTranscriptContentLine{
m.renderPendingPromptContent(width),
m.renderAwaitingResponseContent(width),
} {
content = appendTranscriptContentBlock(content, block)
}
return content
}
func (m *aiModel) renderFooter() string {
pieces := []string{m.styles.chipMuted.Render("focus: " + m.focus.String())}
for _, hint := range m.footerHints() {
pieces = append(pieces, m.styles.footerHint.Render(hint))
}
return lipgloss.NewStyle().Width(m.width).Render(fitStyledPieces(m.width, pieces))
}
func (m *aiModel) footerHints() []string {
switch m.focus {
case aiFocusSidebar:
hints := []string{"tab: next", "j/k: move", "enter: open"}
if m.deleteTargetConversation() != nil {
hints = append(hints, "x: delete")
}
hints = append(hints, "n: new", "t: thinking", "r: refresh", "q/esc: quit")
return hints
case aiFocusTranscript:
hints := []string{"tab: next", "j/k: scroll", "pgup/pgdn: page", "g/G: ends", "mouse: wheel/select text"}
if m.deleteTargetConversation() != nil {
hints = append(hints, "x: delete")
}
hints = append(hints, "n: new", "t: thinking", "r: refresh", "q/esc: quit")
return hints
case aiFocusComposer:
return []string{"tab: sidebar", "enter: send", "/exit: quit", "ctrl+u: clear", "ctrl+c: quit"}
default:
return []string{"tab: next", "q/esc: quit"}
}
}
func (m *aiModel) renderDeleteConfirmActions(width int) string {
cancelStyle := lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(700)).
Background(clienttheme.DefaultMod(50)).
Padding(0, 1)
deleteStyle := lipgloss.NewStyle().
Foreground(clienttheme.Danger()).
Padding(0, 1)
if !m.modal.confirmDelete {
cancelStyle = cancelStyle.
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.DefaultMod(200))
} else {
deleteStyle = deleteStyle.
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.Danger())
}
actions := lipgloss.JoinHorizontal(
lipgloss.Top,
cancelStyle.Render("Cancel"),
" ",
deleteStyle.Render("Delete"),
)
return lipgloss.Place(width, 1, lipgloss.Center, lipgloss.Center, actions)
}
func (m *aiModel) renderNewConversationInput(width int) string {
return m.renderNewConversationField(width, "Conversation Name", "New conversation", aiModalFocusInput)
}
func (m *aiModel) renderNewConversationSystemPromptInput(width int) string {
return m.renderNewConversationField(width, "System Prompt", "Optional instructions for the model", aiModalFocusSystemPrompt)
}
func (m *aiModel) renderNewConversationField(width int, labelText string, placeholder string, focus aiModalFocusTarget) string {
contentWidth := maxInt(1, width-4)
label := lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.Primary()).
Render(labelText)
borderColor := clienttheme.PrimaryMod(400)
if m.modal.focus == focus {
borderColor = clienttheme.Primary()
}
field := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(borderColor).
Padding(0, 1).
Render(clampANSIBlock(m.renderNewConversationFieldContent(contentWidth, placeholder, focus), contentWidth, 1))
return lipgloss.JoinVertical(
lipgloss.Left,
lipgloss.NewStyle().Width(width).Render(label),
field,
)
}
func (m *aiModel) renderNewConversationFieldContent(width int, placeholder string, focus aiModalFocusTarget) string {
input, cursor, ok := m.newConversationFieldValue(focus)
if !ok {
return ""
}
focused := m.modal.focus == focus
if len(input) == 0 {
placeholder = truncateText(placeholder, maxInt(1, width))
if focused {
if width == 1 {
return m.styles.cursor.Render(" ")
}
return m.styles.cursor.Render(" ") + m.styles.inputPlaceholder.Render(truncateText(placeholder, width-1))
}
return m.styles.inputPlaceholder.Render(placeholder)
}
visible, cursor := inputWindow(input, cursor, width)
var b strings.Builder
for i, r := range visible {
ch := string(r)
if i == cursor && focused {
b.WriteString(m.styles.cursor.Render(ch))
continue
}
b.WriteString(m.styles.inputText.Render(ch))
}
if cursor == len(visible) && focused && lipgloss.Width(b.String()) < width {
b.WriteString(m.styles.cursor.Render(" "))
}
return b.String()
}
func (m *aiModel) renderNewConversationActions(width int) string {
cancelStyle := lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(700)).
Background(clienttheme.DefaultMod(50)).
Padding(0, 1)
createStyle := lipgloss.NewStyle().
Foreground(clienttheme.PrimaryMod(700)).
Background(clienttheme.PrimaryMod(50)).
Padding(0, 1)
switch m.modal.focus {
case aiModalFocusCancel:
cancelStyle = cancelStyle.
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.DefaultMod(200))
case aiModalFocusConfirm:
createStyle = createStyle.
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.Primary())
}
actions := lipgloss.JoinHorizontal(
lipgloss.Top,
cancelStyle.Render("Cancel"),
" ",
createStyle.Render("Create"),
)
return lipgloss.Place(width, 1, lipgloss.Center, lipgloss.Center, actions)
}
func (m *aiModel) renderExperimentalWarningActions(width int) string {
cancelStyle := lipgloss.NewStyle().
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.DefaultMod(50)).
Padding(0, 1)
confirmStyle := lipgloss.NewStyle().
Foreground(clienttheme.Danger()).
Background(clienttheme.DangerMod(50)).
Padding(0, 1)
switch m.modal.focus {
case aiModalFocusConfirm:
confirmStyle = confirmStyle.
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(clienttheme.Danger())
default:
cancelStyle = cancelStyle.
Bold(true).
Background(clienttheme.DefaultMod(200))
}
actions := lipgloss.JoinHorizontal(
lipgloss.Top,
cancelStyle.Render(aiExperimentalWarningCancelLabel),
" ",
confirmStyle.Render(aiExperimentalWarningConfirmLabel),
)
return lipgloss.Place(width, 1, lipgloss.Center, lipgloss.Center, actions)
}
func (m *aiModel) renderPane(width, height int, focus aiFocus, lines []string) string {
innerWidth := innerPaneWidth(width)
innerHeight := innerPaneHeight(height)
body := clampANSIBlock(strings.Join(lines, "\n"), innerWidth, innerHeight)
style := m.styles.pane
if m.focus == focus {
style = m.styles.paneFocused
}
return style.Width(width).MaxWidth(width).Height(height).MaxHeight(height).Render(body)
}
func (m *aiModel) startAIStartup() tea.Cmd {
cmds := []tea.Cmd{loadAIStateCmd(m.con, m.ctx.target, "")}
if cmd := m.scheduleTranscriptRender(); cmd != nil {
cmds = append(cmds, cmd)
}
if m.listener != nil {
cmds = append(cmds, waitForAIConversationEventCmd(m.listener))
}
return tea.Batch(cmds...)
}
func (m *aiModel) renderInputLine(width int) string {
prefix := m.styles.roleUser.Render(">>>")
available := maxInt(1, width-4)
content := m.renderInputContent(available)
return prefix + " " + content
}
func (m *aiModel) renderInputContent(width int) string {
if len(m.input) == 0 {
placeholder := truncateText("Ask Sliver AI about the active target...", maxInt(1, width))
if m.focus == aiFocusComposer {
if width == 1 {
return m.styles.cursor.Render(" ")
}
return m.styles.cursor.Render(" ") + m.styles.inputPlaceholder.Render(truncateText(placeholder, width-1))
}
return m.styles.inputPlaceholder.Render(placeholder)
}
visible, cursor := inputWindow(m.input, m.cursor, width)
var b strings.Builder
for i, r := range visible {
ch := string(r)
if i == cursor && m.focus == aiFocusComposer {
b.WriteString(m.styles.cursor.Render(ch))
continue
}
b.WriteString(m.styles.inputText.Render(ch))
}
if cursor == len(visible) && m.focus == aiFocusComposer && lipgloss.Width(b.String()) < width {
b.WriteString(m.styles.cursor.Render(" "))
}
return b.String()
}
func (m *aiModel) layoutHeights() (int, int, int, int) {
headerHeight := 1
composerHeight := 4
footerHeight := 1
bodyHeight := maxInt(1, m.height-headerHeight-composerHeight-footerHeight)
return headerHeight, composerHeight, footerHeight, bodyHeight
}
func (m *aiModel) currentPaneRects() aiPaneRects {
if m.width < aiMinWidth || m.height < aiMinHeight {
return aiPaneRects{}
}
headerHeight, composerHeight, _, bodyHeight := m.layoutHeights()
bodyY := headerHeight
composerY := bodyY + bodyHeight
switch {
case m.width >= 78:
sidebarWidth := clampInt(m.width/4, 24, 28)
return aiPaneRects{
sidebar: aiPaneRect{
x: 0,
y: bodyY,
width: sidebarWidth,
height: bodyHeight,
},
transcript: aiPaneRect{
x: sidebarWidth,
y: bodyY,
width: maxInt(40, m.width-sidebarWidth),
height: bodyHeight,
},
composer: aiPaneRect{
x: 0,
y: composerY,
width: m.width,
height: composerHeight,
},
}
default:
sidebarHeight := clampInt(bodyHeight/4, 5, 7)
return aiPaneRects{
sidebar: aiPaneRect{
x: 0,
y: bodyY,
width: m.width,
height: sidebarHeight,
},
transcript: aiPaneRect{
x: 0,
y: bodyY + sidebarHeight,
width: m.width,
height: maxInt(6, bodyHeight-sidebarHeight),
},
composer: aiPaneRect{
x: 0,
y: composerY,
width: m.width,
height: composerHeight,
},
}
}
}
func (m *aiModel) paneFocusAt(x, y int) (aiFocus, bool) {
rects := m.currentPaneRects()
switch {
case rects.sidebar.contains(x, y):
return aiFocusSidebar, true
case rects.transcript.contains(x, y):
return aiFocusTranscript, true
case rects.composer.contains(x, y):
return aiFocusComposer, true
default:
return aiFocusSidebar, false
}
}
func (m *aiModel) sidebarConversationIndexAt(x, y int) (int, bool) {
rect := m.currentPaneRects().sidebar
if !rect.contains(x, y) || len(m.conversations) == 0 {
return 0, false
}
innerY := y - rect.y - 1
if innerY <= 0 {
return 0, false
}
maxVisible := minInt(len(m.conversations), maxInt(1, innerPaneHeight(rect.height)-1))
idx := innerY - 1
if idx < 0 || idx >= maxVisible {
return 0, false
}
return idx, true
}
func (m *aiModel) currentTranscriptPaneSize() (int, int) {
_, _, _, bodyHeight := m.layoutHeights()
switch {
case m.width >= 78:
sidebarWidth := clampInt(m.width/4, 24, 28)
return maxInt(40, m.width-sidebarWidth), bodyHeight
default:
sidebarHeight := clampInt(bodyHeight/4, 5, 7)
return m.width, maxInt(6, bodyHeight-sidebarHeight)
}
}
func (m *aiModel) currentTranscriptViewportSize() (int, int) {
paneWidth, paneHeight := m.currentTranscriptPaneSize()
innerWidth := innerPaneWidth(paneWidth)
innerHeight := innerPaneHeight(paneHeight)
return m.transcriptContentWidth(innerWidth), maxInt(1, innerHeight-m.transcriptHeaderLineCount())
}
func (m *aiModel) transcriptHeaderLineCount() int {
return 2
}
func (m *aiModel) renderTranscriptHeaderLines(width, viewportHeight int, contentLines []string) []string {
title := "No conversation selected"
line1Label := m.styles.paneTitle.Render("Conversation")
if m.currentConversation != nil {
title = conversationTitle(m.currentConversation)
}
usageWidth := clampInt(width/3, 12, 34)
usage := m.renderContextWindowUsageHeader(usageWidth)
titleWidth := maxInt(1, width-lipgloss.Width(line1Label)-1)
if usage != "" {
titleWidth = maxInt(1, width-lipgloss.Width(line1Label)-lipgloss.Width(usage)-2)
}
left := line1Label + " " + m.styles.heading.Render(truncateText(title, titleWidth))
line1 := joinStyledLeftRight(width, left, usage)
if m.currentConversation == nil {
subtitle := "Create a thread or choose one from the sidebar."
return []string{
line1,
m.styles.subtleText.Width(width).Render(truncateText(subtitle, width)),
}
}
pieces := []string{
m.styles.chip.Render("provider " + fallback(m.currentConversation.GetProvider(), "<unset>")),
m.styles.chipMuted.Render("model " + fallback(m.currentConversation.GetModel(), "<default>")),
m.styles.chipMuted.Render("thinking " + effectiveThinkingLevelChipLabel(m.currentConversation, m.config)),
m.styles.chipMuted.Render(fmt.Sprintf("%d msgs", conversationMessageCount(m.currentConversation))),
}
if scroll := m.transcriptScrollSummary(viewportHeight, len(contentLines)); scroll != "" {
pieces = append(pieces, m.styles.chipMuted.Render(scroll))
}
if updated := formatUnix(m.currentConversation.GetUpdatedAt()); updated != "<unknown>" {
pieces = append(pieces, m.styles.subtleText.Render("updated "+updated))
}
return []string{
line1,
lipgloss.NewStyle().Width(width).Render(fitStyledPieces(width, pieces)),
}
}
func (m *aiModel) transcriptScrollSummary(viewportHeight, totalLines int) string {
if viewportHeight <= 0 || totalLines <= 0 {
return ""
}
maxScroll := maxInt(0, totalLines-viewportHeight)
scroll := clampInt(m.transcriptScroll, 0, maxScroll)
if maxScroll == 0 {
end := minInt(totalLines, scroll+viewportHeight)
return fmt.Sprintf("lines %d/%d", end, totalLines)
}
if m.transcriptFollow || scroll >= maxScroll {
scroll = maxScroll
start := minInt(totalLines, scroll+1)
end := minInt(totalLines, scroll+viewportHeight)
return fmt.Sprintf("latest %d-%d/%d", start, end, totalLines)
}
start := minInt(totalLines, scroll+1)
end := minInt(totalLines, scroll+viewportHeight)
return fmt.Sprintf("lines %d-%d/%d", start, end, totalLines)
}
func (m *aiModel) visibleTranscriptLines(content []aiTranscriptContentLine, viewportHeight int) []string {
if viewportHeight <= 0 || len(content) == 0 {
return nil
}
scroll, end := m.currentTranscriptScrollRange(len(content), viewportHeight)
visible := make([]string, 0, maxInt(0, end-scroll))
for idx := scroll; idx < end; idx++ {
visible = append(visible, m.renderVisibleTranscriptLine(content, idx))
}
return visible
}
func (m *aiModel) transcriptScrollbarCells(viewportHeight, totalLines int) []bool {
if viewportHeight <= 0 || totalLines <= viewportHeight {
return nil
}
maxScroll := maxInt(1, totalLines-viewportHeight)
scroll := clampInt(m.transcriptScroll, 0, maxScroll)
if m.transcriptFollow {
scroll = maxScroll
}
thumbHeight := maxInt(1, (viewportHeight*viewportHeight+totalLines/2)/totalLines)
thumbHeight = minInt(viewportHeight, thumbHeight)
trackSpan := maxInt(0, viewportHeight-thumbHeight)
thumbStart := 0
if trackSpan > 0 {
thumbStart = (scroll*trackSpan + maxScroll/2) / maxScroll
}
cells := make([]bool, viewportHeight)
for i := 0; i < thumbHeight; i++ {
idx := thumbStart + i
if idx >= 0 && idx < len(cells) {
cells[idx] = true
}
}
return cells
}
func (m *aiModel) renderTranscriptScrollbar(viewportHeight, totalLines int) string {
cells := m.transcriptScrollbarCells(viewportHeight, totalLines)
if len(cells) == 0 {
return ""
}
lines := make([]string, 0, len(cells))
for _, thumb := range cells {
lines = append(lines, m.renderTranscriptScrollbarCell(thumb))
}
return strings.Join(lines, "\n")
}
func (m *aiModel) currentTranscriptScrollRange(totalLines, viewportHeight int) (int, int) {
if viewportHeight <= 0 || totalLines <= 0 {
return 0, 0
}
maxScroll := maxInt(0, totalLines-viewportHeight)
scroll := clampInt(m.transcriptScroll, 0, maxScroll)
if m.transcriptFollow {
scroll = maxScroll
}
return scroll, minInt(totalLines, scroll+viewportHeight)
}
func (m *aiModel) transcriptScrollbarRowAt(x, y int, clamp bool) (int, bool) {
rect := m.currentPaneRects().transcript
if rect.width <= 0 || rect.height <= 0 {
return 0, false
}
contentWidth, viewportHeight := m.currentTranscriptViewportSize()
if contentWidth <= 0 || viewportHeight <= 0 {
return 0, false
}
totalLines := len(m.renderTranscriptDisplayContent(contentWidth))
if totalLines <= viewportHeight {
return 0, false
}
bodyTopY := rect.y + 1 + m.transcriptHeaderLineCount()
bodyBottomY := bodyTopY + viewportHeight - 1
if !rect.contains(x, y) {
if !clamp || x < rect.x || x >= rect.x+rect.width {
return 0, false
}
y = clampInt(y, bodyTopY, bodyBottomY)
}
innerWidth := innerPaneWidth(rect.width)
innerX := x - rect.x - 2
if innerX < contentWidth || innerX >= innerWidth {
if !clamp {
return 0, false
}
innerX = clampInt(innerX, contentWidth, maxInt(contentWidth, innerWidth-1))
}
innerY := y - rect.y - 1
if innerY < m.transcriptHeaderLineCount() {
if !clamp {
return 0, false
}
innerY = m.transcriptHeaderLineCount()
}
bodyY := innerY - m.transcriptHeaderLineCount()
if bodyY < 0 || bodyY >= viewportHeight {
if !clamp {
return 0, false
}
bodyY = clampInt(bodyY, 0, viewportHeight-1)
}
return bodyY, true
}
func (m *aiModel) renderTranscriptRightEdge(height int) string {
if height <= 0 {
return ""
}
style := lipgloss.NewStyle().Foreground(clienttheme.DefaultMod(300))
if m.focus == aiFocusTranscript {
style = style.Foreground(clienttheme.Primary())
}
border := lipgloss.RoundedBorder()
lines := make([]string, 0, height)
for i := 0; i < height; i++ {
switch {
case i == 0:
lines = append(lines, style.Render(border.TopRight))
case i == height-1:
lines = append(lines, style.Render(border.BottomRight))
default:
lines = append(lines, style.Render(border.Right))
}
}
return strings.Join(lines, "\n")
}
func (m *aiModel) renderTranscriptScrollbarCell(thumb bool) string {
if thumb {
style := lipgloss.NewStyle().Foreground(clienttheme.PrimaryMod(500))
if m.focus == aiFocusTranscript {
style = style.Foreground(clienttheme.Primary()).Bold(true)
}
return style.Render("#")
}
return lipgloss.NewStyle().Foreground(clienttheme.DefaultMod(300)).Render(":")
}
func (m *aiModel) pinTranscriptToLatest() {
m.transcriptFollow = true
}
func (m *aiModel) syncTranscriptViewport() {
width, viewportHeight := m.currentTranscriptViewportSize()
if width <= 0 || viewportHeight <= 0 {
return
}
contentLines := m.renderTranscriptDisplayContentLines(width)
maxScroll := maxInt(0, len(contentLines)-viewportHeight)
if m.transcriptFollow {
m.transcriptScroll = maxScroll
return
}
m.transcriptScroll = clampInt(m.transcriptScroll, 0, maxScroll)
if m.transcriptScroll >= maxScroll {
m.transcriptScroll = maxScroll
m.transcriptFollow = true
}
}
func (m *aiModel) scrollTranscript(delta int) {
width, viewportHeight := m.currentTranscriptViewportSize()
if width <= 0 || viewportHeight <= 0 {
return
}
contentLines := m.renderTranscriptDisplayContentLines(width)
maxScroll := maxInt(0, len(contentLines)-viewportHeight)
if maxScroll == 0 {
m.transcriptScroll = 0
m.transcriptFollow = true
return
}
start := m.transcriptScroll
if m.transcriptFollow {
start = maxScroll
}
m.transcriptScroll = clampInt(start+delta, 0, maxScroll)
m.transcriptFollow = m.transcriptScroll >= maxScroll
}
func (m *aiModel) scrollTranscriptToScrollbarRow(row int) {
width, viewportHeight := m.currentTranscriptViewportSize()
if width <= 0 || viewportHeight <= 0 {
return
}
totalLines := len(m.renderTranscriptDisplayContent(width))
maxScroll := maxInt(0, totalLines-viewportHeight)
if maxScroll == 0 {
m.transcriptScroll = 0
m.transcriptFollow = true
return
}
row = clampInt(row, 0, viewportHeight-1)
thumbHeight := maxInt(1, (viewportHeight*viewportHeight+totalLines/2)/totalLines)
thumbHeight = minInt(viewportHeight, thumbHeight)
trackSpan := maxInt(0, viewportHeight-thumbHeight)
if trackSpan == 0 {
m.transcriptScroll = maxScroll
m.transcriptFollow = true
return
}
thumbStart := clampInt(row-thumbHeight/2, 0, trackSpan)
m.transcriptScroll = (thumbStart*maxScroll + trackSpan/2) / trackSpan
m.transcriptFollow = m.transcriptScroll >= maxScroll
}
func (m *aiModel) scrollTranscriptPage(delta int) {
_, viewportHeight := m.currentTranscriptViewportSize()
m.scrollTranscript(delta * maxInt(1, viewportHeight-1))
}
func (m *aiModel) scrollTranscriptToTop() {
width, viewportHeight := m.currentTranscriptViewportSize()
if width <= 0 || viewportHeight <= 0 {
return
}
contentLines := m.renderTranscriptDisplayContentLines(width)
maxScroll := maxInt(0, len(contentLines)-viewportHeight)
m.transcriptScroll = 0
m.transcriptFollow = maxScroll == 0
}
func (m *aiModel) scrollTranscriptToBottom() {
m.transcriptFollow = true
m.syncTranscriptViewport()
}
func (m *aiModel) isBusy() bool {
return m.loading || m.awaitingResponse
}
func (m *aiModel) moveSelection(delta int) tea.Cmd {
if len(m.conversations) == 0 {
return nil
}
next := clampInt(m.selectedConversation+delta, 0, len(m.conversations)-1)
return m.selectConversation(next)
}
func (m *aiModel) selectConversation(index int) tea.Cmd {
if index < 0 || index >= len(m.conversations) {
return nil
}
target := m.conversations[index]
if target == nil || strings.TrimSpace(target.GetID()) == "" {
return nil
}
if index == m.selectedConversation &&
m.currentConversation != nil &&
strings.TrimSpace(m.currentConversation.GetID()) == strings.TrimSpace(target.GetID()) {
return nil
}
m.selectedConversation = index
m.loading = true
m.status = "Loading conversation..."
return loadAIStateCmd(m.con, m.ctx.target, target.GetID())
}
func (m *aiModel) showContextModal() (tea.Model, tea.Cmd) {
m.modal = &aiModalState{
kind: aiModalKindContext,
title: "Context",
}
m.status = "Context opened."
return m, nil
}
func (m *aiModel) showExperimentalWarningModal() {
m.modal = &aiModalState{
kind: aiModalKindExperimentalWarning,
title: aiExperimentalWarningTitle,
body: aiExperimentalWarningBody,
focus: aiModalFocusCancel,
}
}
func (m *aiModel) acceptExperimentalWarning() (tea.Model, tea.Cmd) {
m.modal = nil
m.loading = true
m.status = fallback(m.ctx.status, "Loading AI conversations from the server...")
return m, m.startAIStartup()
}
func (m *aiModel) showNewConversationModal() (tea.Model, tea.Cmd) {
if m.loading {
m.status = "A conversation sync is already in progress."
return m, nil
}
input := []rune("New conversation")
systemPrompt := []rune(m.defaultSystemPrompt())
m.modal = &aiModalState{
kind: aiModalKindNewConversation,
title: "New Conversation",
focus: aiModalFocusInput,
input: input,
cursor: len(input),
systemPrompt: systemPrompt,
systemCursor: len(systemPrompt),
}
m.status = "Name the new conversation."
return m, nil
}
func (m *aiModel) showThinkingLevelModal() (tea.Model, tea.Cmd) {
if m.loading {
m.status = "Wait for the current conversation sync to finish before changing the thinking level."
return m, nil
}
if m.awaitingResponse || m.submittingPrompt {
m.status = "Wait for the current AI request to finish before changing the thinking level."
return m, nil
}
if m.currentConversation == nil || strings.TrimSpace(m.currentConversation.GetID()) == "" {
m.status = "No AI conversation selected."
return m, nil
}
m.modal = &aiModalState{
kind: aiModalKindThinkingLevel,
title: "Thinking Level",
selectedOption: aiThinkingLevelOptionIndex(m.currentConversation.GetThinkingLevel()),
conversationID: m.currentConversation.GetID(),
}
m.status = "Select a thinking level for future turns."
return m, nil
}
func (m *aiModel) showTargetSelectModal() (tea.Model, tea.Cmd) {
if m.loading {
m.status = "Wait for the current conversation sync to finish before changing the active target."
return m, nil
}
if m.awaitingResponse || m.submittingPrompt {
m.status = "Wait for the current AI request to finish before changing the active target."
return m, nil
}
conversationTargetSessionID := ""
conversationTargetBeaconID := ""
if m.currentConversation != nil {
conversationTargetSessionID = m.currentConversation.GetTargetSessionID()
conversationTargetBeaconID = m.currentConversation.GetTargetBeaconID()
}
m.targetSelectionOptions = nil
m.modal = &aiModalState{
kind: aiModalKindTargetSelect,
title: "Active Target",
status: "Loading sessions and beacons from the server...",
}
m.status = "Selecting an active target."
return m, loadAITargetSelectionOptionsCmd(m.con, m.ctx.target, conversationTargetSessionID, conversationTargetBeaconID)
}
func (m *aiModel) showDeleteConversationModal() (tea.Model, tea.Cmd) {
if m.loading {
m.status = "Wait for the current conversation sync to finish before deleting."
return m, nil
}
if m.submittingPrompt {
m.status = "Wait for the current prompt to finish saving before deleting the conversation."
return m, nil
}
target := m.deleteTargetConversation()
if target == nil || strings.TrimSpace(target.GetID()) == "" {
m.status = "No AI conversation selected."
return m, nil
}
title := conversationTitle(target)
m.modal = &aiModalState{
kind: aiModalKindDeleteConfirm,
title: "Delete Conversation?",
body: fmt.Sprintf("Delete %q and all of its stored messages from the server? This cannot be undone.", title),
conversationID: target.GetID(),
selectedID: m.nextConversationIDAfterDelete(target.GetID()),
status: fmt.Sprintf("Deleted %q.", title),
}
return m, nil
}
func (m *aiModel) deleteTargetConversation() *clientpb.AIConversation {
if m.selectedConversation >= 0 && m.selectedConversation < len(m.conversations) {
target := m.conversations[m.selectedConversation]
if target == nil {
return m.currentConversation
}
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == strings.TrimSpace(target.GetID()) {
return m.currentConversation
}
return target
}
return m.currentConversation
}
func (m *aiModel) nextConversationIDAfterDelete(conversationID string) string {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" || len(m.conversations) == 0 {
return ""
}
idx := conversationIndexByID(m.conversations, conversationID)
if idx < 0 {
return ""
}
if len(m.conversations) == 1 {
return ""
}
if idx < len(m.conversations)-1 {
return m.conversations[idx+1].GetID()
}
return m.conversations[idx-1].GetID()
}
func (m *aiModel) removeConversation(conversationID string) {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" || len(m.conversations) == 0 {
return
}
filtered := make([]*clientpb.AIConversation, 0, len(m.conversations))
for _, conversation := range m.conversations {
if conversation == nil || strings.TrimSpace(conversation.GetID()) == conversationID {
continue
}
filtered = append(filtered, conversation)
}
m.conversations = filtered
next := conversationIndexByID(m.conversations, m.selectedConversationID())
if next < 0 {
next = 0
}
m.selectedConversation = next
}
func (m *aiModel) selectedConversationID() string {
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) != "" {
return m.currentConversation.GetID()
}
if m.selectedConversation >= 0 && m.selectedConversation < len(m.conversations) {
return m.conversations[m.selectedConversation].GetID()
}
return ""
}
func (m *aiModel) thinkingLevelModalOptions() []aiThinkingLevelOption {
return aiThinkingLevelOptions(m.defaultThinkingLevel())
}
func (m *aiModel) defaultProvider() string {
if m.config != nil && strings.TrimSpace(m.config.GetProvider()) != "" {
return strings.TrimSpace(m.config.GetProvider())
}
for _, provider := range m.providers {
if provider.GetConfigured() && strings.TrimSpace(provider.GetName()) != "" {
return provider.GetName()
}
}
for _, provider := range m.providers {
if strings.TrimSpace(provider.GetName()) != "" {
return provider.GetName()
}
}
return "openai"
}
func (m *aiModel) defaultModel() string {
if m.config == nil {
return ""
}
return strings.TrimSpace(m.config.GetModel())
}
func (m *aiModel) defaultSystemPrompt() string {
if m.currentConversation != nil {
if systemPrompt := strings.TrimSpace(m.currentConversation.GetSystemPrompt()); systemPrompt != "" {
return systemPrompt
}
}
if m.selectedConversation >= 0 && m.selectedConversation < len(m.conversations) {
if conversation := m.conversations[m.selectedConversation]; conversation != nil {
if systemPrompt := strings.TrimSpace(conversation.GetSystemPrompt()); systemPrompt != "" {
return systemPrompt
}
}
}
if m.config != nil {
return m.serverDefaultSystemPrompt()
}
return ""
}
func (m *aiModel) serverDefaultSystemPrompt() string {
if m.config == nil {
return ""
}
return strings.TrimSpace(m.config.GetSystemPrompt())
}
func (m *aiModel) defaultThinkingLevel() string {
if m.config == nil {
return ""
}
return normalizeAIThinkingLevel(m.config.GetThinkingLevel())
}
func (m *aiModel) pendingLabel() string {
switch effectiveConversationThinkingLevel(m.currentConversation, m.config) {
case "low", "medium", "high", "xhigh":
return "Thinking"
default:
return "Working"
}
}
func (m *aiModel) pendingStatus() string {
return "Waiting for AI response..."
}
func newAIToastProgress(level string) progress.Model {
bar := progress.New(
progress.WithWidth(1),
progress.WithoutPercentage(),
progress.WithColors(aiToastAccentColor(level)),
progress.WithFillCharacters(progress.DefaultFullCharFullBlock, ' '),
)
return bar
}
func aiToastAccentColor(level string) color.Color {
switch strings.ToLower(strings.TrimSpace(level)) {
case "error", "warn", "warning":
return clienttheme.Danger()
case "success":
return clienttheme.Success()
default:
return clienttheme.Primary()
}
}
func (t *aiToastState) remaining(now time.Time) time.Duration {
if t == nil {
return 0
}
if now.IsZero() {
now = time.Now()
}
remaining := t.expiresAt.Sub(now)
if remaining < 0 {
return 0
}
return remaining
}
func (t *aiToastState) fractionRemaining(now time.Time) float64 {
if t == nil {
return 0
}
total := t.expiresAt.Sub(t.createdAt)
if total <= 0 {
return 0
}
return clampFloat64(float64(t.remaining(now))/float64(total), 0, 1)
}
func formatAIToastTimeLeft(remaining time.Duration) string {
if remaining < 0 {
remaining = 0
}
return fmt.Sprintf("%.1fs left", remaining.Seconds())
}
func (m *aiModel) showToast(level string, message string) tea.Cmd {
message = strings.TrimSpace(message)
if message == "" {
return nil
}
now := time.Now()
m.nextToastID++
toast := &aiToastState{
id: m.nextToastID,
level: strings.ToLower(strings.TrimSpace(level)),
message: message,
createdAt: now,
expiresAt: now.Add(aiToastDuration),
bar: newAIToastProgress(level),
}
m.toasts = append([]*aiToastState{toast}, m.toasts...)
if len(m.toasts) > aiToastStackLimit {
m.toasts = m.toasts[:aiToastStackLimit]
}
return aiToastExpiryCmd(toast.id)
}
func (m *aiModel) expireToast(id uint64) {
if id == 0 || len(m.toasts) == 0 {
return
}
filtered := m.toasts[:0]
for _, toast := range m.toasts {
if toast == nil || toast.id == id {
continue
}
filtered = append(filtered, toast)
}
for idx := len(filtered); idx < len(m.toasts); idx++ {
m.toasts[idx] = nil
}
if len(filtered) == 0 {
m.toasts = nil
return
}
m.toasts = filtered
}
func (m *aiModel) applyWindowSize(width, height int) tea.Cmd {
if width <= 0 || height <= 0 {
return nil
}
if m.width == width && m.height == height {
return nil
}
m.width = width
m.height = height
m.syncTranscriptViewport()
return m.scheduleTranscriptRender()
}
func (m *aiModel) windowSizeCmd(width, height int) tea.Cmd {
if width <= 0 || height <= 0 {
return nil
}
if m.width == width && m.height == height {
return nil
}
return func() tea.Msg {
return tea.WindowSizeMsg{Width: width, Height: height}
}
}
func (m *aiModel) startAwaitingResponse() tea.Cmd {
wasAwaiting := m.awaitingResponse
m.awaitingResponse = true
if strings.TrimSpace(m.status) == "" || strings.HasPrefix(m.status, "Saving prompt") {
m.status = m.pendingStatus()
}
if !wasAwaiting {
m.thinkingAnim = newAIThinkingAnim(m.pendingLabel())
}
if m.thinkingAnim != nil {
m.thinkingAnim.SetLabel(m.pendingLabel())
}
if !wasAwaiting && m.thinkingAnim != nil {
return m.thinkingAnim.Start()
}
return nil
}
func (m *aiModel) syncAwaitingResponse() tea.Cmd {
wasAwaiting := m.awaitingResponse
m.awaitingResponse = conversationAwaitingResponse(m.currentConversation)
if m.awaitingResponse && !wasAwaiting {
m.thinkingAnim = newAIThinkingAnim(m.pendingLabel())
}
if m.thinkingAnim != nil {
m.thinkingAnim.SetLabel(m.pendingLabel())
}
if m.awaitingResponse && (m.status == "" || strings.HasPrefix(m.status, "Loaded AI conversations") || strings.HasPrefix(m.status, "Conversation updated")) {
m.status = m.pendingStatus()
}
if m.awaitingResponse && !wasAwaiting && m.thinkingAnim != nil {
return m.thinkingAnim.Start()
}
return nil
}
func (m *aiModel) applyOptimisticPrompt(conversation *clientpb.AIConversation, message *clientpb.AIConversationMessage) {
if conversation == nil && message == nil {
return
}
conversationID := ""
if conversation != nil {
conversationID = strings.TrimSpace(conversation.GetID())
}
if conversationID == "" && message != nil {
conversationID = strings.TrimSpace(message.GetConversationID())
}
if conversationID == "" {
return
}
optimistic := cloneConversation(m.currentConversation)
if optimistic == nil || strings.TrimSpace(optimistic.GetID()) != conversationID {
optimistic = cloneConversation(conversation)
}
if optimistic == nil {
optimistic = &clientpb.AIConversation{ID: conversationID}
}
if conversation != nil {
mergeConversationMetadata(optimistic, conversation)
}
optimistic.OperatorName = normalizedConversationOperatorName(optimistic.GetOperatorName())
if optimistic.GetOperatorName() == "" {
optimistic.OperatorName = normalizedConversationOperatorName(m.ctx.connection.Operator)
}
if message != nil {
pending := cloneConversationMessage(message)
if pending != nil {
if strings.TrimSpace(pending.GetConversationID()) == "" {
pending.ConversationID = conversationID
}
pending.OperatorName = normalizedConversationOperatorName(pending.GetOperatorName())
if strings.TrimSpace(pending.GetOperatorName()) == "" {
pending.OperatorName = optimistic.GetOperatorName()
}
if strings.TrimSpace(pending.GetProvider()) == "" {
pending.Provider = optimistic.GetProvider()
}
if strings.TrimSpace(pending.GetModel()) == "" {
pending.Model = optimistic.GetModel()
}
m.upsertConversationMessage(optimistic, pending)
lastUpdate := maxInt64(pending.GetUpdatedAt(), pending.GetCreatedAt())
if lastUpdate == 0 {
lastUpdate = time.Now().Unix()
}
if lastUpdate > optimistic.GetUpdatedAt() {
optimistic.UpdatedAt = lastUpdate
}
}
}
m.currentConversation = optimistic
m.selectedConversation = m.upsertConversation(optimistic)
m.invalidateTranscriptCache()
m.syncTranscriptViewport()
}
func (m *aiModel) upsertConversation(conversation *clientpb.AIConversation) int {
if conversation == nil {
return m.selectedConversation
}
summary := cloneConversation(conversation)
if summary == nil {
return m.selectedConversation
}
idx := conversationIndexByID(m.conversations, summary.GetID())
if idx >= 0 {
m.conversations[idx] = summary
return idx
}
m.conversations = append([]*clientpb.AIConversation{summary}, m.conversations...)
return 0
}
func (m *aiModel) applyConversationEvent(event *clientpb.AIConversationEvent) {
if event == nil {
return
}
conversationID := strings.TrimSpace(event.GetConversationID())
if conversationID == "" && event.GetConversation() != nil {
conversationID = strings.TrimSpace(event.GetConversation().GetID())
}
if conversationID == "" && event.GetMessage() != nil {
conversationID = strings.TrimSpace(event.GetMessage().GetConversationID())
}
if event.GetConversation() != nil {
m.applyConversationSnapshot(event.GetConversation())
}
if event.GetMessage() != nil {
m.applyConversationMessageEvent(conversationID, event.GetMessage())
}
switch event.GetEventType() {
case clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_CONVERSATION_DELETED:
m.removeConversation(conversationID)
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == conversationID {
m.currentConversation = nil
m.awaitingResponse = false
m.submittingPrompt = false
m.pendingPrompt = ""
if len(m.conversations) > 0 {
m.selectedConversation = clampInt(m.selectedConversation, 0, len(m.conversations)-1)
} else {
m.selectedConversation = 0
}
}
if strings.TrimSpace(m.status) == "" {
m.status = "Conversation deleted on the server."
}
case clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_FAILED:
if strings.TrimSpace(event.GetErrorText()) != "" && m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == conversationID {
m.status = "AI request failed: " + strings.TrimSpace(event.GetErrorText())
}
case clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_STARTED:
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == conversationID {
m.status = m.pendingStatus()
}
case clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_TURN_COMPLETED:
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == conversationID {
m.pendingPrompt = ""
if m.status == "" || strings.HasPrefix(m.status, "Waiting for AI response") || strings.HasPrefix(m.status, "Thinking") {
m.status = "AI response synced from the server."
}
}
case clientpb.AIConversationEventType_AI_CONVERSATION_EVENT_TYPE_MESSAGE_COMPLETED:
if event.GetMessage() != nil && strings.EqualFold(strings.TrimSpace(event.GetMessage().GetRole()), "assistant") {
m.pendingPrompt = ""
if m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == conversationID {
m.status = "AI response synced from the server."
}
}
}
if m.currentConversation != nil {
m.selectedConversation = conversationIndexByID(m.conversations, m.currentConversation.GetID())
if m.selectedConversation < 0 {
m.selectedConversation = 0
}
}
m.invalidateTranscriptCache()
}
func (m *aiModel) applyConversationSnapshot(conversation *clientpb.AIConversation) {
if conversation == nil {
return
}
summary := cloneConversation(conversation)
if summary == nil {
return
}
summary.Messages = nil
m.selectedConversation = m.upsertConversation(summary)
conversationID := strings.TrimSpace(conversation.GetID())
switch {
case m.currentConversation == nil && conversationID != "":
if m.selectedConversationID() == "" || m.selectedConversationID() == conversationID {
m.currentConversation = summary
}
case m.currentConversation != nil && strings.TrimSpace(m.currentConversation.GetID()) == conversationID:
mergeConversationMetadata(m.currentConversation, conversation)
}
}
func (m *aiModel) applyConversationMessageEvent(conversationID string, message *clientpb.AIConversationMessage) {
if message == nil {
return
}
if conversationID == "" {
conversationID = strings.TrimSpace(message.GetConversationID())
}
if conversationID == "" {
return
}
if m.currentConversation == nil || strings.TrimSpace(m.currentConversation.GetID()) != conversationID {
return
}
m.upsertConversationMessage(m.currentConversation, message)
}
func (m *aiModel) upsertConversationMessage(conversation *clientpb.AIConversation, message *clientpb.AIConversationMessage) {
if conversation == nil || message == nil {
return
}
cloned := cloneConversationMessage(message)
if cloned == nil {
return
}
for idx, existing := range conversation.GetMessages() {
if sameConversationMessage(existing, cloned) {
conversation.Messages[idx] = cloned
sortConversationMessages(conversation.Messages)
return
}
}
conversation.Messages = append(conversation.Messages, cloned)
sortConversationMessages(conversation.Messages)
}
func (m *aiModel) renderTranscriptMarkdown(width int) string {
lines := m.renderTranscriptMarkdownLines(width)
return strings.Join(lines, "\n")
}
func (m *aiModel) renderTranscriptMarkdownLines(width int) []string {
markdown := buildConversationMarkdown(m.currentConversation)
rendered, err := renderMarkdownWithGlow(width, markdown)
if err != nil {
rendered = markdown
}
rendered = strings.TrimSpace(rendered)
if rendered == "" {
rendered = "_No messages yet._"
}
return strings.Split(rendered, "\n")
}
func (m *aiModel) transcriptRenderKey(width int) string {
return fmt.Sprintf("%d:%d", m.transcriptVersion, width)
}
func (m *aiModel) invalidateTranscriptCache() {
m.transcriptVersion++
m.transcriptPendingKey = ""
m.transcriptCacheKey = ""
m.transcriptCache = ""
m.transcriptCacheLines = nil
m.transcriptCacheContent = nil
m.clearTranscriptSelection()
}
func (m *aiModel) transcriptDisplayLines(width int) []string {
return transcriptContentStyledLines(m.transcriptDisplayContent(width))
}
func (m *aiModel) transcriptDisplayContent(width int) []aiTranscriptContentLine {
key := m.transcriptRenderKey(width)
if key == m.transcriptCacheKey && len(m.transcriptCacheContent) > 0 {
return m.transcriptCacheContent
}
if key == m.transcriptCacheKey && len(m.transcriptCacheLines) > 0 {
content := make([]aiTranscriptContentLine, 0, len(m.transcriptCacheLines))
for _, line := range m.transcriptCacheLines {
content = append(content, aiTranscriptContentLine{styled: line})
}
return content
}
placeholder := "Rendering transcript..."
if m.currentConversation == nil {
placeholder = "Loading conversation..."
}
return []aiTranscriptContentLine{{
styled: m.styles.subtleText.Width(maxInt(1, width)).Render(truncateText(placeholder, maxInt(1, width))),
}}
}
func (m *aiModel) transcriptContentWidth(innerWidth int) int {
return maxInt(1, innerWidth-m.transcriptScrollbarColumns(innerWidth))
}
func (m *aiModel) transcriptScrollbarColumns(innerWidth int) int {
if innerWidth <= 1 {
return 0
}
if innerWidth == 2 {
return 1
}
return aiTranscriptScrollbarWidth + 1
}
func (m *aiModel) currentTranscriptWidth() int {
paneWidth, _ := m.currentTranscriptPaneSize()
return m.transcriptContentWidth(innerPaneWidth(paneWidth))
}
func (m *aiModel) scheduleTranscriptRender() tea.Cmd {
if m.width <= 0 {
return nil
}
width := m.currentTranscriptWidth()
key := m.transcriptRenderKey(width)
if key == m.transcriptCacheKey && len(m.transcriptCacheLines) > 0 {
return nil
}
if key == m.transcriptPendingKey {
return nil
}
m.transcriptPendingKey = key
conversation := cloneConversation(m.currentConversation)
return func() tea.Msg {
content := renderConversationTranscript(width, conversation)
lines := transcriptContentStyledLines(content)
rendered := strings.Join(lines, "\n")
rendered = strings.TrimSpace(rendered)
if rendered == "" {
rendered = "_No messages yet._"
}
return aiTranscriptRenderedMsg{
key: key,
rendered: rendered,
lines: strings.Split(rendered, "\n"),
content: content,
}
}
}
func (m *aiModel) shouldSkipConversationEventReload(conversation *clientpb.AIConversation) bool {
if conversation == nil || !m.awaitingResponse {
return false
}
if isAIConversationDeleteEvent(conversation) {
return false
}
if !conversationAwaitingResponse(m.currentConversation) {
return false
}
currentID := strings.TrimSpace(m.currentConversation.GetID())
eventID := strings.TrimSpace(conversation.GetID())
if currentID == "" || eventID == "" || currentID != eventID {
return false
}
eventUpdatedAt := conversation.GetUpdatedAt()
currentUpdatedAt := m.currentConversation.GetUpdatedAt()
if eventUpdatedAt > currentUpdatedAt {
return false
}
// Ignore redundant sync events for the same optimistic user message and keep
// the local pending animation running until the assistant reply arrives.
return true
}
func isAIConversationDeleteEvent(conversation *clientpb.AIConversation) bool {
if conversation == nil {
return false
}
if strings.TrimSpace(conversation.GetID()) == "" {
return false
}
if conversation.GetCreatedAt() != 0 || conversation.GetUpdatedAt() != 0 {
return false
}
if len(conversation.GetMessages()) != 0 {
return false
}
if strings.TrimSpace(conversation.GetProvider()) != "" ||
strings.TrimSpace(conversation.GetModel()) != "" ||
strings.TrimSpace(conversation.GetThinkingLevel()) != "" ||
strings.TrimSpace(conversation.GetTitle()) != "" ||
strings.TrimSpace(conversation.GetSummary()) != "" ||
strings.TrimSpace(conversation.GetSystemPrompt()) != "" {
return false
}
return true
}
func (m aiFocus) String() string {
switch m {
case aiFocusSidebar:
return "sidebar"
case aiFocusTranscript:
return "conversation"
case aiFocusComposer:
return "composer"
default:
return "unknown"
}
}
func aiView(content string) tea.View {
view := tea.NewView(content)
view.AltScreen = true
view.MouseMode = tea.MouseModeAllMotion
return view
}
func aiWindowPollCmd() tea.Cmd {
return tea.Tick(aiWindowPollInterval, func(time.Time) tea.Msg {
width, height, ok := currentTerminalSize()
if !ok {
return aiWindowPollMsg{}
}
return aiWindowPollMsg{width: width, height: height}
})
}
func aiToastExpiryCmd(id uint64) tea.Cmd {
if id == 0 {
return nil
}
return tea.Tick(aiToastDuration, func(time.Time) tea.Msg {
return aiToastExpiredMsg{id: id}
})
}
func currentTerminalSize() (int, int, bool) {
for _, fd := range []int{1, 0, 2} {
width, height, err := term.GetSize(fd)
if err == nil && width > 0 && height > 0 {
return width, height, true
}
}
return 0, 0, false
}
func loadAIStateCmd(con *console.SliverClient, target aiTargetSummary, selectedID string) tea.Cmd {
return loadAIStateWithStatusCmd(con, target, selectedID, "")
}
func loadAIStateWithStatusCmd(con *console.SliverClient, target aiTargetSummary, selectedID string, baseStatus string) tea.Cmd {
return func() tea.Msg {
if con == nil || con.Rpc == nil {
return aiAsyncErrMsg{err: fmt.Errorf("AI RPC client is unavailable")}
}
grpcCtx, cancel := con.GrpcContext(nil)
defer cancel()
providersResp, err := con.Rpc.GetAIProviders(grpcCtx, &commonpb.Empty{})
if err != nil {
return aiAsyncErrMsg{err: err}
}
config := safeAIConfigSummary(providersResp)
if !config.GetValid() {
return aiStartupConfigInvalidMsg{err: aiConfigError(config)}
}
conversationsResp, err := con.Rpc.GetAIConversations(grpcCtx, &commonpb.Empty{})
if err != nil {
return aiAsyncErrMsg{err: err}
}
conversations := conversationsResp.GetConversations()
activeID := strings.TrimSpace(selectedID)
status := strings.TrimSpace(baseStatus)
if len(conversations) == 0 {
createdConversation, err := con.Rpc.SaveAIConversation(grpcCtx, &clientpb.AIConversation{
Provider: config.GetProvider(),
Model: config.GetModel(),
Title: "New conversation",
SystemPrompt: strings.TrimSpace(config.GetSystemPrompt()),
TargetSessionID: strings.TrimSpace(target.SessionID),
TargetBeaconID: strings.TrimSpace(target.BeaconID),
})
if err != nil {
return aiAsyncErrMsg{err: err}
}
conversations = []*clientpb.AIConversation{createdConversation}
activeID = createdConversation.GetID()
if status == "" {
status = "Created a new AI conversation."
} else {
status += " Created a new AI conversation."
}
}
if activeID == "" && len(conversations) > 0 {
activeID = conversations[0].GetID()
}
var conversation *clientpb.AIConversation
if activeID != "" {
conversation, err = con.Rpc.GetAIConversation(grpcCtx, &clientpb.AIConversationReq{
ID: activeID,
IncludeMessages: true,
})
if err != nil && len(conversations) > 0 {
fallbackID := conversations[0].GetID()
if fallbackID != activeID {
conversation, err = con.Rpc.GetAIConversation(grpcCtx, &clientpb.AIConversationReq{
ID: fallbackID,
IncludeMessages: true,
})
activeID = fallbackID
}
}
if err != nil {
return aiAsyncErrMsg{err: err}
}
}
return aiLoadedMsg{
providers: providersResp.GetProviders(),
config: config,
conversations: conversations,
conversation: conversation,
selectedID: activeID,
status: status,
}
}
}
func loadAITargetSelectionOptionsCmd(con *console.SliverClient, activeTarget aiTargetSummary, conversationTargetSessionID string, conversationTargetBeaconID string) tea.Cmd {
return func() tea.Msg {
if con == nil || con.Rpc == nil {
return aiTargetOptionsLoadedMsg{status: "AI RPC client is unavailable."}
}
grpcCtx, cancel := con.GrpcContext(nil)
defer cancel()
sessionsResp, err := con.Rpc.GetSessions(grpcCtx, &commonpb.Empty{})
if err != nil {
return aiTargetOptionsLoadedMsg{status: "Failed to load sessions: " + err.Error()}
}
beaconsResp, err := con.Rpc.GetBeacons(grpcCtx, &commonpb.Empty{})
if err != nil {
return aiTargetOptionsLoadedMsg{status: "Failed to load beacons: " + err.Error()}
}
options := buildAITargetSelectionOptions(sessionsResp.GetSessions(), beaconsResp.GetBeacons(), activeTarget)
status := "Select the session or beacon to make active."
if len(options) == 0 {
status = "No sessions or beacons are currently available."
}
return aiTargetOptionsLoadedMsg{
options: options,
selectedOption: aiTargetSelectionOptionIndex(options, activeTarget, conversationTargetSessionID, conversationTargetBeaconID),
status: status,
}
}
}
func createConversationCmd(con *console.SliverClient, target aiTargetSummary, provider string, model string, title string, systemPrompt string) tea.Cmd {
return func() tea.Msg {
if con == nil || con.Rpc == nil {
return aiAsyncErrMsg{err: fmt.Errorf("AI RPC client is unavailable")}
}
grpcCtx, cancel := con.GrpcContext(nil)
defer cancel()
conversation, err := con.Rpc.SaveAIConversation(grpcCtx, &clientpb.AIConversation{
Provider: provider,
Model: strings.TrimSpace(model),
Title: strings.TrimSpace(title),
SystemPrompt: strings.TrimSpace(systemPrompt),
TargetSessionID: strings.TrimSpace(target.SessionID),
TargetBeaconID: strings.TrimSpace(target.BeaconID),
})
if err != nil {
return aiAsyncErrMsg{err: err}
}
return aiConversationCreatedMsg{
conversationID: conversation.GetID(),
status: "Created a new AI conversation.",
}
}
}
func deleteConversationCmd(con *console.SliverClient, conversationID string, selectedID string, status string) tea.Cmd {
return func() tea.Msg {
if con == nil || con.Rpc == nil {
return aiAsyncErrMsg{err: fmt.Errorf("AI RPC client is unavailable")}
}
grpcCtx, cancel := con.GrpcContext(nil)
defer cancel()
_, err := con.Rpc.DeleteAIConversation(grpcCtx, &clientpb.AIConversationReq{ID: conversationID})
if err != nil {
return aiAsyncErrMsg{err: err}
}
return aiConversationDeletedMsg{
conversationID: conversationID,
selectedID: selectedID,
status: status,
}
}
}
func updateConversationThinkingLevelCmd(con *console.SliverClient, conversation *clientpb.AIConversation, thinkingLevel string) tea.Cmd {
return func() tea.Msg {
if con == nil || con.Rpc == nil {
return aiAsyncErrMsg{err: fmt.Errorf("AI RPC client is unavailable")}
}
if conversation == nil || strings.TrimSpace(conversation.GetID()) == "" {
return aiAsyncErrMsg{err: fmt.Errorf("AI conversation is unavailable")}
}
grpcCtx, cancel := con.GrpcContext(nil)
defer cancel()
req := conversationSaveRequest(conversation)
req.ThinkingLevel = normalizeAIThinkingLevel(thinkingLevel)
savedConversation, err := con.Rpc.SaveAIConversation(grpcCtx, req)
if err != nil {
return aiAsyncErrMsg{err: err}
}
status := "Thinking level reset to provider default."
if req.GetThinkingLevel() != "" {
status = "Thinking level set to " + req.GetThinkingLevel() + "."
}
return aiConversationUpdatedMsg{
conversation: savedConversation,
status: status,
}
}
}
func updateConversationTargetCmd(con *console.SliverClient, conversation *clientpb.AIConversation, target aiTargetSummary) tea.Cmd {
return func() tea.Msg {
if con == nil || con.Rpc == nil {
return aiAsyncErrMsg{err: fmt.Errorf("AI RPC client is unavailable")}
}
if conversation == nil || strings.TrimSpace(conversation.GetID()) == "" {
return aiAsyncErrMsg{err: fmt.Errorf("AI conversation is unavailable")}
}
grpcCtx, cancel := con.GrpcContext(nil)
defer cancel()
sessionID, beaconID := normalizedAITargetIDs(target.SessionID, target.BeaconID)
req := conversationSaveRequest(conversation)
req.TargetSessionID = sessionID
req.TargetBeaconID = beaconID
savedConversation, err := con.Rpc.SaveAIConversation(grpcCtx, req)
if err != nil {
return aiAsyncErrMsg{err: err}
}
return aiConversationTargetUpdatedMsg{
conversation: savedConversation,
target: aiTargetSummary{SessionID: sessionID, BeaconID: beaconID, Label: target.Label},
status: "Active target set to " + fallback(target.Label, "selected target") + ".",
}
}
}
func submitPromptCmd(con *console.SliverClient, target aiTargetSummary, conversation *clientpb.AIConversation, provider string, model string, systemPrompt string, prompt string) tea.Cmd {
return func() tea.Msg {
return submitPromptMsg(con, target, conversation, provider, model, systemPrompt, prompt)
}
}
func submitPromptMsg(con *console.SliverClient, target aiTargetSummary, conversation *clientpb.AIConversation, provider string, model string, systemPrompt string, prompt string) tea.Msg {
if con == nil || con.Rpc == nil {
return aiAsyncErrMsg{err: fmt.Errorf("AI RPC client is unavailable")}
}
grpcCtx, cancel := con.GrpcContext(nil)
defer cancel()
activeConversation := conversation
var err error
if activeConversation == nil || strings.TrimSpace(activeConversation.GetID()) == "" {
activeConversation, err = con.Rpc.SaveAIConversation(grpcCtx, &clientpb.AIConversation{
Provider: provider,
Model: strings.TrimSpace(model),
Title: promptConversationTitle(prompt),
SystemPrompt: strings.TrimSpace(systemPrompt),
TargetSessionID: strings.TrimSpace(target.SessionID),
TargetBeaconID: strings.TrimSpace(target.BeaconID),
})
if err != nil {
return aiAsyncErrMsg{err: err}
}
}
messageProvider := strings.TrimSpace(activeConversation.GetProvider())
if messageProvider == "" {
messageProvider = strings.TrimSpace(provider)
}
message := &clientpb.AIConversationMessage{
ConversationID: activeConversation.GetID(),
Provider: messageProvider,
Model: activeConversation.GetModel(),
Role: "user",
Content: prompt,
}
savedMessage, err := con.Rpc.SaveAIConversationMessage(grpcCtx, message)
if err != nil {
return aiAsyncErrMsg{err: err}
}
if savedMessage == nil {
savedMessage = cloneConversationMessage(message)
}
return aiPromptSubmittedMsg{
conversationID: activeConversation.GetID(),
conversation: cloneConversation(activeConversation),
message: savedMessage,
status: "Saved prompt to " + conversationTitle(activeConversation) + ". Waiting for AI response...",
}
}
func waitForAISubmitResultCmd(results <-chan tea.Msg) tea.Cmd {
return func() tea.Msg {
if results == nil {
return aiAsyncErrMsg{err: fmt.Errorf("AI submit queue is unavailable")}
}
msg, ok := <-results
if !ok {
return aiAsyncErrMsg{err: fmt.Errorf("AI submit queue closed")}
}
return msg
}
}
func waitForAIConversationEventCmd(listener <-chan *clientpb.Event) tea.Cmd {
return func() tea.Msg {
if listener == nil {
return nil
}
for {
event, ok := <-listener
if !ok {
return aiListenerClosedMsg{}
}
if event == nil {
continue
}
switch event.GetEventType() {
case consts.AIConversationEvent:
aiEvent := &clientpb.AIConversationEvent{}
if len(event.GetData()) > 0 {
if err := proto.Unmarshal(event.GetData(), aiEvent); err != nil {
continue
}
}
return aiConversationEventMsg{event: aiEvent}
case consts.ClientToastEvent:
return aiToastMsg{
level: strings.TrimSpace(event.GetErr()),
message: strings.TrimSpace(string(event.GetData())),
}
}
}
}
}
func conversationMessagesForDisplay(conversation *clientpb.AIConversation) []*clientpb.AIConversationMessage {
if conversation == nil {
return nil
}
messages := conversation.GetMessages()
systemPrompt := strings.TrimSpace(conversation.GetSystemPrompt())
if systemPrompt == "" {
return messages
}
for _, message := range messages {
if message == nil {
continue
}
if message.GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT {
continue
}
if strings.EqualFold(strings.TrimSpace(message.GetRole()), "system") &&
strings.TrimSpace(message.GetContent()) == systemPrompt {
return messages
}
}
includeInContext := true
systemMessageID := "system-prompt"
if conversationID := strings.TrimSpace(conversation.GetID()); conversationID != "" {
systemMessageID = conversationID + "-system-prompt"
}
systemMessage := &clientpb.AIConversationMessage{
ID: systemMessageID,
ConversationID: strings.TrimSpace(conversation.GetID()),
CreatedAt: conversation.GetCreatedAt(),
UpdatedAt: conversation.GetUpdatedAt(),
OperatorName: strings.TrimSpace(conversation.GetOperatorName()),
Provider: strings.TrimSpace(conversation.GetProvider()),
Model: strings.TrimSpace(conversation.GetModel()),
Sequence: 0,
Role: "system",
Content: systemPrompt,
Kind: clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT,
Visibility: clientpb.AIConversationMessageVisibility_AI_MESSAGE_VISIBILITY_CONTEXT,
State: clientpb.AIConversationMessageState_AI_MESSAGE_STATE_COMPLETED,
IncludeInContext: &includeInContext,
}
displayMessages := make([]*clientpb.AIConversationMessage, 0, len(messages)+1)
displayMessages = append(displayMessages, systemMessage)
displayMessages = append(displayMessages, messages...)
return displayMessages
}
func conversationMessageCount(conversation *clientpb.AIConversation) int {
return len(conversationMessagesForDisplay(conversation))
}
func buildConversationMarkdown(conversation *clientpb.AIConversation) string {
if conversation == nil {
return "### No conversation selected\n\nCreate a new conversation or submit a prompt to start one."
}
var markdown strings.Builder
if summary := strings.TrimSpace(conversation.GetSummary()); summary != "" {
markdown.WriteString(summary)
markdown.WriteString("\n\n---\n\n")
}
messages := conversationMessagesForDisplay(conversation)
if len(messages) == 0 {
markdown.WriteString("_No messages yet. Type a prompt below to start this thread._")
return markdown.String()
}
for i, message := range messages {
meta := []string{}
if ts := formatUnix(message.GetCreatedAt()); ts != "<unknown>" {
meta = append(meta, ts)
}
if provider := strings.TrimSpace(message.GetProvider()); provider != "" {
meta = append(meta, provider)
}
if model := strings.TrimSpace(message.GetModel()); model != "" {
meta = append(meta, model)
}
markdown.WriteString(conversationMessageMarkdown(conversation, message, meta))
if i < len(messages)-1 {
markdown.WriteString("\n\n***\n\n")
}
}
return markdown.String()
}
func renderMarkdownWithGlow(width int, markdown string) (string, error) {
// Glow renders markdown through Glamour; mirror that render path here so AI
// conversation content is displayed with the same terminal markdown renderer.
// Use a fixed style to avoid Glamour's auto-style terminal color queries,
// which can leak OSC/CSI responses back into the TUI input stream.
renderer, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("dark"),
glamour.WithWordWrap(maxInt(8, width)),
glamour.WithPreservedNewLines(),
)
if err != nil {
return "", err
}
rendered, err := renderer.Render(markdown)
if err != nil {
return "", err
}
lines := strings.Split(rendered, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, " \t")
}
return strings.Join(lines, "\n"), nil
}
func looksLikeTerminalResponseFragment(text string) bool {
text = strings.TrimSpace(text)
if text == "" {
return false
}
if strings.Contains(text, "rgb:") {
return true
}
if strings.HasPrefix(text, "]10;") || strings.HasPrefix(text, "]11;") || strings.HasPrefix(text, "]12;") {
return true
}
if strings.HasPrefix(text, "[") && strings.HasSuffix(text, "R") {
for _, r := range text[1 : len(text)-1] {
if (r < '0' || r > '9') && r != ';' {
return false
}
}
return true
}
return false
}
func conversationContainsUserPrompt(conversation *clientpb.AIConversation, prompt string) bool {
prompt = strings.TrimSpace(prompt)
if conversation == nil || prompt == "" {
return false
}
for _, message := range conversation.GetMessages() {
if message == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(message.GetRole()), "user") &&
strings.TrimSpace(message.GetContent()) == prompt {
return true
}
}
return false
}
func selectedConversationID(conversation *clientpb.AIConversation, fallbackID string) string {
if conversation != nil && strings.TrimSpace(conversation.GetID()) != "" {
return conversation.GetID()
}
return strings.TrimSpace(fallbackID)
}
func conversationIndexByID(conversations []*clientpb.AIConversation, id string) int {
if strings.TrimSpace(id) == "" {
if len(conversations) == 0 {
return 0
}
return 0
}
for i, conversation := range conversations {
if conversation.GetID() == id {
return i
}
}
return -1
}
func conversationTitle(conversation *clientpb.AIConversation) string {
if conversation == nil {
return "Untitled conversation"
}
title := strings.TrimSpace(conversation.GetTitle())
if title != "" {
return title
}
if shortID := shortenID(conversation.GetID()); shortID != "" {
return "Conversation " + shortID
}
return "Untitled conversation"
}
func normalizeAIThinkingLevel(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "low", "medium", "high", "xhigh", "disabled":
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
func effectiveConversationThinkingLevel(conversation *clientpb.AIConversation, config *clientpb.AIConfigSummary) string {
if conversation != nil {
if thinkingLevel := normalizeAIThinkingLevel(conversation.GetThinkingLevel()); thinkingLevel != "" {
return thinkingLevel
}
}
if config != nil {
return normalizeAIThinkingLevel(config.GetThinkingLevel())
}
return ""
}
func defaultThinkingLevelSummary(config *clientpb.AIConfigSummary) string {
if config == nil {
return "provider default"
}
if thinkingLevel := normalizeAIThinkingLevel(config.GetThinkingLevel()); thinkingLevel != "" {
return thinkingLevel
}
return "provider default"
}
func conversationThinkingLevelSummary(conversation *clientpb.AIConversation, config *clientpb.AIConfigSummary) string {
if conversation != nil {
if thinkingLevel := normalizeAIThinkingLevel(conversation.GetThinkingLevel()); thinkingLevel != "" {
return thinkingLevel
}
}
if defaultLevel := normalizeAIThinkingLevel(config.GetThinkingLevel()); defaultLevel != "" {
return "provider default (" + defaultLevel + ")"
}
return "provider default"
}
func effectiveThinkingLevelChipLabel(conversation *clientpb.AIConversation, config *clientpb.AIConfigSummary) string {
if thinkingLevel := effectiveConversationThinkingLevel(conversation, config); thinkingLevel != "" {
return thinkingLevel
}
return "default"
}
func conversationContextWindowSummary(conversation *clientpb.AIConversation) string {
usage := conversationContextWindowUsage(conversation)
if usage == nil {
return "No usage data yet."
}
if usage.GetContextWindowTokens() > 0 {
prefix := ""
if usage.GetContextWindowTokensEstimated() {
prefix = "~"
}
return fmt.Sprintf(
"%s%s / %s (%s)",
prefix,
formatAICompactTokenCount(usage.GetTotalTokens()),
formatAICompactTokenCount(usage.GetContextWindowTokens()),
formatAIContextWindowPercent(usage),
)
}
if usage.GetTotalTokens() > 0 {
return formatAICompactTokenCount(usage.GetTotalTokens()) + " total tokens"
}
return "No usage data yet."
}
func conversationContextWindowUsage(conversation *clientpb.AIConversation) *clientpb.AIContextWindowUsage {
if conversation == nil {
return nil
}
usage := conversation.GetContextWindowUsage()
if usage == nil {
return nil
}
if usage.GetInputTokens() == 0 &&
usage.GetOutputTokens() == 0 &&
usage.GetTotalTokens() == 0 &&
usage.GetContextWindowTokens() == 0 &&
!usage.GetContextWindowTokensEstimated() {
return nil
}
return usage
}
func formatAICompactTokenCount(tokens int64) string {
switch {
case tokens >= 1000000:
return trimAITokenUnit(fmt.Sprintf("%.1fM", float64(tokens)/1000000))
case tokens >= 1000:
return trimAITokenUnit(fmt.Sprintf("%.1fk", float64(tokens)/1000))
default:
return fmt.Sprintf("%d", tokens)
}
}
func trimAITokenUnit(value string) string {
value = strings.Replace(value, ".0M", "M", 1)
value = strings.Replace(value, ".0k", "k", 1)
return value
}
func aiContextWindowFraction(usage *clientpb.AIContextWindowUsage) float64 {
if usage == nil || usage.GetContextWindowTokens() <= 0 || usage.GetTotalTokens() <= 0 {
return 0
}
return clampFloat64(float64(usage.GetTotalTokens())/float64(usage.GetContextWindowTokens()), 0, 1)
}
func formatAIContextWindowPercent(usage *clientpb.AIContextWindowUsage) string {
return fmt.Sprintf("%d%%", int(aiContextWindowFraction(usage)*100+0.5))
}
func aiContextWindowAccentColor(usage *clientpb.AIContextWindowUsage) color.Color {
fraction := aiContextWindowFraction(usage)
switch {
case fraction >= 0.9:
return clienttheme.Danger()
case fraction >= 0.75:
return clienttheme.Warning()
default:
return clienttheme.Primary()
}
}
func renderAIContextWindowBar(width int, usage *clientpb.AIContextWindowUsage) string {
if width <= 0 {
return ""
}
fraction := aiContextWindowFraction(usage)
filled := int(float64(width)*fraction + 0.5)
filled = clampInt(filled, 0, width)
empty := maxInt(0, width-filled)
fill := lipgloss.NewStyle().Foreground(aiContextWindowAccentColor(usage)).Render(strings.Repeat("█", filled))
background := lipgloss.NewStyle().Foreground(clienttheme.DefaultMod(300)).Render(strings.Repeat("░", empty))
return fill + background
}
func (m *aiModel) renderContextWindowUsageHeader(width int) string {
if width <= 0 {
return ""
}
usage := conversationContextWindowUsage(m.currentConversation)
if usage == nil {
return ansi.Cut(m.styles.subtleText.Render("ctx n/a"), 0, width)
}
if usage.GetContextWindowTokens() <= 0 {
label := "ctx " + formatAICompactTokenCount(usage.GetTotalTokens()) + " tok"
return ansi.Cut(m.styles.subtleText.Render(label), 0, width)
}
barWidth := clampInt(width/2, 6, 12)
prefix := "ctx "
if usage.GetContextWindowTokensEstimated() {
prefix = "ctx ~"
}
label := prefix + formatAICompactTokenCount(usage.GetTotalTokens()) + "/" + formatAICompactTokenCount(usage.GetContextWindowTokens())
piece := label + " " + renderAIContextWindowBar(barWidth, usage)
if lipgloss.Width(piece) <= width {
return piece
}
shortLabel := "ctx " + formatAIContextWindowPercent(usage)
if usage.GetContextWindowTokensEstimated() {
shortLabel = "ctx ~" + formatAIContextWindowPercent(usage)
}
piece = shortLabel + " " + renderAIContextWindowBar(barWidth, usage)
if lipgloss.Width(piece) <= width {
return piece
}
return ansi.Cut(shortLabel, 0, width)
}
func aiThinkingLevelOptionIndex(value string) int {
for idx, option := range aiThinkingLevelOptions("") {
if option.value == normalizeAIThinkingLevel(value) {
return idx
}
}
return 0
}
func aiThinkingLevelOptions(defaultThinkingLevel string) []aiThinkingLevelOption {
defaultDescription := "Use the server or provider default."
if defaultThinkingLevel = normalizeAIThinkingLevel(defaultThinkingLevel); defaultThinkingLevel != "" {
defaultDescription = "Use the server or provider default (" + defaultThinkingLevel + ")."
}
return []aiThinkingLevelOption{
{
label: "Provider default",
value: "",
description: defaultDescription,
},
{
label: "Low",
value: "low",
description: "Prefer faster responses with lighter reasoning effort.",
},
{
label: "Medium",
value: "medium",
description: "Balance speed and reasoning depth.",
},
{
label: "High",
value: "high",
description: "Prefer deeper reasoning for harder turns.",
},
{
label: "X-High",
value: "xhigh",
description: "Prefer the maximum supported reasoning effort.",
},
{
label: "Disabled",
value: "disabled",
description: "Do not request an explicit reasoning effort from the backend.",
},
}
}
func buildAITargetSelectionOptions(sessions []*clientpb.Session, beacons []*clientpb.Beacon, activeTarget aiTargetSummary) []aiTargetSelectionOption {
sessionList := make([]*clientpb.Session, 0, len(sessions))
for _, session := range sessions {
if session != nil {
sessionList = append(sessionList, session)
}
}
beaconList := make([]*clientpb.Beacon, 0, len(beacons))
for _, beacon := range beacons {
if beacon != nil {
beaconList = append(beaconList, beacon)
}
}
sort.SliceStable(sessionList, func(i, j int) bool {
return aiTargetSelectionSortKey(sessionList[i].GetName(), sessionList[i].GetHostname(), sessionList[i].GetID()) <
aiTargetSelectionSortKey(sessionList[j].GetName(), sessionList[j].GetHostname(), sessionList[j].GetID())
})
sort.SliceStable(beaconList, func(i, j int) bool {
return aiTargetSelectionSortKey(beaconList[i].GetName(), beaconList[i].GetHostname(), beaconList[i].GetID()) <
aiTargetSelectionSortKey(beaconList[j].GetName(), beaconList[j].GetHostname(), beaconList[j].GetID())
})
options := make([]aiTargetSelectionOption, 0, len(sessionList)+len(beaconList))
for _, session := range sessionList {
target := aiSessionTargetSummary(session)
options = append(options, aiTargetSelectionOption{
label: sessionTargetSelectionLabel(target),
metadata: []string{
aiTargetSelectionMetadataLine("ID "+shortenID(session.GetID()), fallback(target.Host, "<unknown host>"), fallback(target.OS, "unknown")+"/"+fallback(target.Arch, "unknown"), fallback(target.Mode, "interactive session")),
aiTargetSelectionMetadataLine("C2 "+fallback(target.C2, "unknown"), "User: "+fallback(session.GetUsername(), "<unknown>"), fmt.Sprintf("PID: %d", session.GetPID()), "Remote: "+fallback(session.GetRemoteAddress(), "<unknown>")),
},
target: target,
session: session,
active: sameAITargetSelectionIDs(session.GetID(), "", activeTarget.SessionID, activeTarget.BeaconID),
})
}
for _, beacon := range beaconList {
target := aiBeaconTargetSummary(beacon)
options = append(options, aiTargetSelectionOption{
label: beaconTargetSelectionLabel(target),
metadata: []string{
aiTargetSelectionMetadataLine("ID "+shortenID(beacon.GetID()), fallback(target.Host, "<unknown host>"), fallback(target.OS, "unknown")+"/"+fallback(target.Arch, "unknown"), fallback(target.Mode, "asynchronous beacon")),
aiTargetSelectionMetadataLine("C2 "+fallback(target.C2, "unknown"), "User: "+fallback(beacon.GetUsername(), "<unknown>"), fmt.Sprintf("PID: %d", beacon.GetPID()), "Remote: "+fallback(beacon.GetRemoteAddress(), "<unknown>")),
aiTargetSelectionMetadataLine(fmt.Sprintf("Interval: %s", time.Duration(beacon.GetInterval()).String()), "Next checkin: "+formatUnix(beacon.GetNextCheckin())),
},
target: target,
beacon: beacon,
active: sameAITargetSelectionIDs("", beacon.GetID(), activeTarget.SessionID, activeTarget.BeaconID),
})
}
return options
}
func aiTargetSelectionSortKey(name, host, id string) string {
return strings.ToLower(strings.TrimSpace(name) + "\x00" + strings.TrimSpace(host) + "\x00" + strings.TrimSpace(id))
}
func sessionTargetSelectionLabel(target aiTargetSummary) string {
return fallback(target.Label, "Session "+fallback(target.SessionID, "<unknown>"))
}
func beaconTargetSelectionLabel(target aiTargetSummary) string {
return fallback(target.Label, "Beacon "+fallback(target.BeaconID, "<unknown>"))
}
func aiTargetSelectionMetadataLine(parts ...string) string {
filtered := make([]string, 0, len(parts))
for _, part := range parts {
if text := strings.TrimSpace(part); text != "" {
filtered = append(filtered, text)
}
}
return strings.Join(filtered, " | ")
}
func aiTargetSelectionOptionIndex(options []aiTargetSelectionOption, activeTarget aiTargetSummary, conversationTargetSessionID string, conversationTargetBeaconID string) int {
activeSessionID, activeBeaconID := normalizedAITargetIDs(activeTarget.SessionID, activeTarget.BeaconID)
for idx, option := range options {
optionSessionID, optionBeaconID := normalizedAITargetIDs(option.target.SessionID, option.target.BeaconID)
if sameAITargetSelectionIDs(optionSessionID, optionBeaconID, activeSessionID, activeBeaconID) {
return idx
}
}
conversationTargetSessionID, conversationTargetBeaconID = normalizedAITargetIDs(conversationTargetSessionID, conversationTargetBeaconID)
for idx, option := range options {
optionSessionID, optionBeaconID := normalizedAITargetIDs(option.target.SessionID, option.target.BeaconID)
if sameAITargetSelectionIDs(optionSessionID, optionBeaconID, conversationTargetSessionID, conversationTargetBeaconID) {
return idx
}
}
return 0
}
func targetSelectionVisibleRange(blocks [][]string, maxLines int, selected int) (int, int) {
if len(blocks) == 0 || maxLines <= 0 {
return 0, 0
}
selected = clampInt(selected, 0, len(blocks)-1)
start, end := selected, selected+1
used := len(blocks[selected])
for {
extended := false
if start > 0 && used+len(blocks[start-1]) <= maxLines {
start--
used += len(blocks[start])
extended = true
}
if end < len(blocks) && used+len(blocks[end]) <= maxLines {
used += len(blocks[end])
end++
extended = true
}
if !extended {
break
}
}
return start, end
}
func conversationSubtitle(conversation *clientpb.AIConversation) string {
if conversation == nil {
return ""
}
parts := []string{}
if provider := strings.TrimSpace(conversation.GetProvider()); provider != "" {
parts = append(parts, provider)
}
if model := strings.TrimSpace(conversation.GetModel()); model != "" {
parts = append(parts, model)
}
if count := conversationMessageCount(conversation); count > 0 {
parts = append(parts, fmt.Sprintf("%d messages", count))
}
if updated := formatUnix(conversation.GetUpdatedAt()); updated != "<unknown>" {
parts = append(parts, "updated "+updated)
}
if len(parts) == 0 {
return "No provider or message metadata yet."
}
return strings.Join(parts, " | ")
}
func conversationListLabel(conversation *clientpb.AIConversation, width int) string {
title := conversationTitle(conversation)
if provider := strings.TrimSpace(conversation.GetProvider()); provider != "" {
title += " [" + provider + "]"
}
return truncateText(title, width)
}
func cloneConversation(conversation *clientpb.AIConversation) *clientpb.AIConversation {
if conversation == nil {
return nil
}
cloned, ok := proto.Clone(conversation).(*clientpb.AIConversation)
if !ok {
return nil
}
return cloned
}
func cloneConversationMessage(message *clientpb.AIConversationMessage) *clientpb.AIConversationMessage {
if message == nil {
return nil
}
cloned, ok := proto.Clone(message).(*clientpb.AIConversationMessage)
if !ok {
return nil
}
return cloned
}
func cloneAIContextWindowUsage(usage *clientpb.AIContextWindowUsage) *clientpb.AIContextWindowUsage {
if usage == nil {
return nil
}
cloned, ok := proto.Clone(usage).(*clientpb.AIContextWindowUsage)
if !ok {
return nil
}
return cloned
}
func conversationSaveRequest(conversation *clientpb.AIConversation) *clientpb.AIConversation {
if conversation == nil {
return &clientpb.AIConversation{}
}
return &clientpb.AIConversation{
ID: strings.TrimSpace(conversation.GetID()),
OperatorName: strings.TrimSpace(conversation.GetOperatorName()),
Provider: strings.TrimSpace(conversation.GetProvider()),
Model: strings.TrimSpace(conversation.GetModel()),
ThinkingLevel: normalizeAIThinkingLevel(conversation.GetThinkingLevel()),
Title: strings.TrimSpace(conversation.GetTitle()),
Summary: strings.TrimSpace(conversation.GetSummary()),
SystemPrompt: strings.TrimSpace(conversation.GetSystemPrompt()),
ActiveTurnID: strings.TrimSpace(conversation.GetActiveTurnID()),
TurnState: conversation.GetTurnState(),
TargetSessionID: strings.TrimSpace(conversation.GetTargetSessionID()),
TargetBeaconID: strings.TrimSpace(conversation.GetTargetBeaconID()),
ContextWindowUsage: cloneAIContextWindowUsage(conversation.GetContextWindowUsage()),
}
}
func normalizedAITargetIDs(sessionID, beaconID string) (string, string) {
sessionID = strings.TrimSpace(sessionID)
beaconID = strings.TrimSpace(beaconID)
switch {
case sessionID != "":
return sessionID, ""
case beaconID != "":
return "", beaconID
default:
return "", ""
}
}
func sameAITargetSelectionIDs(leftSessionID, leftBeaconID, rightSessionID, rightBeaconID string) bool {
leftSessionID, leftBeaconID = normalizedAITargetIDs(leftSessionID, leftBeaconID)
rightSessionID, rightBeaconID = normalizedAITargetIDs(rightSessionID, rightBeaconID)
return leftSessionID == rightSessionID && leftBeaconID == rightBeaconID
}
func sameAITargetSelectionSummary(left aiTargetSummary, right aiTargetSummary) bool {
return sameAITargetSelectionIDs(left.SessionID, left.BeaconID, right.SessionID, right.BeaconID)
}
func conversationUsesTarget(conversation *clientpb.AIConversation, target aiTargetSummary) bool {
if conversation == nil {
return false
}
return sameAITargetSelectionIDs(conversation.GetTargetSessionID(), conversation.GetTargetBeaconID(), target.SessionID, target.BeaconID)
}
func (m *aiModel) applyTargetSelectionOption(option aiTargetSelectionOption) {
sessionID, beaconID := normalizedAITargetIDs(option.target.SessionID, option.target.BeaconID)
m.ctx.target = option.target
m.ctx.target.SessionID = sessionID
m.ctx.target.BeaconID = beaconID
if m.con != nil && m.con.ActiveTarget != nil {
m.con.ActiveTarget.Set(option.session, option.beacon)
}
}
func normalizedConversationOperatorName(name string) string {
name = strings.TrimSpace(name)
switch strings.ToLower(name) {
case "", "unknown", "<unknown>", "<unknown operator>", "<disconnected>":
return ""
default:
return name
}
}
func sameConversationMessage(left *clientpb.AIConversationMessage, right *clientpb.AIConversationMessage) bool {
if left == nil || right == nil {
return false
}
leftID := strings.TrimSpace(left.GetID())
rightID := strings.TrimSpace(right.GetID())
if leftID != "" && rightID != "" && leftID == rightID {
return true
}
leftItemID := strings.TrimSpace(left.GetItemID())
rightItemID := strings.TrimSpace(right.GetItemID())
return leftItemID != "" && rightItemID != "" && leftItemID == rightItemID
}
func sortConversationMessages(messages []*clientpb.AIConversationMessage) {
sort.SliceStable(messages, func(i, j int) bool {
left := messages[i]
right := messages[j]
if left == nil || right == nil {
return left != nil
}
if left.GetSequence() != right.GetSequence() {
return left.GetSequence() < right.GetSequence()
}
leftCreated := maxInt64(left.GetCreatedAt(), left.GetUpdatedAt())
rightCreated := maxInt64(right.GetCreatedAt(), right.GetUpdatedAt())
if leftCreated != rightCreated {
return leftCreated < rightCreated
}
return strings.TrimSpace(left.GetID()) < strings.TrimSpace(right.GetID())
})
}
func mergeConversationMetadata(dst *clientpb.AIConversation, src *clientpb.AIConversation) {
if dst == nil || src == nil {
return
}
if strings.TrimSpace(dst.GetID()) == "" {
dst.ID = src.GetID()
}
if dst.GetCreatedAt() == 0 && src.GetCreatedAt() != 0 {
dst.CreatedAt = src.GetCreatedAt()
}
if src.GetUpdatedAt() > dst.GetUpdatedAt() {
dst.UpdatedAt = src.GetUpdatedAt()
}
if operator := strings.TrimSpace(src.GetOperatorName()); operator != "" {
dst.OperatorName = operator
}
if provider := strings.TrimSpace(src.GetProvider()); provider != "" {
dst.Provider = provider
}
if model := strings.TrimSpace(src.GetModel()); model != "" {
dst.Model = model
}
dst.ThinkingLevel = normalizeAIThinkingLevel(src.GetThinkingLevel())
if title := strings.TrimSpace(src.GetTitle()); title != "" {
dst.Title = title
}
if summary := strings.TrimSpace(src.GetSummary()); summary != "" {
dst.Summary = summary
}
if systemPrompt := strings.TrimSpace(src.GetSystemPrompt()); systemPrompt != "" {
dst.SystemPrompt = systemPrompt
}
dst.ActiveTurnID = strings.TrimSpace(src.GetActiveTurnID())
dst.TurnState = src.GetTurnState()
if targetSessionID := strings.TrimSpace(src.GetTargetSessionID()); targetSessionID != "" {
dst.TargetSessionID = targetSessionID
}
if targetBeaconID := strings.TrimSpace(src.GetTargetBeaconID()); targetBeaconID != "" {
dst.TargetBeaconID = targetBeaconID
}
if src.GetContextWindowUsage() != nil {
dst.ContextWindowUsage = cloneAIContextWindowUsage(src.GetContextWindowUsage())
}
}
func conversationAwaitingResponse(conversation *clientpb.AIConversation) bool {
if conversation == nil {
return false
}
if conversation.GetTurnState() == clientpb.AIConversationTurnState_AI_TURN_STATE_IN_PROGRESS {
return true
}
if conversation.GetTurnState() == clientpb.AIConversationTurnState_AI_TURN_STATE_FAILED {
return false
}
message := lastContextConversationMessage(conversation)
if message == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(message.GetRole()), "user")
}
func lastContextConversationMessage(conversation *clientpb.AIConversation) *clientpb.AIConversationMessage {
if conversation == nil {
return nil
}
messages := conversation.GetMessages()
for i := len(messages) - 1; i >= 0; i-- {
if messages[i] == nil {
continue
}
if !clientpbutil.AIConversationMessageIncludesContext(messages[i]) {
continue
}
if messages[i].GetKind() != clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_CHAT {
continue
}
return messages[i]
}
return nil
}
func lastConversationMessage(conversation *clientpb.AIConversation) *clientpb.AIConversationMessage {
if conversation == nil {
return nil
}
messages := conversation.GetMessages()
for i := len(messages) - 1; i >= 0; i-- {
if messages[i] != nil {
return messages[i]
}
}
return nil
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func providerDisplay(provider *clientpb.AIProviderConfig) string {
if provider == nil {
return "<unknown provider>"
}
status := "not configured"
if provider.GetConfigured() {
status = "configured"
}
return fmt.Sprintf("%s: %s", fallback(provider.GetName(), "<unnamed>"), status)
}
func safeAIConfigSummary(resp *clientpb.AIProviderConfigs) *clientpb.AIConfigSummary {
if resp == nil || resp.GetConfig() == nil {
return &clientpb.AIConfigSummary{
Error: "server did not return AI configuration status; update the server and try again",
}
}
return resp.GetConfig()
}
func aiConfigError(config *clientpb.AIConfigSummary) string {
if config == nil {
return "server AI configuration is unavailable"
}
if err := strings.TrimSpace(config.GetError()); err != "" {
return err
}
return "server AI configuration is invalid"
}
func conversationMessageMarkdown(conversation *clientpb.AIConversation, message *clientpb.AIConversationMessage, meta []string) string {
label := escapeMarkdownText(messageBlockLabel(conversation, message))
content := ""
if message != nil {
content = strings.Trim(formatConversationMessageMarkdownContent(message), "\n")
}
if strings.TrimSpace(content) == "" {
content = "_Empty message._"
}
var markdown strings.Builder
markdown.WriteString("### ")
markdown.WriteString(label)
markdown.WriteString("\n\n")
if len(meta) > 0 {
markdown.WriteString("> ")
markdown.WriteString(escapeMarkdownText(strings.Join(meta, " | ")))
markdown.WriteString("\n\n")
}
markdown.WriteString(content)
return markdown.String()
}
func formatConversationMessageMarkdownContent(message *clientpb.AIConversationMessage) string {
if message == nil {
return ""
}
switch message.GetKind() {
case clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL:
parts := []string{}
if args := renderToolCallStructuredMarkdown(message.GetToolArguments()); args != "" {
parts = append(parts, "Arguments:\n"+args)
}
if result := renderToolCallStructuredMarkdown(message.GetToolResult()); result != "" {
parts = append(parts, "Result:\n"+result)
}
if errText := strings.TrimSpace(message.GetErrorText()); errText != "" {
parts = append(parts, "Error:\n"+errText)
}
return joinTextBlocks(parts)
default:
return message.GetContent()
}
}
func messageBlockLabel(conversation *clientpb.AIConversation, message *clientpb.AIConversationMessage) string {
if message != nil {
switch message.GetKind() {
case clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING:
return "Reasoning"
case clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL:
if toolName := strings.TrimSpace(message.GetToolName()); toolName != "" {
return "Tool: " + toolName
}
return "Tool Call"
}
}
role := ""
if message != nil {
role = strings.ToLower(strings.TrimSpace(message.GetRole()))
}
switch role {
case "user":
if message != nil {
if operatorName := normalizedConversationOperatorName(message.GetOperatorName()); operatorName != "" {
return operatorName
}
}
if conversation != nil {
if operatorName := normalizedConversationOperatorName(conversation.GetOperatorName()); operatorName != "" {
return operatorName
}
}
return "User"
case "assistant":
return "AI"
case "system":
return "System"
default:
if role == "" {
return "Message"
}
return strings.ToUpper(role[:1]) + role[1:]
}
}
func messageBlockRole(message *clientpb.AIConversationMessage) string {
if message == nil {
return ""
}
switch message.GetKind() {
case clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING:
return "reasoning"
case clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL:
return "tool"
default:
return strings.ToLower(strings.TrimSpace(message.GetRole()))
}
}
func conversationMessageStateLabel(message *clientpb.AIConversationMessage) string {
if message == nil {
return ""
}
switch message.GetState() {
case clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS:
return "in progress"
case clientpb.AIConversationMessageState_AI_MESSAGE_STATE_FAILED:
return "failed"
default:
if message.GetKind() == clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL ||
message.GetKind() == clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING {
return "completed"
}
return ""
}
}
func joinTextBlocks(blocks []string) string {
filtered := make([]string, 0, len(blocks))
for _, block := range blocks {
block = strings.TrimSpace(block)
if block == "" {
continue
}
filtered = append(filtered, block)
}
return strings.Join(filtered, "\n\n")
}
func escapeMarkdownText(text string) string {
replacer := strings.NewReplacer(
"\\", "\\\\",
"`", "\\`",
"*", "\\*",
"_", "\\_",
"[", "\\[",
"]", "\\]",
"#", "\\#",
"|", "\\|",
">", "\\>",
)
return replacer.Replace(text)
}
func promptConversationTitle(prompt string) string {
for _, line := range strings.Split(prompt, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
return truncateText(line, 48)
}
return "New conversation"
}
type transcriptSpeakerStyles struct {
border lipgloss.Style
label lipgloss.Style
meta lipgloss.Style
}
func renderConversationTranscriptLines(width int, conversation *clientpb.AIConversation) []string {
return transcriptContentStyledLines(renderConversationTranscript(width, conversation))
}
func renderConversationTranscript(width int, conversation *clientpb.AIConversation) []aiTranscriptContentLine {
if width <= 0 {
return nil
}
if conversation == nil {
return renderTranscriptSectionBlockContent(width, "Conversation", "system", nil, wrapText("Create a new conversation or submit a prompt to start one.", maxInt(1, width)))
}
lines := []aiTranscriptContentLine{}
if summary := strings.TrimSpace(conversation.GetSummary()); summary != "" {
lines = appendTranscriptContentBlock(lines, renderTranscriptSectionBlockContent(width, "Summary", "system", nil, wrapText(summary, maxInt(1, width))))
}
messageCount := 0
for _, message := range conversationMessagesForDisplay(conversation) {
if message == nil {
continue
}
messageCount++
meta := []string{}
if ts := formatUnix(message.GetCreatedAt()); ts != "<unknown>" {
meta = append(meta, ts)
}
if provider := strings.TrimSpace(message.GetProvider()); provider != "" {
meta = append(meta, provider)
}
if model := strings.TrimSpace(message.GetModel()); model != "" {
meta = append(meta, model)
}
lines = appendTranscriptContentBlock(lines, renderTranscriptBoxBlockContent(
width,
messageBlockLabel(conversation, message),
messageBlockRole(message),
meta,
renderConversationMessageBodyLines(maxInt(1, width-4), message),
true,
))
}
if messageCount == 0 {
lines = appendTranscriptContentBlock(lines, renderTranscriptSectionBlockContent(width, "Conversation", "system", nil, wrapText("No messages yet. Type a prompt below to start this thread.", maxInt(1, width))))
}
return lines
}
func renderConversationMessageBlockLines(width int, conversation *clientpb.AIConversation, message *clientpb.AIConversationMessage) []string {
meta := []string{}
if ts := formatUnix(message.GetCreatedAt()); ts != "<unknown>" {
meta = append(meta, ts)
}
if state := conversationMessageStateLabel(message); state != "" {
meta = append(meta, state)
}
if provider := strings.TrimSpace(message.GetProvider()); provider != "" {
meta = append(meta, provider)
}
if model := strings.TrimSpace(message.GetModel()); model != "" {
meta = append(meta, model)
}
return renderTranscriptBoxBlock(
width,
messageBlockLabel(conversation, message),
messageBlockRole(message),
meta,
renderConversationMessageBodyLines(maxInt(1, width-4), message),
)
}
func renderConversationMessageBodyLines(width int, message *clientpb.AIConversationMessage) []string {
if message == nil {
return nil
}
switch message.GetKind() {
case clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_REASONING:
return renderTranscriptMarkdownBodyLines(width, message.GetContent(), "Reasoning was used, but the provider did not return a visible summary.")
case clientpb.AIConversationMessageKind_AI_MESSAGE_KIND_TOOL_CALL:
return renderToolCallBodyLines(width, message)
default:
return renderTranscriptMessageBodyLines(width, message.GetContent())
}
}
func renderTranscriptMessageBodyLines(width int, content string) []string {
return renderTranscriptMarkdownBodyLines(width, content, "Empty message.")
}
func renderTranscriptMarkdownBodyLines(width int, content string, empty string) []string {
content = strings.Trim(content, "\n")
if strings.TrimSpace(content) == "" {
return []string{empty}
}
rendered, err := renderMarkdownWithGlow(maxInt(8, width), content)
if err != nil {
return wrapText(content, width)
}
rendered = strings.Trim(rendered, "\n")
if strings.TrimSpace(rendered) == "" {
return wrapText(content, width)
}
return trimRenderedLines(strings.Split(rendered, "\n"))
}
func renderTranscriptPlainBodyLines(width int, content string, empty string) []string {
content = strings.Trim(content, "\n")
if strings.TrimSpace(content) == "" {
return []string{empty}
}
rawLines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
lines := make([]string, 0, len(rawLines))
for _, rawLine := range rawLines {
if rawLine == "" {
lines = append(lines, "")
continue
}
lines = append(lines, wrapText(rawLine, maxInt(1, width))...)
}
if len(lines) == 0 {
return []string{empty}
}
return lines
}
func renderToolCallBodyLines(width int, message *clientpb.AIConversationMessage) []string {
if message == nil {
return []string{"Tool call details unavailable."}
}
lines := []string{}
addSection := func(title string, body string, fallback string, structured bool) {
body = strings.TrimSpace(body)
if body == "" && fallback == "" {
return
}
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, strings.ToUpper(title))
lines = append(lines, renderToolCallSectionBodyLines(width, body, fallback, structured)...)
}
addSection("Arguments", message.GetToolArguments(), "No arguments.", true)
if errText := strings.TrimSpace(message.GetErrorText()); errText != "" {
addSection("Error", errText, "", false)
}
if result := strings.TrimSpace(message.GetToolResult()); result != "" {
addSection("Result", result, "", true)
} else if message.GetState() == clientpb.AIConversationMessageState_AI_MESSAGE_STATE_IN_PROGRESS {
addSection("Result", "", "Waiting for tool output...", true)
}
if len(lines) == 0 {
return []string{"Tool call details unavailable."}
}
return lines
}
func renderToolCallSectionBodyLines(width int, body string, fallback string, structured bool) []string {
if strings.TrimSpace(body) == "" {
return renderTranscriptPlainBodyLines(width, "", fallback)
}
if !structured {
return renderTranscriptPlainBodyLines(width, body, fallback)
}
return renderTranscriptMarkdownBodyLines(width, renderToolCallStructuredMarkdown(body), fallback)
}
type toolCallStructuredBody struct {
content string
language string
hiddenBytes int
}
func formatToolCallStructuredBody(raw string) toolCallStructuredBody {
raw = strings.TrimSpace(raw)
if raw == "" {
return toolCallStructuredBody{}
}
body := toolCallStructuredBody{
content: raw,
language: "text",
}
if pretty, ok := prettyJSONBlock(raw); ok {
body.content = pretty
body.language = "json"
}
body.content, body.hiddenBytes = truncateToolCallContentLines(body.content, aiToolCallSectionMaxLines)
return body
}
func renderToolCallStructuredMarkdown(raw string) string {
body := formatToolCallStructuredBody(raw)
if body.content == "" {
return ""
}
markdown := renderMarkdownCodeBlock(body.language, body.content)
if body.hiddenBytes > 0 {
markdown += "\n\n" + renderMarkdownItalicLine(toolCallHiddenBytesMarker(body.hiddenBytes))
}
return markdown
}
func renderMarkdownCodeBlock(language string, content string) string {
language = strings.TrimSpace(language)
if language == "" {
return "```\n" + content + "\n```"
}
return "```" + language + "\n" + content + "\n```"
}
func renderMarkdownItalicLine(content string) string {
content = strings.TrimSpace(content)
if content == "" {
return ""
}
return "_" + escapeMarkdownText(content) + "_"
}
func formatStructuredBlock(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if pretty, ok := prettyJSONBlock(raw); ok {
return pretty
}
return raw
}
func prettyJSONBlock(raw string) (string, bool) {
var pretty bytes.Buffer
if err := json.Indent(&pretty, []byte(raw), "", " "); err != nil {
return "", false
}
return pretty.String(), true
}
func truncateToolCallContentLines(content string, maxLines int) (string, int) {
content = strings.Trim(content, "\n")
if maxLines <= 0 || content == "" {
return content, 0
}
lines := strings.Split(content, "\n")
if len(lines) <= maxLines {
return content, 0
}
truncated := strings.Join(lines[:maxLines], "\n")
hiddenBytes := len(content) - len(truncated)
if hiddenBytes < 0 {
hiddenBytes = 0
}
return truncated, hiddenBytes
}
func toolCallHiddenBytesMarker(hiddenBytes int) string {
if hiddenBytes < 0 {
hiddenBytes = 0
}
return fmt.Sprintf("... (%d bytes) ...", hiddenBytes)
}
func renderTranscriptBoxBlock(width int, label, role string, meta []string, contentLines []string) []string {
return transcriptContentStyledLines(renderTranscriptBoxBlockContent(width, label, role, meta, contentLines, true))
}
func renderTranscriptBoxBlockContent(width int, label, role string, meta []string, contentLines []string, selectable bool) []aiTranscriptContentLine {
if width <= 0 {
return nil
}
styles := transcriptSpeakerStyle(label, role)
label = fallback(label, "Message")
if len(contentLines) == 0 {
contentLines = []string{""}
}
pieces := []string{styles.label.Render(label)}
if metaText := strings.Join(meta, " | "); strings.TrimSpace(metaText) != "" {
pieces = append(pieces, styles.meta.Render(metaText))
}
header := fitStyledPieces(maxInt(1, width-3), pieces)
lines := []aiTranscriptContentLine{{styled: renderTranscriptBoxTopLine(width, styles.border, header)}}
for _, line := range contentLines {
text := ansi.Strip(line)
text = strings.TrimRight(text, " ")
prefix, paddedContent, contentWidth := renderTranscriptBoxContentParts(width, styles.border, line)
rendered := aiTranscriptContentLine{styled: prefix + paddedContent}
if selectable && ansi.StringWidth(text) > 0 {
rendered.selectableStart = ansi.StringWidth(prefix)
rendered.selectableText = text
rendered.selectablePrefix = prefix
rendered.selectableAreaWidth = contentWidth
}
lines = append(lines, rendered)
}
lines = append(lines, aiTranscriptContentLine{styled: renderTranscriptBoxBottomLine(width, styles.border)})
return lines
}
func renderTranscriptSectionBlock(width int, label, role string, meta []string, contentLines []string) []string {
return transcriptContentStyledLines(renderTranscriptSectionBlockContent(width, label, role, meta, contentLines))
}
func renderTranscriptSectionBlockContent(width int, label, role string, meta []string, contentLines []string) []aiTranscriptContentLine {
if width <= 0 {
return nil
}
styles := transcriptSpeakerStyle(label, role)
label = fallback(label, "Message")
if len(contentLines) == 0 {
contentLines = []string{""}
}
pieces := []string{styles.label.Render(label)}
if metaText := strings.Join(meta, " | "); strings.TrimSpace(metaText) != "" {
pieces = append(pieces, styles.meta.Render(metaText))
}
lines := []aiTranscriptContentLine{{styled: padANSIRight(fitStyledPieces(width, pieces), width)}}
for _, line := range contentLines {
lines = append(lines, aiTranscriptContentLine{styled: padANSIRight(line, width)})
}
return lines
}
func transcriptSpeakerStyle(label, role string) transcriptSpeakerStyles {
label = strings.TrimSpace(label)
role = strings.ToLower(strings.TrimSpace(role))
if role == "user" {
accent := clienttheme.Primary()
return transcriptSpeakerStyles{
border: lipgloss.NewStyle().Foreground(accent),
label: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(accent).
Padding(0, 1),
meta: lipgloss.NewStyle().Foreground(accent),
}
}
if role == "tool" || role == "reasoning" {
accent := clienttheme.DefaultMod(600)
return transcriptSpeakerStyles{
border: lipgloss.NewStyle().Foreground(accent),
label: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(50)).
Background(clienttheme.DefaultMod(700)).
Padding(0, 1),
meta: lipgloss.NewStyle().Foreground(clienttheme.DefaultMod(500)),
}
}
if role == "system" && strings.EqualFold(label, "Conversation") {
accent := clienttheme.PrimaryMod(200)
return transcriptSpeakerStyles{
border: lipgloss.NewStyle().Foreground(accent),
label: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(accent).
Padding(0, 1),
meta: lipgloss.NewStyle().Foreground(accent),
}
}
if role == "system" && strings.EqualFold(label, "System") {
accent := clienttheme.Secondary()
return transcriptSpeakerStyles{
border: lipgloss.NewStyle().Foreground(accent),
label: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(accent).
Padding(0, 1),
meta: lipgloss.NewStyle().Foreground(accent),
}
}
accent := transcriptSpeakerPalette()[transcriptSpeakerPaletteIndex(label, role)]
borderColor := accent
headerColor := accent
if role == "user" {
borderColor = clienttheme.Primary()
headerColor = clienttheme.Primary()
}
return transcriptSpeakerStyles{
border: lipgloss.NewStyle().Foreground(borderColor),
label: lipgloss.NewStyle().
Bold(true).
Foreground(clienttheme.DefaultMod(900)).
Background(headerColor).
Padding(0, 1),
meta: lipgloss.NewStyle().Foreground(headerColor),
}
}
func renderTranscriptBoxTopLine(width int, border lipgloss.Style, header string) string {
if width <= 0 {
return ""
}
if width == 1 {
return border.Render("╭")
}
if width == 2 {
return border.Render("╭─")
}
line := border.Render("╭─")
if strings.TrimSpace(ansi.Strip(header)) != "" {
line += " " + header
}
return padANSIRight(line, width)
}
func renderTranscriptBoxBottomLine(width int, border lipgloss.Style) string {
if width <= 0 {
return ""
}
if width == 1 {
return border.Render("╰")
}
return padANSIRight(border.Render("╰─"), width)
}
func renderTranscriptBoxContentLine(width int, border lipgloss.Style, content string) string {
prefix, paddedContent, _ := renderTranscriptBoxContentParts(width, border, content)
return prefix + paddedContent
}
func renderTranscriptBoxContentParts(width int, border lipgloss.Style, content string) (string, string, int) {
if width <= 0 {
return "", "", 0
}
if width == 1 {
return border.Render("│"), "", 0
}
contentWidth := width - 2
return border.Render("│ "), padANSIRight(content, contentWidth), contentWidth
}
func transcriptSpeakerPalette() []color.Color {
return []color.Color{
clienttheme.Primary(),
clienttheme.Secondary(),
clienttheme.Warning(),
clienttheme.Danger(),
clienttheme.DefaultMod(700),
clienttheme.PrimaryMod(700),
clienttheme.SecondaryMod(700),
}
}
func transcriptSpeakerPaletteIndex(label, role string) int {
role = strings.ToLower(strings.TrimSpace(role))
switch role {
case "assistant":
return 1
case "system":
return 4
}
label = strings.TrimSpace(strings.ToLower(label))
if label == "" {
label = role
}
hash := fnv.New32a()
_, _ = hash.Write([]byte(label))
palette := transcriptSpeakerPalette()
return int(hash.Sum32() % uint32(len(palette)))
}
func shortenID(id string) string {
id = strings.TrimSpace(id)
if id == "" {
return ""
}
parts := strings.SplitN(id, "-", 2)
return parts[0]
}
func inputWindow(input []rune, cursor, width int) ([]rune, int) {
if width <= 0 {
return nil, 0
}
if cursor < 0 {
cursor = 0
}
if cursor > len(input) {
cursor = len(input)
}
start := 0
if len(input) > width {
start = cursor - width + 1
if start < 0 {
start = 0
}
if start > len(input)-width {
start = len(input) - width
}
}
end := minInt(len(input), start+width)
return input[start:end], cursor - start
}
func transcriptContentStyledLines(content []aiTranscriptContentLine) []string {
lines := make([]string, 0, len(content))
for _, line := range content {
lines = append(lines, line.styled)
}
return lines
}
func appendTranscriptContentBlock(content []aiTranscriptContentLine, block []aiTranscriptContentLine) []aiTranscriptContentLine {
if len(block) == 0 {
return content
}
if len(content) > 0 {
content = append(content, aiTranscriptContentLine{styled: ""})
}
return append(content, block...)
}
func (m *aiModel) clearTranscriptSelection() {
m.transcriptSelection = nil
}
func (m *aiModel) beginTranscriptSelection(x, y int) {
point, ok := m.transcriptSelectionPointAt(x, y, false)
if !ok {
m.clearTranscriptSelection()
return
}
m.transcriptSelection = &aiTranscriptSelection{
anchor: point,
active: point,
dragging: true,
}
}
func (m *aiModel) updateTranscriptSelection(x, y int, clamp bool) {
if m.transcriptSelection == nil {
return
}
point, ok := m.transcriptSelectionPointAt(x, y, clamp)
if !ok {
return
}
m.transcriptSelection.active = point
}
func (m *aiModel) transcriptSelectionPointAt(x, y int, clamp bool) (aiTranscriptSelectionPoint, bool) {
rect := m.currentPaneRects().transcript
if !rect.contains(x, y) {
return aiTranscriptSelectionPoint{}, false
}
contentWidth, viewportHeight := m.currentTranscriptViewportSize()
if contentWidth <= 0 || viewportHeight <= 0 {
return aiTranscriptSelectionPoint{}, false
}
innerX := x - rect.x - 2
innerY := y - rect.y - 1
if innerX < 0 || innerY < m.transcriptHeaderLineCount() {
return aiTranscriptSelectionPoint{}, false
}
bodyY := innerY - m.transcriptHeaderLineCount()
if bodyY < 0 || bodyY >= viewportHeight {
return aiTranscriptSelectionPoint{}, false
}
content := m.renderTranscriptDisplayContent(contentWidth)
scroll, end := m.currentTranscriptScrollRange(len(content), viewportHeight)
lineIndex := scroll + bodyY
if lineIndex < scroll || lineIndex >= end || lineIndex >= len(content) {
return aiTranscriptSelectionPoint{}, false
}
line := content[lineIndex]
if !line.hasSelectableText() {
return aiTranscriptSelectionPoint{}, false
}
col := innerX - line.selectableStart
if col < 0 {
if !clamp {
return aiTranscriptSelectionPoint{}, false
}
col = 0
}
width := line.selectableWidth()
if width <= 0 {
return aiTranscriptSelectionPoint{}, false
}
if col >= width {
if !clamp {
return aiTranscriptSelectionPoint{}, false
}
col = width - 1
}
return aiTranscriptSelectionPoint{line: lineIndex, col: col}, true
}
func (m *aiModel) renderVisibleTranscriptLine(content []aiTranscriptContentLine, idx int) string {
if idx < 0 || idx >= len(content) {
return ""
}
line := content[idx]
if m.transcriptSelection == nil || !line.hasSelectableText() {
return line.styled
}
start, end, ok := m.transcriptSelectionRangeForLine(idx, line.selectableWidth())
if !ok {
return line.styled
}
if line.selectablePrefix != "" && line.selectableAreaWidth > 0 {
return line.selectablePrefix + padANSIRight(
renderSelectedTranscriptText(line.selectableText, start, end, m.styles.selection),
line.selectableAreaWidth,
)
}
return highlightANSIRange(line.styled, line.selectableStart+start, line.selectableStart+end, m.styles.selection)
}
func (m *aiModel) transcriptSelectionRangeForLine(lineIdx, lineWidth int) (int, int, bool) {
if m.transcriptSelection == nil || lineWidth <= 0 {
return 0, 0, false
}
start, end := normalizeTranscriptSelection(m.transcriptSelection.anchor, m.transcriptSelection.active)
if lineIdx < start.line || lineIdx > end.line {
return 0, 0, false
}
lineStart := 0
if lineIdx == start.line {
lineStart = clampInt(start.col, 0, lineWidth-1)
}
lineEnd := lineWidth
if lineIdx == end.line {
lineEnd = clampInt(end.col+1, 1, lineWidth)
}
if lineStart >= lineEnd {
return 0, 0, false
}
return lineStart, lineEnd, true
}
func (m *aiModel) selectedTranscriptText() string {
if m.transcriptSelection == nil {
return ""
}
contentWidth, _ := m.currentTranscriptViewportSize()
if contentWidth <= 0 {
return ""
}
content := m.renderTranscriptDisplayContent(contentWidth)
if len(content) == 0 {
return ""
}
start, end := normalizeTranscriptSelection(m.transcriptSelection.anchor, m.transcriptSelection.active)
start.line = clampInt(start.line, 0, len(content)-1)
end.line = clampInt(end.line, 0, len(content)-1)
lines := make([]string, 0, end.line-start.line+1)
for idx := start.line; idx <= end.line; idx++ {
line := content[idx]
if !line.hasSelectableText() {
continue
}
lineStart, lineEnd, ok := m.transcriptSelectionRangeForLine(idx, line.selectableWidth())
if !ok {
continue
}
lines = append(lines, ansi.Cut(line.selectableText, lineStart, lineEnd))
}
return strings.Join(lines, "\n")
}
func (m *aiModel) isCollapsedTranscriptSelection() bool {
if m.transcriptSelection == nil {
return true
}
start, end := normalizeTranscriptSelection(m.transcriptSelection.anchor, m.transcriptSelection.active)
return start.line == end.line && start.col == end.col
}
func normalizeTranscriptSelection(a, b aiTranscriptSelectionPoint) (aiTranscriptSelectionPoint, aiTranscriptSelectionPoint) {
if a.line > b.line || (a.line == b.line && a.col > b.col) {
return b, a
}
return a, b
}
func highlightANSIRange(line string, start, end int, style lipgloss.Style) string {
if start < 0 {
start = 0
}
if end <= start {
return line
}
prefix := ansi.Cut(line, 0, start)
selected := ansi.Cut(line, start, end)
if ansi.StringWidth(selected) == 0 {
return line
}
suffix := ansi.Cut(line, end, ansi.StringWidth(line))
return prefix + style.Render(selected) + suffix
}
func renderSelectedTranscriptText(text string, start, end int, style lipgloss.Style) string {
if start < 0 {
start = 0
}
if end <= start {
return text
}
width := ansi.StringWidth(text)
if width == 0 {
return text
}
if start >= width {
return text
}
end = minInt(end, width)
if end <= start {
return text
}
prefix := ansi.Cut(text, 0, start)
selected := ansi.Cut(text, start, end)
suffix := ansi.Cut(text, end, width)
if ansi.StringWidth(selected) == 0 {
return text
}
return prefix + style.Render(selected) + suffix
}
func copyTranscriptSelectionToClipboard(text string) {
if strings.TrimSpace(text) == "" {
return
}
_ = clipboard.WriteAll(text)
}
func fitStyledPieces(width int, pieces []string) string {
if width <= 0 {
return ""
}
var kept []string
for _, piece := range pieces {
candidate := strings.Join(append(kept, piece), " ")
if lipgloss.Width(candidate) > width {
break
}
kept = append(kept, piece)
}
return strings.Join(kept, " ")
}
func joinStyledLeftRight(width int, left string, right string) string {
if width <= 0 {
return ""
}
if strings.TrimSpace(right) == "" {
return padANSIRight(left, width)
}
rightWidth := ansi.StringWidth(right)
if rightWidth >= width {
return ansi.Cut(right, 0, width)
}
leftWidth := ansi.StringWidth(left)
availableLeft := maxInt(0, width-rightWidth-1)
if leftWidth > availableLeft {
left = ansi.Cut(left, 0, availableLeft)
leftWidth = ansi.StringWidth(left)
}
gap := maxInt(1, width-leftWidth-rightWidth)
return padANSIRight(left, leftWidth) + strings.Repeat(" ", gap) + right
}
func overlayContent(base string, overlay string, left, top, width int) string {
if strings.TrimSpace(overlay) == "" {
return base
}
baseLines := strings.Split(base, "\n")
overlayLines := strings.Split(overlay, "\n")
requiredHeight := maxInt(len(baseLines), top+len(overlayLines))
for len(baseLines) < requiredHeight {
baseLines = append(baseLines, "")
}
for i, overlayLine := range overlayLines {
row := top + i
if row < 0 || row >= len(baseLines) {
continue
}
baseLines[row] = overlayLineAt(baseLines[row], overlayLine, left, width)
}
return strings.Join(baseLines, "\n")
}
func overlayLineAt(baseLine string, overlayLine string, left, width int) string {
if left < 0 {
left = 0
}
if width <= 0 {
width = maxInt(
ansi.StringWidth(baseLine),
left+ansi.StringWidth(overlayLine),
)
}
prefix := ansi.Cut(baseLine, 0, minInt(left, width))
prefixWidth := ansi.StringWidth(prefix)
if prefixWidth < left {
prefix += strings.Repeat(" ", left-prefixWidth)
}
suffixStart := minInt(width, left+ansi.StringWidth(overlayLine))
suffix := ansi.Cut(baseLine, suffixStart, width)
visibleWidth := left + ansi.StringWidth(overlayLine) + ansi.StringWidth(suffix)
if visibleWidth < width {
suffix += strings.Repeat(" ", width-visibleWidth)
}
return prefix + overlayLine + suffix
}
func clampANSIBlock(text string, width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
lines := strings.Split(text, "\n")
if len(lines) > height {
lines = lines[:height]
}
for i, line := range lines {
lines[i] = padANSIRight(line, width)
}
return strings.Join(lines, "\n")
}
func trimRenderedLines(lines []string) []string {
start := 0
for start < len(lines) && strings.TrimSpace(ansi.Strip(lines[start])) == "" {
start++
}
end := len(lines)
for end > start && strings.TrimSpace(ansi.Strip(lines[end-1])) == "" {
end--
}
trimmed := append([]string(nil), lines[start:end]...)
if len(trimmed) == 0 {
return []string{""}
}
return trimmed
}
func padANSIRight(text string, width int) string {
if width <= 0 {
return ""
}
clipped := ansi.Cut(text, 0, width)
if padding := width - ansi.StringWidth(clipped); padding > 0 {
clipped += strings.Repeat(" ", padding)
}
return clipped
}
func wrapText(text string, width int) []string {
if width <= 1 {
return []string{truncateText(text, width)}
}
paragraphs := strings.Split(text, "\n")
lines := make([]string, 0, len(paragraphs))
for _, paragraph := range paragraphs {
words := strings.Fields(paragraph)
if len(words) == 0 {
lines = append(lines, "")
continue
}
current := words[0]
for _, word := range words[1:] {
if utf8.RuneCountInString(current)+1+utf8.RuneCountInString(word) <= width {
current += " " + word
continue
}
lines = append(lines, current)
current = word
}
lines = append(lines, current)
}
return lines
}
func truncateText(text string, width int) string {
if width <= 0 {
return ""
}
if utf8.RuneCountInString(text) <= width {
return text
}
if width <= 3 {
return string([]rune(text)[:width])
}
runes := []rune(text)
return string(runes[:width-3]) + "..."
}
func headLines(lines []string, count int) []string {
if count <= 0 {
return nil
}
if len(lines) <= count {
return lines
}
return lines[:count]
}
func tailLines(lines []string, count int) []string {
if count <= 0 {
return nil
}
if len(lines) <= count {
return lines
}
return lines[len(lines)-count:]
}
func innerPaneWidth(width int) int {
return maxInt(1, width-aiPaneHorizontalChrome)
}
func innerPaneHeight(height int) int {
return maxInt(1, height-aiPaneVerticalChrome)
}
func clampInt(value, low, high int) int {
if value < low {
return low
}
if value > high {
return high
}
return value
}
func clampFloat64(value, low, high float64) float64 {
if value < low {
return low
}
if value > high {
return high
}
return value
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}