diff --git a/pkg/api/conversation.go b/pkg/api/conversation.go index 116fd49..c35ebd3 100644 --- a/pkg/api/conversation.go +++ b/pkg/api/conversation.go @@ -71,14 +71,22 @@ func ApplySystemPrompt(m []Message, system string, force bool) []Message { } } -func (m *MessageRole) IsAssistant() bool { - switch *m { +func (m MessageRole) IsAssistant() bool { + switch m { case MessageRoleAssistant, MessageRoleToolCall: return true } return false } +func (m MessageRole) IsSystem() bool { + switch m { + case MessageRoleSystem: + return true + } + return false +} + // FriendlyRole returns a human friendly signifier for the message's role. func (m MessageRole) FriendlyRole() string { switch m { diff --git a/pkg/tui/views/chat/chat.go b/pkg/tui/views/chat/chat.go index 726f66a..97d316c 100644 --- a/pkg/tui/views/chat/chat.go +++ b/pkg/tui/views/chat/chat.go @@ -89,11 +89,11 @@ type Model struct { persistence bool // whether we will save new messages in the conversation // UI state - focus focusState - wrap bool // whether message content is wrapped to viewport width - showToolResults bool // whether tool calls and results are shown - messageCache []string // cache of syntax highlighted and wrapped message content - messageOffsets []int + focus focusState + showDetails bool // whether various details are shown in the UI (e.g. system prompt, tool calls/results, message metadata) + wrap bool // whether message content is wrapped to viewport width + messageCache []string // cache of syntax highlighted and wrapped message content + messageOffsets []int // ui elements content viewport.Model diff --git a/pkg/tui/views/chat/input.go b/pkg/tui/views/chat/input.go index ecd6fcd..b139f4e 100644 --- a/pkg/tui/views/chat/input.go +++ b/pkg/tui/views/chat/input.go @@ -12,17 +12,6 @@ import ( ) func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd { - switch msg.String() { - case "ctrl+g": - if m.state == pendingResponse { - m.stopSignal <- struct{}{} - return shared.KeyHandled(msg) - } - return func() tea.Msg { - return shared.MsgViewChange(shared.ViewSettings) - } - } - switch m.focus { case focusInput: cmd := m.handleInputKey(msg) @@ -50,11 +39,19 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd { m.stopSignal <- struct{}{} return shared.KeyHandled(msg) } + case "ctrl+g": + if m.state == pendingResponse { + m.stopSignal <- struct{}{} + return shared.KeyHandled(msg) + } + return func() tea.Msg { + return shared.MsgViewChange(shared.ViewSettings) + } case "ctrl+p": m.persistence = !m.persistence return shared.KeyHandled(msg) case "ctrl+t": - m.showToolResults = !m.showToolResults + m.showDetails = !m.showDetails m.rebuildMessageCache() m.updateContent() return shared.KeyHandled(msg) @@ -72,6 +69,27 @@ func (m *Model) handleInput(msg tea.KeyMsg) tea.Cmd { return nil } +func (m *Model) scrollSelection(dir int) { + if m.selectedMessage + dir < 0 || m.selectedMessage + dir >= len(m.App.Messages) { + return + } + + newIdx := m.selectedMessage + for i := newIdx + dir; i >= 0 && i < len(m.App.Messages); i += dir{ + if !m.showDetails && m.App.Messages[i].Role.IsSystem() { + continue + } + newIdx = i + break + } + if newIdx != m.selectedMessage { + m.selectedMessage = newIdx + m.updateContent() + yOffset := m.messageOffsets[m.selectedMessage] + tuiutil.ScrollIntoView(&m.content, yOffset, m.content.Height/2) + } +} + // handleMessagesKey handles input when the messages pane is focused func (m *Model) handleMessagesKey(msg tea.KeyMsg) tea.Cmd { switch msg.String() { @@ -90,25 +108,19 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) tea.Cmd { ) } return nil - case "ctrl+k": - if m.selectedMessage > 0 && len(m.App.Messages) == len(m.messageOffsets) { - m.selectedMessage-- - m.updateContent() - offset := m.messageOffsets[m.selectedMessage] - tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2) + case "ctrl+k", "ctrl+up": + if m.selectedMessage > 0 { + m.scrollSelection(-1) } return shared.KeyHandled(msg) - case "ctrl+j": - if m.selectedMessage < len(m.App.Messages)-1 && len(m.App.Messages) == len(m.messageOffsets) { - m.selectedMessage++ - m.updateContent() - offset := m.messageOffsets[m.selectedMessage] - tuiutil.ScrollIntoView(&m.content, offset, m.content.Height/2) + case "ctrl+j", "ctrl+down": + if m.selectedMessage < len(m.App.Messages)-1 { + m.scrollSelection(1) } return shared.KeyHandled(msg) - case "ctrl+h", "ctrl+l": + case "ctrl+h", "ctrl+left", "ctrl+l", "ctrl+right": dir := model.CyclePrev - if msg.String() == "ctrl+l" { + if msg.String() == "ctrl+l" || msg.String() == "ctrl+right" { dir = model.CycleNext } @@ -120,7 +132,7 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) tea.Cmd { } return cmd case "ctrl+r": - // resubmit the conversation with all messages up until and including the selected message + // prompt the model with all messages up to and including the selected message if m.state == idle && m.selectedMessage < len(m.App.Messages) { m.App.Messages = m.App.Messages[:m.selectedMessage+1] m.messageCache = m.messageCache[:m.selectedMessage+1] @@ -130,7 +142,7 @@ func (m *Model) handleMessagesKey(msg tea.KeyMsg) tea.Cmd { return cmd } } - return nil + return nil } // handleInputKey handles input when the input textarea is focused diff --git a/pkg/tui/views/chat/view.go b/pkg/tui/views/chat/view.go index ba108df..14c46c4 100644 --- a/pkg/tui/views/chat/view.go +++ b/pkg/tui/views/chat/view.go @@ -16,14 +16,19 @@ import ( // styles var ( + boldStyle = lipgloss.NewStyle().Bold(true) + faintStyle = lipgloss.NewStyle().Faint(true) + boldFaintStyle = lipgloss.NewStyle().Faint(true).Bold(true) + messageHeadingStyle = lipgloss.NewStyle(). MarginTop(1). - MarginBottom(1). - Bold(true) + MarginBottom(1) - userStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("10")) + userStyle = boldFaintStyle.Foreground(lipgloss.Color("10")) - assistantStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("12")) + assistantStyle = boldFaintStyle.Foreground(lipgloss.Color("12")) + + systemStyle = boldStyle.Foreground(lipgloss.Color("8")) messageStyle = lipgloss.NewStyle(). PaddingLeft(2). @@ -40,13 +45,10 @@ var ( ) func (m *Model) renderMessageHeading(i int, message *api.Message) string { - icon := "" friendly := message.Role.FriendlyRole() - style := lipgloss.NewStyle().Faint(true).Bold(true) + style := systemStyle switch message.Role { - case api.MessageRoleSystem: - icon = "⚙️" case api.MessageRoleUser: style = userStyle case api.MessageRoleAssistant: @@ -54,16 +56,19 @@ func (m *Model) renderMessageHeading(i int, message *api.Message) string { case api.MessageRoleToolCall: style = assistantStyle friendly = api.MessageRoleAssistant.FriendlyRole() + case api.MessageRoleSystem: case api.MessageRoleToolResult: - icon = "🔧" } - user := style.Render(icon + friendly) + user := style.Render(friendly) - var prefix string - var suffix string + var prefix, suffix string - faint := lipgloss.NewStyle().Faint(true) + if i == m.selectedMessage { + prefix = "> " + } else { + prefix = " " + } if i == 0 && len(m.App.RootMessages) > 1 && m.App.Conversation.SelectedRootID != nil { selectedRootIndex := 0 @@ -73,7 +78,7 @@ func (m *Model) renderMessageHeading(i int, message *api.Message) string { break } } - suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedRootIndex+1, len(m.App.RootMessages))) + suffix += faintStyle.Render(fmt.Sprintf(" <%d/%d>", selectedRootIndex+1, len(m.App.RootMessages))) } if i > 0 && len(m.App.Messages[i-1].Replies) > 1 { // Find the selected reply index @@ -84,20 +89,22 @@ func (m *Model) renderMessageHeading(i int, message *api.Message) string { break } } - suffix += faint.Render(fmt.Sprintf(" <%d/%d>", selectedReplyIndex+1, len(m.App.Messages[i-1].Replies))) - } - - if i == m.selectedMessage { - prefix = "> " - } else { - prefix = " " + suffix += faintStyle.Render(fmt.Sprintf(" <%d/%d>", selectedReplyIndex+1, len(m.App.Messages[i-1].Replies))) } if message.ID == 0 { - suffix += faint.Render(" (not saved)") + suffix += faintStyle.Render(" (not saved)") } - return messageHeadingStyle.Render(prefix + user + suffix) + heading := prefix + user + suffix + + if message.Metadata.GenerationModel != nil && m.showDetails { + heading += faintStyle.Render( + fmt.Sprintf(" | %s", *message.Metadata.GenerationModel), + ) + } + + return messageHeadingStyle.Render(heading) } // renderMessages renders the message at the given index as it should be shown @@ -143,7 +150,7 @@ func (m *Model) renderMessage(i int) string { var toolResults []renderedResult for _, result := range msg.ToolResults { - if m.showToolResults { + if m.showDetails { var jsonResult interface{} err := json.Unmarshal([]byte(result.Result), &jsonResult) if err != nil { @@ -206,6 +213,10 @@ func (m *Model) conversationMessagesView() string { for i, message := range m.App.Messages { m.messageOffsets[i] = lineCnt + if !m.showDetails && message.Role.IsSystem() { + continue + } + heading := m.renderMessageHeading(i, &message) sb.WriteString(heading) sb.WriteString("\n") @@ -221,6 +232,9 @@ func (m *Model) conversationMessagesView() string { if m.state == pendingResponse && m.App.Messages[len(m.App.Messages)-1].Role != api.MessageRoleAssistant { heading := m.renderMessageHeading(-1, &api.Message{ Role: api.MessageRoleAssistant, + Metadata: api.MessageMeta { + GenerationModel: &m.App.Model, + }, }) sb.WriteString(heading) sb.WriteString("\n")