Merge branch 'main' into feat/docker-mcp-integration

Andrey Nering created

Change summary

.github/cla-signatures.json        |  8 +++
README.md                          |  1 
go.mod                             |  5 -
go.sum                             | 10 +--
internal/agent/hyper/provider.json |  0 
internal/config/config.go          |  5 ++
internal/ui/list/list.go           |  6 -
internal/ui/model/chat.go          | 71 ++++++++++++++++++++++++++-----
internal/ui/model/ui.go            | 20 ++++----
9 files changed, 91 insertions(+), 35 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -1239,6 +1239,14 @@
       "created_at": "2026-02-12T11:58:04Z",
       "repoId": 987670088,
       "pullRequestNo": 2203
+    },
+    {
+      "name": "PHPCraftdream",
+      "id": 14233546,
+      "comment_id": 3893502046,
+      "created_at": "2026-02-12T21:34:20Z",
+      "repoId": 987670088,
+      "pullRequestNo": 2212
     }
   ]
 }

README.md 🔗

@@ -191,6 +191,7 @@ That said, you can also set environment variables for preferred providers.
 | `HF_TOKEN`                  | Hugging Face Inference                             |
 | `CEREBRAS_API_KEY`          | Cerebras                                           |
 | `OPENROUTER_API_KEY`        | OpenRouter                                         |
+| `IONET_API_KEY`             | io.net                                             |
 | `GROQ_API_KEY`              | Groq                                               |
 | `VERTEXAI_PROJECT`          | Google Cloud VertexAI (Gemini)                     |
 | `VERTEXAI_LOCATION`         | Google Cloud VertexAI (Gemini)                     |

go.mod 🔗

@@ -5,8 +5,8 @@ go 1.25.5
 require (
 	charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66
 	charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0
-	charm.land/catwalk v0.18.0
-	charm.land/fantasy v0.7.2
+	charm.land/catwalk v0.19.2
+	charm.land/fantasy v0.8.0
 	charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b
 	charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea
 	charm.land/log/v2 v2.0.0-20251110204020-529bb77f35da
@@ -79,7 +79,6 @@ require (
 	cloud.google.com/go/compute/metadata v0.9.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
-	github.com/RealAlexandreAI/json-repair v0.0.15 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect

go.sum 🔗

@@ -2,10 +2,10 @@ charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66 h1:2BdJynsAW+8rv
 charm.land/bubbles/v2 v2.0.0-rc.1.0.20260109112849-ae99f46cec66/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0 h1:HAbpM9TPjZM18D677ww3VnkKXdd2hyMQtHUsVV0HcPQ=
 charm.land/bubbletea/v2 v2.0.0-rc.2.0.20260209074636-30878e43d7b0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
-charm.land/catwalk v0.18.0 h1:vBbhhxuGqkx2qVzom54ElJyBCQHn30dOnPYG977za4Q=
-charm.land/catwalk v0.18.0/go.mod h1:kAdk/GjAJbl1AjRjmfU5c9lZfs7PeC3Uy9TgaVtlN64=
-charm.land/fantasy v0.7.2 h1:OUBgbs7hllZE7rpJP9SzdsGE/hMCm+mr11iEIqU02hE=
-charm.land/fantasy v0.7.2/go.mod h1:vH6F5eYqaxgNEvDQdXRsOsfvoRyT3f/uJngPNJmcDmw=
+charm.land/catwalk v0.19.2 h1:exy+egllV6VEuR0e5eGkefnL6xnlszNxy9FpH2vjss4=
+charm.land/catwalk v0.19.2/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s=
+charm.land/fantasy v0.8.0 h1:w0FNH2K7DF0xxXJL1AqojMa5HQhK1sK/Rogo0NwjWmQ=
+charm.land/fantasy v0.8.0/go.mod h1:KJ8vjy9FH7G2aeR/fL+os2uFHkQ4js2+UJVbsUKCXYM=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b h1:A6IUUyChZDWP16RUdRJCfmYISAKWQGyIcfhZJUCViQ0=
 charm.land/glamour/v2 v2.0.0-20260123212943-6014aa153a9b/go.mod h1:J3kVhY6oHXZq5f+8vC3hmDO95fEvbqj3z7xDwxrfzU8=
 charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea h1:XBmpGhIKPN8o9VjuXg+X5WXFsEqUs/YtPx0Q0zzmTTA=
@@ -37,8 +37,6 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
 github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
-github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
-github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=

internal/config/config.go 🔗

@@ -799,6 +799,11 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
 			return fmt.Errorf("invalid API key format for provider %s", c.ID)
 		}
 		return nil
+	case catwalk.InferenceProviderIoNet:
+		if !strings.HasPrefix(apiKey, "io-") {
+			return fmt.Errorf("invalid API key format for provider %s", c.ID)
+		}
+		return nil
 	}
 
 	switch c.Type {

internal/ui/list/list.go 🔗

@@ -77,9 +77,7 @@ func (l *List) Gap() int {
 
 // AtBottom returns whether the list is showing the last item at the bottom.
 func (l *List) AtBottom() bool {
-	const margin = 2
-
-	if len(l.items) == 0 || l.offsetIdx >= len(l.items)-1 {
+	if len(l.items) == 0 {
 		return true
 	}
 
@@ -94,7 +92,7 @@ func (l *List) AtBottom() bool {
 		totalHeight += itemHeight
 	}
 
-	return totalHeight-l.offsetLine-margin <= l.height
+	return totalHeight-l.offsetLine <= l.height
 }
 
 // SetReverse shows the list in reverse order.

internal/ui/model/chat.go 🔗

@@ -59,6 +59,10 @@ type Chat struct {
 
 	// Pending single click action (delayed to detect double-click)
 	pendingClickID int // Incremented on each click to invalidate old pending clicks
+
+	// follow is a flag to indicate whether the view should auto-scroll to
+	// bottom on new messages.
+	follow bool
 }
 
 // NewChat creates a new instance of [Chat] that handles chat interactions and
@@ -93,8 +97,8 @@ func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
 func (m *Chat) SetSize(width, height int) {
 	m.list.SetSize(width, height)
 	// Anchor to bottom if we were at the bottom.
-	if m.list.AtBottom() {
-		m.list.ScrollToBottom()
+	if m.AtBottom() {
+		m.ScrollToBottom()
 	}
 }
 
@@ -120,7 +124,7 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
 		items[i] = msg
 	}
 	m.list.SetItems(items...)
-	m.list.ScrollToBottom()
+	m.ScrollToBottom()
 }
 
 // AppendMessages appends a new message item to the chat list.
@@ -239,31 +243,72 @@ func (m *Chat) Blur() {
 	m.list.Blur()
 }
 
+// AtBottom returns whether the chat list is currently scrolled to the bottom.
+func (m *Chat) AtBottom() bool {
+	return m.list.AtBottom()
+}
+
+// Follow returns whether the chat view is in follow mode (auto-scroll to
+// bottom on new messages).
+func (m *Chat) Follow() bool {
+	return m.follow
+}
+
+// ScrollToBottom scrolls the chat view to the bottom.
+func (m *Chat) ScrollToBottom() {
+	m.list.ScrollToBottom()
+	m.follow = true // Enable follow mode when user scrolls to bottom
+}
+
+// ScrollToTop scrolls the chat view to the top.
+func (m *Chat) ScrollToTop() {
+	m.list.ScrollToTop()
+	m.follow = false // Disable follow mode when user scrolls up
+}
+
+// ScrollBy scrolls the chat view by the given number of line deltas.
+func (m *Chat) ScrollBy(lines int) {
+	m.list.ScrollBy(lines)
+	m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
+}
+
+// ScrollToSelected scrolls the chat view to the selected item.
+func (m *Chat) ScrollToSelected() {
+	m.list.ScrollToSelected()
+	m.follow = m.AtBottom() // Disable follow mode if user scrolls up
+}
+
+// ScrollToIndex scrolls the chat view to the item at the given index.
+func (m *Chat) ScrollToIndex(index int) {
+	m.list.ScrollToIndex(index)
+	m.follow = m.AtBottom() // Disable follow mode if user scrolls up
+}
+
 // ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
 // any paused animations that are now visible.
 func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
-	m.list.ScrollToTop()
+	m.ScrollToTop()
 	return m.RestartPausedVisibleAnimations()
 }
 
 // ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
 // restart any paused animations that are now visible.
 func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
-	m.list.ScrollToBottom()
+	m.ScrollToBottom()
 	return m.RestartPausedVisibleAnimations()
 }
 
 // ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
 // a command to restart any paused animations that are now visible.
 func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
-	m.list.ScrollBy(lines)
+	m.ScrollBy(lines)
 	return m.RestartPausedVisibleAnimations()
 }
 
 // ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
 // command to restart any paused animations that are now visible.
 func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
-	m.list.ScrollToSelected()
+	m.ScrollToSelected()
 	return m.RestartPausedVisibleAnimations()
 }
 
@@ -438,10 +483,10 @@ func (m *Chat) MessageItem(id string) chat.MessageItem {
 func (m *Chat) ToggleExpandedSelectedItem() {
 	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
 		if !expandable.ToggleExpanded() {
-			m.list.ScrollToIndex(m.list.Selected())
+			m.ScrollToIndex(m.list.Selected())
 		}
-		if m.list.AtBottom() {
-			m.list.ScrollToBottom()
+		if m.AtBottom() {
+			m.ScrollToBottom()
 		}
 	}
 }
@@ -549,11 +594,11 @@ func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
 		// Toggle expansion if applicable.
 		if expandable, ok := selectedItem.(chat.Expandable); ok {
 			if !expandable.ToggleExpanded() {
-				m.list.ScrollToIndex(m.list.Selected())
+				m.ScrollToIndex(m.list.Selected())
 			}
 		}
-		if m.list.AtBottom() {
-			m.list.ScrollToBottom()
+		if m.AtBottom() {
+			m.ScrollToBottom()
 		}
 		return handled
 	}

internal/ui/model/ui.go 🔗

@@ -53,6 +53,10 @@ import (
 	"github.com/charmbracelet/x/editor"
 )
 
+// MouseScrollThreshold defines how many lines to scroll the chat when a mouse
+// wheel event occurs.
+const MouseScrollThreshold = 5
+
 // Compact mode breakpoints.
 const (
 	compactModeWidthBreakpoint  = 120
@@ -659,7 +663,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case uiChat:
 			switch msg.Button {
 			case tea.MouseWheelUp:
-				if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
+				if cmd := m.chat.ScrollByAndAnimate(-MouseScrollThreshold); cmd != nil {
 					cmds = append(cmds, cmd)
 				}
 				if !m.chat.SelectedItemInView() {
@@ -669,7 +673,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}
 				}
 			case tea.MouseWheelDown:
-				if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
+				if cmd := m.chat.ScrollByAndAnimate(MouseScrollThreshold); cmd != nil {
 					cmds = append(cmds, cmd)
 				}
 				if !m.chat.SelectedItemInView() {
@@ -880,7 +884,6 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 // if the message is a tool result it will update the corresponding tool call message
 func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 	var cmds []tea.Cmd
-	atBottom := m.chat.list.AtBottom()
 
 	existing := m.chat.MessageItem(msg.ID)
 	if existing != nil {
@@ -913,7 +916,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 		}
 		m.chat.AppendMessages(items...)
-		if atBottom {
+		if m.chat.Follow() {
 			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 				cmds = append(cmds, cmd)
 			}
@@ -921,7 +924,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.Config(), time.Unix(m.lastUserMessageTime, 0))
 			m.chat.AppendMessages(infoItem)
-			if atBottom {
+			if m.chat.Follow() {
 				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 					cmds = append(cmds, cmd)
 				}
@@ -936,7 +939,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
 				toolMsgItem.SetResult(&tr)
-				if atBottom {
+				if m.chat.Follow() {
 					if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 						cmds = append(cmds, cmd)
 					}
@@ -971,7 +974,6 @@ func (m *UI) handleClickFocus(msg tea.MouseClickMsg) (cmd tea.Cmd) {
 func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 	var cmds []tea.Cmd
 	existingItem := m.chat.MessageItem(msg.ID)
-	atBottom := m.chat.list.AtBottom()
 
 	if existingItem != nil {
 		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
@@ -1020,7 +1022,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 	}
 
 	m.chat.AppendMessages(items...)
-	if atBottom {
+	if m.chat.Follow() {
 		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
@@ -1033,7 +1035,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.Cmd {
 	var cmds []tea.Cmd
 
-	atBottom := m.chat.list.AtBottom()
+	atBottom := m.chat.AtBottom()
 	// Only process messages with tool calls or results.
 	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
 		return nil