detached 949637b

Merge pull request #127 from bnema/refactor/interactive-mode-files

refactor(modes): split interactive responsibilities
commit
949637b0f271a77951867c571ffe5baee6f1530b
author
bnema · · 2026-08-14 03:58 UTC
committer
GitHub · · 2026-08-14 03:58 UTC
merge of
180d726 + cf1ed1b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.17.0
15 files · +8257 -8227
M packages/agent/modes/interactive.go +160 -8227
@@ -3,19 +3,11 @@ package modes
33 import (
44 "context"
55 "encoding/json"
6- "errors"
76 "fmt"
8- "os"
9- "path/filepath"
10- "runtime"
11- "sort"
12- "strconv"
137 "strings"
148 "sync"
159 "sync/atomic"
1610 "time"
17- "unicode"
18- "unicode/utf8"
1911
2012 "github.com/bnema/zut/packages/agent/extensions"
2113 "github.com/bnema/zut/packages/agent/extproto"
@@ -1377,143 +1369,6 @@ func (i *Interactive) Run(ctx context.Context) error {
13771369 }
13781370 }
13791371
1380-func (i *Interactive) requestRendererClear() {
1381- if scheduler := i.renderScheduler.Load(); scheduler != nil {
1382- scheduler.request(true, false)
1383- return
1384- }
1385- if i.rend != nil {
1386- i.rend.Clear()
1387- }
1388-}
1389-
1390-func (i *Interactive) requestRendererInvalidate() {
1391- if scheduler := i.renderScheduler.Load(); scheduler != nil {
1392- scheduler.request(false, true)
1393- return
1394- }
1395- if i.rend != nil {
1396- i.rend.Invalidate()
1397- }
1398-}
1399-
1400-func (i *Interactive) requestRendererTheme(theme tui.Theme) {
1401- if scheduler := i.renderScheduler.Load(); scheduler != nil {
1402- scheduler.requestTheme(theme)
1403- return
1404- }
1405- if i.rend != nil {
1406- i.rend.SetTheme(theme)
1407- }
1408-}
1409-
1410-func (i *Interactive) invalidate() {
1411- i.renderRevision.Add(1)
1412- // Keep ordinary state changes on the main-loop throttle. The scheduler
1413- // owns terminal output, but scheduling it here would bypass
1414- // redrawMinInterval and paint every intermediate tool-event frame.
1415- select {
1416- case i.dirty <- struct{}{}:
1417- default:
1418- }
1419-}
1420-
1421-func (i *Interactive) cachedChatLocked(cols int) []string {
1422- // A busy frame contains mutable streaming/tool state. Keep the stable
1423- // transcript cache, but never return a full-frame cache entry that would
1424- // hide a newly arrived delta or completed result.
1425- if i.busy || i.streamOn || i.streamFlushPending {
1426- return i.buildChatLocked(cols)
1427- }
1428- key := i.chatCacheKeyLocked(cols)
1429- if i.chatCacheValid && i.chatCacheKey == key {
1430- return append([]string(nil), i.chatCache...)
1431- }
1432- chat := i.buildChatLocked(cols)
1433- key = i.chatCacheKeyLocked(cols)
1434- i.chatCache = append(i.chatCache[:0], chat...)
1435- i.chatCacheKey = key
1436- i.chatCacheValid = true
1437- return chat
1438-}
1439-
1440-func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
1441- var rev uint64
1442- if i.agent != nil {
1443- rev = i.agent.Revision()
1444- }
1445- showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1446- return chatCacheKey{
1447- cols: cols,
1448- agentRev: rev,
1449- statusOK: i.statusOK,
1450- statusErr: i.statusErr,
1451- help: strings.Join(i.helpBlock, "\n"),
1452- extNotes: strings.Join(i.extNotes, "\n"),
1453- extStatuses: i.extensionStatusesKeyLocked(),
1454- extWidgets: i.extensionWidgetsKeyLocked(),
1455- reloadErrors: strings.Join(i.reloadErrors, "\n"),
1456- updateAvailable: i.updateInfo.Available,
1457- updateCurrent: i.updateInfo.Current,
1458- updateLatest: i.updateInfo.Latest,
1459- updateURL: i.updateInfo.URL,
1460- welcomeShowVer: showVer,
1461- expandAll: i.view.ExpandAll,
1462- tailLimit: i.view.TailLimit,
1463- renderedMessageCount: len(i.view.Messages),
1464- viewCacheRev: i.view.RenderCacheRevision,
1465- }
1466-}
1467-
1468-func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
1469- keys := make([]string, 0, len(groups))
1470- for key := range groups {
1471- keys = append(keys, key)
1472- }
1473- sort.Strings(keys)
1474- return keys
1475-}
1476-
1477-func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
1478- items := groups[outer]
1479- keys := make([]string, 0, len(items))
1480- for key := range items {
1481- keys = append(keys, key)
1482- }
1483- sort.Strings(keys)
1484- return keys
1485-}
1486-
1487-func (i *Interactive) extensionStatusesKeyLocked() string {
1488- var sb strings.Builder
1489- extNames := sortedNestedOuterKeys(i.extStatuses)
1490- for _, extName := range extNames {
1491- keys := sortedNestedInnerKeys(i.extStatuses, extName)
1492- for _, key := range keys {
1493- status := i.extStatuses[extName][key]
1494- fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
1495- }
1496- }
1497- return sb.String()
1498-}
1499-
1500-func (i *Interactive) extensionWidgetsKeyLocked() string {
1501- var sb strings.Builder
1502- extNames := sortedNestedOuterKeys(i.extWidgets)
1503- for _, extName := range extNames {
1504- ids := sortedNestedInnerKeys(i.extWidgets, extName)
1505- for _, id := range ids {
1506- widget := i.extWidgets[extName][id]
1507- fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
1508- for _, line := range widget.Lines {
1509- sb.WriteString(line)
1510- sb.WriteByte('\n')
1511- }
1512- }
1513- }
1514- return sb.String()
1515-}
1516-
15171372 const maxExtensionWidgetRows = 12
15181373 const maxExtensionStatusRows = 6
15191374
@@ -1521,360 +1376,17 @@ const maxExtensionStatusRows = 6
15211376 // above the input, leaving one row for the truncation marker.
15221377 const maxNarrowExtensionChromeRows = 6
15231378
1524-func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1525- var out []tui.RightBarWidget
1526- for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1527- for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1528- widget := i.extWidgets[extName][id]
1529- if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
1530- continue
1531- }
1532- out = append(out, tui.RightBarWidget{
1533- Extension: extName,
1534- ID: id,
1535- Title: widget.Title,
1536- Lines: append([]string(nil), widget.Lines...),
1537- })
1538- }
1539- }
1540- return out
1541-}
1542-
1543-func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1544- return i.extensionChromeLinesAtLocked(cols, false)
1545-}
1546-
1547-func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
1548- lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
1549- if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
1550- return lines
1551- }
1552- limit := maxNarrowExtensionChromeRows
1553- trimmed := append([]string(nil), lines[:limit-1]...)
1554- trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1555- return trimmed
1556-}
1557-
1558-func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
1559- var out []string
1560- bodyWidth := cols - 2
1561- if bodyWidth < 1 {
1562- bodyWidth = 1
1563- }
1564- widgetRows := 0
1565- widgetTruncated := false
1566- appendWidgetRow := func(line string) bool {
1567- if widgetRows >= maxExtensionWidgetRows-1 {
1568- widgetTruncated = true
1569- return false
1570- }
1571- out = append(out, line)
1572- widgetRows++
1573- return true
1574- }
1575-
1576- stopWidgets := false
1577- for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1578- if stopWidgets {
1579- break
1580- }
1581- for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1582- widget := i.extWidgets[extName][id]
1583- if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
1584- continue
1585- }
1586- label := " [" + extName + "]"
1587- if widget.Title != "" {
1588- label += " " + widget.Title
1589- }
1590- if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1591- stopWidgets = true
1592- break
1593- }
1594- for _, line := range widget.Lines {
1595- if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
1596- stopWidgets = true
1597- break
1598- }
1599- }
1600- if stopWidgets {
1601- break
1602- }
1603- }
1604- }
1605- if widgetTruncated {
1606- out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1607- }
1608-
1609- statusRows := 0
1610- statusTruncated := false
1611- for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
1612- for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
1613- if statusRows >= maxExtensionStatusRows-1 {
1614- statusTruncated = true
1615- break
1616- }
1617- status := i.extStatuses[extName][key]
1618- color := i.cfg.Theme.Muted
1619- switch status.Level {
1620- case "warn":
1621- color = i.cfg.Theme.Warning
1622- case "error":
1623- color = i.cfg.Theme.Error
1624- case "success":
1625- color = i.cfg.Theme.Tool
1626- }
1627- label := " [" + extName + "] " + status.Text
1628- out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
1629- statusRows++
1630- }
1631- if statusTruncated {
1632- break
1633- }
1634- }
1635- if statusTruncated {
1636- out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
1637- }
1638- return out
1639-}
1640-
1641-func (i *Interactive) stableChatRowsLocked(cols int) []string {
1642- key := i.chatCacheKeyLocked(cols)
1643- if i.stableChatCacheValid && i.stableChatCacheKey == key {
1644- return append([]string(nil), i.stableChatCache...)
1645- }
1646-
1647- renderView := i.view.CloneForRender()
1648- renderView.Streaming = ""
1649- renderView.StreamingActive = false
1650- renderView.ToolCalls = nil
1651- renderView.Err = ""
1652- if !i.renderOutsideLock {
1653- rows := renderView.Build(cols)
1654- i.stableChatCache = append(i.stableChatCache[:0], rows...)
1655- i.stableChatCacheKey = key
1656- i.stableChatCacheValid = true
1657- return append([]string(nil), rows...)
1658- }
1659- // Markdown, syntax highlighting, and wrapping are the expensive part of
1660- // a frame. Build the immutable snapshot without holding the interactive
1661- // mutex so key processing can continue while a cold transcript renders.
1662- i.mu.Unlock()
1663- rows := renderView.Build(cols)
1664- i.mu.Lock()
1665- if i.view.MessagesRevision == renderView.MessagesRevision &&
1666- i.view.RenderCacheRevision == renderView.RenderCacheRevision {
1667- i.view.AdoptRenderCacheFrom(renderView)
1668- }
1669-
1670- i.stableChatCache = append(i.stableChatCache[:0], rows...)
1671- i.stableChatCacheKey = key
1672- i.stableChatCacheValid = true
1673- return append([]string(nil), rows...)
1674-}
1675-
1676-func (i *Interactive) buildChatLocked(cols int) []string {
1677- if i.agent != nil {
1678- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1679- i.view.MessagesRevision = i.agent.Revision()
1680- } else {
1681- i.view.Messages = nil
1682- i.view.MessagesRevision = 0
1683- }
1684- // Pacer flush: while the streaming pacer is still draining the
1685- // buffer (i.e. EvAssistantMessage already fired but more runes
1686- // are queued), the final assistant message is already in
1687- // i.agent.Messages() in full. Painting it in the transcript
1688- // AND the streaming block at the same time shows the user the
1689- // complete text immediately — which defeats the whole pacer.
1690- // Hide the last message until the pacer catches up; once the
1691- // flush-pending latch clears, the message is revealed (the
1692- // streaming block disappears the same frame because streamOn
1693- // flips off, so the transition is seamless).
1694- if i.streamFlushPending && len(i.view.Messages) > 0 {
1695- i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
1696- }
1697- i.view.Streaming = i.streaming.String()
1698- i.view.StreamingActive = i.streamOn
1699- // Guard against the narrow race where EvAssistantMessage has
1700- // just promoted a streaming reply into the transcript but a
1701- // render tick hasn't flipped streamOn off yet. Without the
1702- // guard, the same text would appear twice (once as the
1703- // in-flight streaming block, once as the last transcript
1704- // message). We detect the duplicate strictly: the last
1705- // assistant message's visible text must equal the streaming
1706- // buffer. Just matching on role is too broad — it also hides
1707- // the next round's typewriter streaming after a tool turn,
1708- // because the last transcript message is always assistant
1709- // (the tool-use block) until the follow-up summary lands.
1710- if i.streamOn && i.streaming.Len() > 0 {
1711- if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
1712- if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
1713- i.view.StreamingActive = false
1714- }
1715- }
1716- }
1717- // Live tool-call view: only shown while a turn is in flight. Once
1718- // the agent is idle, every tool call has already been folded into
1719- // the transcript (as assistant.ToolCallBlock + a tool-role message),
1720- // so showing v.ToolCalls a second time would duplicate them below
1721- // the final assistant text — which looks like the summary came
1722- // "before" the tools.
1723- i.view.ToolCalls = i.view.ToolCalls[:0]
1724- if i.busy {
1725- for _, id := range i.toolOrder {
1726- // Deterministic ordering: a tool block stays hidden until
1727- // the paced assistant text that preceded it has finished
1728- // typing out. toolOrder is append-only in arrival order,
1729- // so once one tool is still gated, every later tool is too,
1730- // so stop here to avoid showing a tool out of sequence.
1731- if !i.toolGateOpenLocked(id) {
1732- break
1733- }
1734- if tc, ok := i.toolCalls[id]; ok {
1735- i.view.ToolCalls = append(i.view.ToolCalls, *tc)
1736- }
1737- }
1738- }
1739- i.view.Err = i.statusErr
1740- // Live streaming/tool rows are appended to the chat buffer (not
1741- // hoisted into a separate live block above the editor). That keeps
1742- // the renderer's diff view append-only: when a tool finishes the
1743- // rows update in place at the end of the buffer, instead of the
1744- // whole bottom band shrinking and shifting chat lines around.
1745- i.liveBlock = nil
1746- stable := i.stableChatRowsLocked(cols)
1747- chat := append([]string(nil), stable...)
1748- chat = append(chat, i.view.BuildLive(cols)...)
1749-
1750- // Welcome banner: shown at the top of the chat area when there is
1751- // no transcript yet. Disappears after the first message is sent.
1752- // The version suffix is shown for welcomeVersionDuration after
1753- // startup, then drops off automatically.
1754- if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
1755- showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1756- chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
1757- }
1758-
1759- // Update-available banner: prepended above everything else so it's
1760- // the first thing the user sees when opening a new zut session.
1761- // Once rendered, it stays until the user updates to a newer
1762- // version — we don't persist a "dismissed" flag because this is
1763- // cheap and re-showing it is how most users remember to update.
1764- if i.updateInfo.Available {
1765- banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
1766- chat = append(banner, chat...)
1767- }
1768-
1769- // /help block: appended to the transcript so it appears at the
1770- // bottom of the chat area (right above the status bar / editor).
1771- // Prepending it would push long conversations off the top of the
1772- // viewport, which users would miss entirely.
1773- if len(i.helpBlock) > 0 {
1774- chat = append(chat, i.helpBlock...)
1775- }
1776-
1777- if i.statusOK != "" {
1778- // Hard-truncate the OK line to the visible width so a long
1779- // session path ("resumed session: /Users/.../sessions/...")
1780- // doesn't overflow past the right edge and look broken on a
1781- // narrow terminal.
1782- line := "✓ " + i.statusOK
1783- if cols > 4 && len(line) > cols {
1784- line = line[:cols-3] + "..."
1785- }
1786- chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
1787- }
1788-
1789- // Live shell-escape output (!command / entry.pre) streams into the
1790- // transcript area while the command runs, then is replaced by the
1791- // final user-context message when it finishes.
1792- if i.shellRunning && i.shellLive != "" {
1793- chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...)
1794- chat = append(chat, "")
1795- }
1796-
1797- // Extension notes (notify / display) live just under the
1798- // transcript, above the dialog/editor band. Cleared by /clear.
1799- if len(i.extNotes) > 0 {
1800- chat = append(chat, i.extNotes...)
1801- chat = append(chat, "")
1802- }
1803-
1804- // Reload failures remain in the scrolling chat until /clear. They are
1805- // host-only display state, not provider messages or persisted context.
1806- // While the latest failure is still the temporary status error, hide its
1807- // stored copy to avoid painting the same message twice.
1808- reloadErrors := i.reloadErrors
1809- if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
1810- reloadErrors = reloadErrors[:n-1]
1811- }
1812- if len(reloadErrors) > 0 {
1813- chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
1814- chat = append(chat, "")
1815- }
1816-
1817- // Strip trailing blank rows so the chat content sits flush
1818- // against the new "blank above status bar" row added by the
1819- // bottom-region assembly. Build() ends every message with a
1820- // blank separator; without this trim, the final message in
1821- // the transcript would have its own trailing blank plus the
1822- // status block's leading blank, doubling the gap.
1823- for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1824- chat = chat[:len(chat)-1]
1825- }
1826- return chat
1827-}
1828-
18291379 // sessionsRoot returns the session namespace for this run.
1830-func (i *Interactive) sessionsRoot() string {
1831- if i.cfg.SessionsRoot != "" {
1832- return i.cfg.SessionsRoot
1833- }
1834- return i.cfg.ZutHome
1835-}
18361380
18371381 // lastCols returns the current terminal width in columns.
1838-func (i *Interactive) lastCols() int {
1839- cols, _ := i.cfg.Terminal.Size()
1840- return cols
1841-}
18421382
18431383 // chatPage returns the number of chat rows currently visible, used
18441384 // as the page size for PageUp/PageDown.
1845-func (i *Interactive) chatPage() int {
1846- _, rows := i.cfg.Terminal.Size()
1847- p := rows - 6 // rough reservation for status + editor + a dialog line
1848- if p < 4 {
1849- p = 4
1850- }
1851- return p
1852-}
18531385
18541386 // scrollBy adjusts the scroll offset. Positive = up (into history).
18551387 // Clearing the parked-turn label when we're back at the bottom means
18561388 // the "viewing turn N" footer goes away automatically as soon as you
18571389 // scroll back to the live tail.
1858-func (i *Interactive) scrollBy(delta int) {
1859- i.mu.Lock()
1860- i.scrollOffset += delta
1861- if i.scrollOffset < 0 {
1862- i.scrollOffset = 0
1863- }
1864- if i.scrollOffset == 0 {
1865- i.parkedTurn = 0
1866- i.parkedTotal = 0
1867- }
1868- if i.rend != nil {
1869- // VS Code's terminal is especially prone to leaving stray
1870- // wrapped-character fragments behind during scroll-driven
1871- // viewport changes. Force a full repaint on scroll, but
1872- // avoid a whole-screen clear because that visibly flickers.
1873- i.requestRendererInvalidate()
1874- }
1875- i.mu.Unlock()
1876- i.invalidate()
1877-}
18781390
18791391 // anchorScrollOffset keeps the user's reading position pinned when the
18801392 // chat buffer grows/shrinks or the viewport height changes between two
@@ -1885,637 +1397,8 @@ func (i *Interactive) scrollBy(delta int) {
18851397 // `start` constant we adjust the offset by the buffer-length delta minus
18861398 // the viewport-height delta. The result is clamped to [0, newLen] so a
18871399 // shrinking buffer can't push it negative.
1888-func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1889- adj := (newLen - prevLen) - (newRows - prevRows)
1890- offset += adj
1891- if offset < 0 {
1892- offset = 0
1893- }
1894- if offset > newLen {
1895- offset = newLen
1896- }
1897- return offset
1898-}
18991400
19001401 // scrollToBottom pins the view to the latest content.
1901-func (i *Interactive) scrollToBottom() {
1902- i.mu.Lock()
1903- i.scrollOffset = 0
1904- i.parkedTurn = 0
1905- i.parkedTotal = 0
1906- // Reset the auto-follow baseline. scrollToBottom is the resume /
1907- // session-swap snap point, where the chat buffer changes length
1908- // wholesale. Without zeroing these, the next render's follow guard
1909- // compares the fresh transcript's length against a stale baseline
1910- // and nudges scrollOffset, which reads as a viewport jump right
1911- // after resume. See commit 43da5e5 for the same fix on new turns.
1912- i.prevChatLen = 0
1913- i.prevChatCols = 0
1914- i.requestRendererInvalidate()
1915- i.mu.Unlock()
1916- i.invalidate()
1917-}
1918-
1919-func (i *Interactive) redraw() {
1920- i.mu.Lock()
1921- renderRevision := i.renderRevision.Load()
1922-
1923- cols, rows := i.cfg.Terminal.Size()
1924- mainCols := cols
1925- rightBarWidgets := i.rightBarWidgetsLocked()
1926- rightBarActive := false
1927- var rightBarWidth int
1928- if !i.rightBarHidden && len(rightBarWidgets) > 0 {
1929- if width, rail, ok := tui.RightBarColumns(cols); ok {
1930- mainCols = width
1931- rightBarWidth = rail
1932- rightBarActive = true
1933- }
1934- }
1935- rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1936- i.renderOutsideLock = true
1937- chat := i.cachedChatLocked(mainCols)
1938- i.renderOutsideLock = false
1939- if i.renderRevision.Load() != renderRevision {
1940- i.mu.Unlock()
1941- return
1942- }
1943-
1944- // Dialogs (login or model picker) render between chat and the editor.
1945- var dialog []string
1946- switch {
1947- case i.dialog.Active():
1948- dialog = i.dialog.Render(i.cfg.Theme, mainCols)
1949- case i.modelDialog.Active():
1950- dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
1951- case i.llamaDialog.Active():
1952- dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
1953- case i.rescueDialog.Active():
1954- dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
1955- case i.sessionDialog.Active():
1956- // Reserve rows for the editor (~3), status line (1-2),
1957- // dialog chrome (header + hint + rule + indicators, ~5),
1958- // and leave the remainder for session rows. Minimum of 3
1959- // rows so even a very small terminal shows something.
1960- _, rows := i.cfg.Terminal.Size()
1961- avail := rows - 12
1962- if avail < 3 {
1963- avail = 3
1964- }
1965- i.sessionDialog.MaxRows = avail
1966- dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1967- case i.residentSubagentsDialog.Active():
1968- _, terminalRows := i.cfg.Terminal.Size()
1969- dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows)
1970- case i.residentChildSession != nil:
1971- _, terminalRows := i.cfg.Terminal.Size()
1972- dialog = i.residentChildSession.Render(mainCols, terminalRows)
1973- case i.jumpDialog.Active():
1974- dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
1975- case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1976- dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
1977- case i.confirmDialog.Active():
1978- if i.btwDialog.Active() {
1979- // Keep the side-chat transcript and its live tool preview visible
1980- // above confirmation, matching how the main transcript remains
1981- // visible while --no-yolo waits for a decision.
1982- dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
1983- } else {
1984- dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
1985- }
1986- case i.btwDialog.Active():
1987- dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
1988- case i.skillsDialog.Active():
1989- dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
1990- case i.changelogDialog.Active():
1991- dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
1992- case i.logoutDialog.Active():
1993- dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
1994- case i.telegramDialog.Active():
1995- dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
1996- case i.settingsDialog.Active():
1997- dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
1998- case i.sessionOpsDialog.Active():
1999- dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
2000- case i.sessionTreeDialog.Active():
2001- // Reserve rows for the editor, status, and tree chrome while using
2002- // the same budget for rendering and PageUp/PageDown movement.
2003- _, rows := i.cfg.Terminal.Size()
2004- avail := rows - 12
2005- if avail < 3 {
2006- avail = 3
2007- }
2008- i.sessionTreeDialog.MaxRows = avail
2009- dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
2010- }
2011- if len(dialog) > 0 {
2012- dialog = padDialogFrame(dialog)
2013- }
2014- modalBackdrop := len(dialog) > 0
2015-
2016- // Slash-command autocomplete: popup above the status line, only
2017- // when the editor starts with "/" and no dialog is already open.
2018- // Feed extension-registered commands into the suggester first so
2019- // they show up in tab-complete + the popup alongside the built-ins.
2020- i.suggest.SetJailed(i.cfg.Sandbox.Locked())
2021- i.suggest.SetLlamaConfigured(i.llamaConfigured)
2022- if i.cfg.Extensions != nil {
2023- catalog := i.cfg.Extensions.Commands()
2024- extra := make([]slashCommand, 0, len(catalog))
2025- for _, c := range catalog {
2026- // The popup renders extension commands under a dedicated
2027- // "── extensions ───" divider, so the description doesn't
2028- // need to repeat the source. If the description is empty,
2029- // fall back to the extension name so the row isn't blank.
2030- desc := c.Description
2031- if strings.TrimSpace(desc) == "" {
2032- desc = "(" + c.Extension + ")"
2033- }
2034- extra = append(extra, slashCommand{
2035- Name: "/" + c.Name,
2036- Desc: desc,
2037- })
2038- }
2039- i.suggest.SetExtra(extra)
2040- }
2041- var suggest []string
2042- currentInput := i.ed.Value()
2043- if i.suggest.SkillInputStarted(currentInput) {
2044- var list []*skills.Skill
2045- if i.cfg.SkillSnapshot != nil {
2046- list = i.cfg.SkillSnapshot()
2047- }
2048- i.suggest.SetSkills(list)
2049- }
2050- // Slash popup renders even while the agent is busy so the user
2051- // can queue a destructive command (/clear, /compact, /logout,
2052- // /model) or a read-only one (/help, /jump, /sessions, etc.)
2053- // without waiting for the current turn to finish. The dispatcher
2054- // in runSlash already handles the busy case per-command: safe
2055- // ones run immediately, destructive ones cancel the turn first.
2056- i.fileSuggest.SetCWD(i.cfg.CWD)
2057- mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
2058- if mainInputFocused && i.suggest.Active(currentInput) {
2059- suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
2060- } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
2061- suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
2062- }
2063-
2064- // Detect overlay close (any dialog or slash/file suggestion popup
2065- // just transitioned from open to closed). Force a full redraw so
2066- // the rows the overlay occupied are guaranteed to be repainted
2067- // from the chat below, instead of the diff path leaving stale
2068- // dialog content behind. Equivalent to the user pressing ctrl+l.
2069- overlayOpen := len(dialog) > 0 || len(suggest) > 0
2070- if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
2071- // An overlay (dialog or slash/file popup) just closed, so the
2072- // bottom band shrinks. On terminals where we can drop
2073- // scrollback, a full Clear is the simplest way to guarantee
2074- // the vacated rows are repainted from the chat below.
2075- //
2076- // On VS Code's terminal closing a dialog leaves the stale
2077- // overlay rows in the retained scrollback (we can't drop them
2078- // with the quiet in-place diff). Run the same full Clear() that
2079- // Ctrl+L uses so the scrollback is purged and the conversation
2080- // is repainted clean, matching what the user expects after
2081- // dismissing a picker. Clear() is keepScrollback-aware and
2082- // emits \x1b[3J there.
2083- i.rend.Clear()
2084- }
2085- i.prevOverlayOpen = overlayOpen
2086- if len(suggest) > 0 {
2087- // One blank row above the popup so it doesn't sit flush
2088- // against the chat / welcome content above.
2089- suggest = append([]string{""}, suggest...)
2090- }
2091-
2092- // Busy prefix shown at the far left of the status bar. The spinner owns
2093- // only its frame; the activity label reports the operation in progress.
2094- var busyPrefix string
2095- if i.busy {
2096- label := i.activity.label()
2097- if label == "" {
2098- label = "Preparing request"
2099- }
2100- busyPrefix = fmt.Sprintf("%s %s %s %s",
2101- i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
2102- i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
2103- i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
2104- i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
2105- )
2106- }
2107-
2108- ctxMax := 0
2109- if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
2110- ctxMax = m.ContextWindow
2111- }
2112- statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
2113- workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
2114- workingWithStatus := statusPosition == workingPosition
2115- statusBusyPrefix := ""
2116- if workingWithStatus {
2117- statusBusyPrefix = busyPrefix
2118- }
2119- goalStatus := string(i.goalStatus)
2120- statusLines := tui.StatusBar(tui.StatusBarParams{
2121- Theme: i.cfg.Theme,
2122- Provider: i.cfg.Provider,
2123- Model: i.cfg.Model,
2124- Reasoning: i.cfg.Reasoning,
2125- FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
2126- Busy: i.busy,
2127- BusyPrefix: statusBusyPrefix,
2128- CWD: i.cfg.CWD,
2129- Locked: i.cfg.Sandbox.Locked(),
2130- NoYolo: i.cfg.NoYolo,
2131- GoalStatus: goalStatus,
2132- Usage: i.cumUsage,
2133- Subscription: i.cfg.AuthMethod == "oauth",
2134- ContextUsed: i.lastCtxInput,
2135- ContextMax: ctxMax,
2136- AutoCompacting: i.autoCompacting,
2137- Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
2138- Cols: mainCols,
2139- })
2140- inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
2141- if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
2142- i.ed.Prompt = ""
2143- } else {
2144- i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
2145- }
2146- edLines, curR, curC := i.ed.Render(mainCols)
2147- dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
2148- var allResidentSubagentLines []string
2149- residentSubagentHidden := 0
2150- if !dashboardActive && i.cfg.ResidentManager != nil {
2151- const residentIndicatorSnapshotLimit = 8
2152- snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
2153- allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
2154- residentSubagentHidden = total - len(snapshots)
2155- }
2156- var workingLines []string
2157- if busyPrefix != "" && !workingWithStatus {
2158- workingLines = []string{" " + busyPrefix}
2159- }
2160- inputCursorOffset := 0
2161- inputCursorColOffset := 0
2162- switch inputStyle {
2163- case tui.InputStyleLines:
2164- edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
2165- inputCursorOffset = 1
2166- case tui.InputStyleBlock:
2167- edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
2168- inputCursorOffset = 1
2169- inputCursorColOffset = 2
2170- }
2171-
2172- // "Sliding in" chips for messages the user typed while a turn is
2173- // in flight. Shown directly above the status bar so they're close
2174- // to the editor but don't push the chat around.
2175- var queue []string
2176- queued := append([]string(nil), i.queued...)
2177- if i.agent != nil {
2178- queued = append(queued, i.agent.PendingQueuedMessages()...)
2179- }
2180- if len(queued) > 0 {
2181- queue = append(queue, "")
2182- for _, q := range queued {
2183- label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
2184- text := truncateLine(q, mainCols-17)
2185- queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
2186- }
2187- // Hint row, rendered in the same muted tone as the model
2188- // info on the status bar so it reads as ambient metadata
2189- // rather than a chip. Tells the user how to recover the
2190- // most recent queued message back into the editor.
2191- hint := " Press " + slideBackChordHint() + " to slide back into input"
2192- queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
2193- }
2194-
2195- extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
2196-
2197- // Bottom-sticky sections (always visible, never scroll). Each
2198- // non-empty subsection (dialog, suggest popup, sliding-in queue)
2199- // is preceded by one blank row so it has air above the chat
2200- // content. The status block and editor get their own dedicated
2201- // blanks so spacing stays consistent whether or not a dialog or
2202- // popup is showing.
2203- composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
2204- bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
2205- inputStartRow = -1
2206- if len(dialog) > 0 {
2207- bottom = append(bottom, "")
2208- }
2209- bottom = append(bottom, dialog...)
2210- // The resident child views own the bottom of the screen while active.
2211- if dashboardActive {
2212- return bottom, inputStartRow
2213- }
2214-
2215- bottom = append(bottom, suggest...)
2216- bottom = append(bottom, queue...)
2217- lineInput := inputStyle == tui.InputStyleLines
2218- statusBelow := statusPosition == tui.StatusPositionBelowInput
2219- workingBelow := workingPosition == tui.WorkingPositionBelowInput
2220- var aboveInput []string
2221- aboveInput = append(aboveInput, extensionLines...)
2222- if !statusBelow {
2223- aboveInput = append(aboveInput, statusLines...)
2224- }
2225- if !workingBelow {
2226- aboveInput = append(aboveInput, workingLines...)
2227- }
2228- var belowInput []string
2229- // Background resident work belongs directly below the editor. It is
2230- // deliberately independent of the main turn's busy/status placement.
2231- belowInput = append(belowInput, residentSubagentLines...)
2232- if workingBelow {
2233- belowInput = append(belowInput, workingLines...)
2234- }
2235- if statusBelow {
2236- belowInput = append(belowInput, statusLines...)
2237- }
2238-
2239- bottom = append(bottom, "")
2240- bottom = append(bottom, aboveInput...)
2241- needsGapAboveInput := len(aboveInput) > 0 && !lineInput
2242- if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
2243- needsGapAboveInput = true
2244- }
2245- if needsGapAboveInput {
2246- bottom = append(bottom, "")
2247- }
2248- inputStartRow = len(bottom)
2249- bottom = append(bottom, edLines...)
2250- if len(belowInput) > 0 {
2251- // Active resident work sits immediately below the prompt. Other
2252- // below-input chrome keeps the usual breathing room.
2253- if !lineInput && len(residentSubagentLines) == 0 {
2254- bottom = append(bottom, "")
2255- }
2256- bottom = append(bottom, belowInput...)
2257- }
2258- return bottom, inputStartRow
2259- }
2260-
2261- const rendererBottomMarginRows = 1
2262- maxBottomRows := rows - rendererBottomMarginRows - 1
2263- var residentSubagentLines []string
2264- var bottom []string
2265- var inputStartRow int
2266- if !dashboardActive && len(allResidentSubagentLines) > 0 {
2267- for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
2268- candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
2269- candidateBottom, _ := composeBottom(candidate)
2270- if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
2271- residentSubagentLines = candidate
2272- break
2273- }
2274- }
2275- }
2276- bottom, inputStartRow = composeBottom(residentSubagentLines)
2277-
2278- chatRows := rows - len(bottom)
2279- if chatRows < 1 {
2280- chatRows = 1
2281- }
2282-
2283- // Auto-follow guard: when the user has scrolled up (scrollOffset
2284- // > 0) and the agent appends new content below the viewport while
2285- // they're reading, compensate so the visible content stays
2286- // anchored. scrollOffset is measured from the bottom of `chat`,
2287- // so without compensation a growing buffer pushes the window
2288- // downward through the content and the lines the user was
2289- // reading scroll off the top.
2290- //
2291- // Skip compensation when the terminal width changed (a resize
2292- // reflows the whole buffer and the line-count delta no longer
2293- // corresponds to appended content) and when scrollOffset is 0
2294- // (the user is following the tail and wants new content to push
2295- // the view down as usual).
2296- //
2297- // The window the user sees starts at row
2298- // start = len(chat) - scrollOffset - chatRows
2299- // so to keep `start` fixed across a redraw we must offset by both
2300- // the buffer growth (len delta) AND the viewport-height change
2301- // (chatRows delta, e.g. the status band or sliding-in queue
2302- // appearing during a turn). Compensating only for the len delta
2303- // let a shrinking chatRows pull the window toward the tail, which
2304- // read as the viewport jumping to the bottom whenever the agent
2305- // streamed text or a tool call grew the bottom band.
2306- if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
2307- i.scrollOffset = anchorScrollOffset(i.scrollOffset,
2308- i.prevChatLen, len(chat), i.prevChatRows, chatRows)
2309- }
2310- i.prevChatLen = len(chat)
2311- i.prevChatCols = mainCols
2312- i.prevChatRows = chatRows
2313-
2314- // Apply scroll offset to the chat slice.
2315- maxOffset := len(chat) - chatRows
2316- if maxOffset < 0 {
2317- maxOffset = 0
2318- }
2319- // Tail-render expansion: if the user has scrolled to (or above)
2320- // the top of the currently rendered tail and there are still
2321- // truncated messages above, widen view.TailLimit and rebuild.
2322- // The chat cache is keyed on tailLimit so the next cachedChatLocked
2323- // will re-issue Build instead of returning the stale slice. We
2324- // rebuild immediately so the same redraw shows the freshly-revealed
2325- // rows; otherwise the user would have to scroll again to see them.
2326- if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
2327- prevLen := len(chat)
2328- i.view.TailLimit += resumeTailExpandStep
2329- if i.view.TailLimit >= len(i.view.Messages) {
2330- i.view.TailLimit = 0 // unbounded
2331- }
2332- i.chatCacheValid = false
2333- chat = i.cachedChatLocked(mainCols)
2334- for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
2335- chat = chat[:len(chat)-1]
2336- }
2337- // Newly-revealed rows are older messages prepended at the top.
2338- // scrollOffset counts rows from the bottom, so to keep the
2339- // viewport visually anchored on the same content the user was
2340- // looking at we shift it up by the number of rows added. Keep
2341- // the auto-follow baseline (prevChatLen) in sync with the
2342- // post-expansion length too, so the next render's follow guard
2343- // doesn't see this growth as a synthetic delta and yank the
2344- // viewport again.
2345- if grew := len(chat) - prevLen; grew > 0 {
2346- i.scrollOffset += grew
2347- }
2348- i.prevChatLen = len(chat)
2349- i.prevChatCols = mainCols
2350- maxOffset = len(chat) - chatRows
2351- if maxOffset < 0 {
2352- maxOffset = 0
2353- }
2354- }
2355- if i.scrollOffset > maxOffset {
2356- i.scrollOffset = maxOffset
2357- }
2358- if i.scrollOffset < 0 {
2359- i.scrollOffset = 0
2360- }
2361-
2362- var visibleChat []string
2363- if len(chat) <= chatRows {
2364- visibleChat = chat
2365- } else {
2366- end := len(chat) - i.scrollOffset
2367- rawStart := end - chatRows
2368- if rawStart < 0 {
2369- rawStart = 0
2370- }
2371- start := snapViewportStartToImageBlock(chat, rawStart)
2372- // If the snap pulled start upward (an image-block was atomic) while
2373- // the user is scrolling downward, the viewport would sit on the same
2374- // image until the user mashes down past every reserved row. Bump
2375- // scrollOffset past the image so one keypress always clears it.
2376- if start < rawStart && i.scrollOffset < i.prevScrollOffset {
2377- jump := rawStart - start
2378- i.scrollOffset -= jump
2379- if i.scrollOffset < 0 {
2380- i.scrollOffset = 0
2381- }
2382- end = len(chat) - i.scrollOffset
2383- rawStart = end - chatRows
2384- if rawStart < 0 {
2385- rawStart = 0
2386- }
2387- start = snapViewportStartToImageBlock(chat, rawStart)
2388- }
2389- end = start + chatRows
2390- if end > len(chat) {
2391- end = len(chat)
2392- start = end - chatRows
2393- if start < 0 {
2394- start = 0
2395- }
2396- }
2397- visibleChat = chat[start:end]
2398- }
2399- i.prevScrollOffset = i.scrollOffset
2400- visibleChat = clipBottomClippedImages(visibleChat)
2401-
2402- // A tiny "scrolled up" indicator in the top-right of the chat pane
2403- // so you know you're not at the bottom. When the viewport was
2404- // parked by /jump we include the turn number so the user remembers
2405- // they're reading history rather than the live conversation.
2406- if i.scrollOffset > 0 && len(visibleChat) > 0 {
2407- var text string
2408- if i.parkedTurn > 0 && i.parkedTotal > 0 {
2409- text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
2410- i.parkedTurn, i.parkedTotal, i.scrollOffset)
2411- } else {
2412- text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
2413- }
2414- note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
2415- visibleChat = append([]string{note}, visibleChat...)
2416- if len(visibleChat) > chatRows {
2417- visibleChat = visibleChat[:chatRows]
2418- }
2419- }
2420-
2421- // A dialog is the foreground layer. Dim every other visible surface,
2422- // including the main input beneath it, without changing the dialog's
2423- // own styling. Keep the source slices untouched because chat is cached.
2424- if modalBackdrop {
2425- chat = tui.DimLines(chat)
2426- visibleChat = tui.DimLines(visibleChat)
2427- dimmedBottom := tui.DimLines(bottom)
2428- copy(dimmedBottom[1:1+len(dialog)], dialog)
2429- bottom = dimmedBottom
2430- }
2431-
2432- // Default: the real terminal cursor sits on the main editor's
2433- // input position. In main-screen log mode cursor rows are relative
2434- // to the fixed bottom band, not the chat transcript.
2435- // dialogLead is 1 when the bottom region prepends a blank above
2436- // the dialog block (whenever a dialog is showing) so popup-side
2437- // cursor positions still land in the right cell.
2438- dialogLead := 0
2439- if len(dialog) > 0 {
2440- dialogLead = 1
2441- }
2442- cursorRow := inputStartRow + inputCursorOffset + curR
2443- cursorCol := curC + inputCursorColOffset
2444- cursorInDialog := false
2445- if i.btwDialog.Active() {
2446- if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
2447- cursorRow = dialogLead + r
2448- cursorCol = c
2449- cursorInDialog = true
2450- }
2451- }
2452- if i.dialog.Active() {
2453- if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
2454- cursorRow = dialogLead + r
2455- cursorCol = c
2456- cursorInDialog = true
2457- }
2458- }
2459- if i.llamaDialog.Active() {
2460- if r, c := i.llamaDialog.CursorPos(); r >= 0 {
2461- cursorRow = dialogLead + r
2462- cursorCol = c
2463- cursorInDialog = true
2464- } else {
2465- cursorRow = -1
2466- cursorCol = 0
2467- }
2468- }
2469- if i.sessionDialog.Active() {
2470- if r, c := i.sessionDialog.CursorPos(); r >= 0 {
2471- cursorRow = dialogLead + r
2472- cursorCol = c
2473- cursorInDialog = true
2474- }
2475- }
2476- if i.sessionTreeDialog.Active() {
2477- if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
2478- cursorRow = dialogLead + r
2479- cursorCol = c
2480- cursorInDialog = true
2481- }
2482- }
2483- if i.extPanel.Active() {
2484- cursorRow = -1
2485- cursorCol = 0
2486- }
2487- i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2488- if i.renderRevision.Load() != renderRevision {
2489- i.mu.Unlock()
2490- return
2491- }
2492- theme := i.cfg.Theme
2493- // State assembly above is synchronized; terminal output is owned by
2494- // this renderer goroutine and must not hold the global state mutex.
2495- i.mu.Unlock()
2496- if rightBarActive {
2497- rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
2498- if modalBackdrop {
2499- i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
2500- } else {
2501- i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2502- }
2503- } else {
2504- _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2505- i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2506- }
2507- i.mu.Lock()
2508- if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
2509- alert := *i.pendingAlert
2510- i.pendingAlert = nil
2511- i.emitAlertLocked(alert)
2512- }
2513- i.mu.Unlock()
2514-}
2515-
2516-func hasImageEscape(line string) bool {
2517- return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
2518-}
25191402
25201403 // snapViewportStartToImageBlock treats inline images as atomic blocks for
25211404 // scrolling. Terminal image protocols draw from a single escape row into a
@@ -2523,3188 +1406,130 @@ func hasImageEscape(line string) bool {
25231406 // footprint. If the viewport starts on one of those blank rows, there is no
25241407 // correct partial-image state to render. Snap back to the escape row instead
25251408 // so the image is either shown from its beginning or skipped entirely.
2526-func snapViewportStartToImageBlock(chat []string, start int) int {
2527- if start <= 0 || start >= len(chat) {
2528- return start
2529- }
2530- if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
2531- return start
2532- }
2533- for k := start - 1; k >= 0; k-- {
2534- line := chat[k]
2535- if hasImageEscape(line) {
2536- return k
2537- }
2538- if !isBoxBlankLine(line) {
2539- break
2540- }
2541- }
2542- return start
2543-}
25441409
25451410 const (
2546- hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
2547- autoCompactContinueMetaKey = "auto_compact_continue"
2548- shellEscapeMetaKey = "shell_escape"
2549-)
2550-
2551-func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
2552- if len(msgs) == 0 {
2553- return nil
2554- }
2555- out := make([]provider.Message, 0, len(msgs))
2556- for _, m := range msgs {
2557- if isHiddenTranscriptMessage(m) {
2558- continue
2559- }
2560- out = append(out, m)
2561- }
2562- return out
2563-}
2564-
2565-func isHiddenTranscriptMessage(m provider.Message) bool {
2566- if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
2567- return true
2568- }
2569- if m.Role != provider.RoleUser || len(m.Content) == 0 {
2570- return false
2571- }
2572- tb, ok := m.Content[0].(provider.TextBlock)
2573- if !ok {
2574- return false
2575- }
2576- return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
2577-}
2578-
2579-func clipBottomClippedImages(lines []string) []string {
2580- if len(lines) == 0 {
2581- return lines
2582- }
2583- out := append([]string(nil), lines...)
2584- for i, line := range out {
2585- if !hasImageEscape(line) {
2586- continue
2587- }
2588- // Image blocks render as: image escape, zero or more blank
2589- // reservation rows, then the muted "image - ..." info line,
2590- // then one trailing blank. If the info line isn't visible in
2591- // the current chat slice, the image would paint down into the
2592- // fixed status bar area. Suppress that image for this frame.
2593- //
2594- // When the image lives inside a tool box, the reservation rows
2595- // are wrapped in vertical box edges ("│ ... │"); those rows
2596- // look non-blank under a naive whitespace check but are still
2597- // reservation rows for this scan, so treat them as blank.
2598- foundInfo := false
2599- for j := i + 1; j < len(out); j++ {
2600- if strings.Contains(out[j], "image - ") {
2601- foundInfo = true
2602- break
2603- }
2604- if !isBoxBlankLine(out[j]) {
2605- break
2606- }
2607- }
2608- if !foundInfo {
2609- out[i] = ""
2610- }
2611- }
2612- return out
2613-}
2614-
2615-// isBoxBlankLine reports whether line is visually empty after
2616-// stripping ANSI escape sequences, surrounding whitespace, and the
2617-// vertical box edges drawn by the tool-box renderer. Used by
2618-// clipBottomClippedImages so an image's reservation rows still count
2619-// as blank when those rows are wrapped in "│ ... │" inside a tool box.
2620-func isBoxBlankLine(line string) bool {
2621- stripped := stripANSIBytes(line)
2622- stripped = strings.TrimSpace(stripped)
2623- stripped = strings.Trim(stripped, "│")
2624- stripped = strings.TrimSpace(stripped)
2625- return stripped == ""
2626-}
2627-
2628-// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
2629-// byte) from s without pulling in the regexp package. Mirrors the
2630-// internal helper in package tui; the duplicated copy avoids exporting
2631-// it just for one caller.
2632-func stripANSIBytes(s string) string {
2633- if !strings.Contains(s, "\x1b") {
2634- return s
2635- }
2636- var b strings.Builder
2637- b.Grow(len(s))
2638- i := 0
2639- for i < len(s) {
2640- if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
2641- end := i + 2
2642- for end < len(s) {
2643- c := s[end]
2644- end++
2645- if c >= 0x40 && c <= 0x7e {
2646- break
2647- }
2648- }
2649- i = end
2650- continue
2651- }
2652- b.WriteByte(s[i])
2653- i++
2654- }
2655- return b.String()
2656-}
2657-
2658-// truncateLine shortens s so it fits within n display cells, with an
2659-// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
2660-// novel doesn't blow past the status line.
2661-func panelKeyName(k tui.Key) string {
2662- switch k.Kind {
2663- case tui.KeyUp:
2664- return "up"
2665- case tui.KeyDown:
2666- return "down"
2667- case tui.KeyLeft:
2668- return "left"
2669- case tui.KeyRight:
2670- return "right"
2671- case tui.KeyEnter:
2672- return "enter"
2673- case tui.KeyEsc:
2674- return "esc"
2675- case tui.KeyTab:
2676- return "tab"
2677- case tui.KeyBackspace:
2678- return "backspace"
2679- case tui.KeyDelete:
2680- return "delete"
2681- case tui.KeyHome:
2682- return "home"
2683- case tui.KeyEnd:
2684- return "end"
2685- case tui.KeyPageUp:
2686- return "pageup"
2687- case tui.KeyPageDown:
2688- return "pagedown"
2689- case tui.KeyRune:
2690- return "rune"
2691- default:
2692- return "unknown"
2693- }
2694-}
2695-
2696-func panelKeyText(k tui.Key) string {
2697- if k.Kind == tui.KeyRune {
2698- return string(k.Rune)
2699- }
2700- return ""
2701-}
2702-
2703-func truncateLine(s string, n int) string {
2704- if n <= 0 {
2705- return ""
2706- }
2707- // Collapse newlines — chips are single line.
2708- s = strings.ReplaceAll(s, "\n", " ↩ ")
2709- runes := []rune(s)
2710- if len(runes) <= n {
2711- return s
2712- }
2713- if n <= 3 {
2714- return strings.Repeat(".", n)
2715- }
2716- return string(runes[:n-3]) + "..."
2717-}
2718-
2719-// ctrlCExitWindow is how long after a ctrl+c press a *second* press
2720-// will exit instead of just clearing input. Long enough to be
2721-// deliberate (rules out accidental key chord), short enough that the
2722-// hint stays meaningful.
2723-const ctrlCExitWindow = 2 * time.Second
2724-
2725-// armCtrlCExit records the timestamp of the current ctrl+c so the next
2726-// one within ctrlCExitWindow exits.
2727-func (i *Interactive) armCtrlCExit() {
2728- i.mu.Lock()
2729- i.lastCtrlC = time.Now()
2730- i.mu.Unlock()
2731-}
2732-
2733-// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
2734-// that another press should now exit.
2735-func (i *Interactive) ctrlCExitArmed() bool {
2736- i.mu.Lock()
2737- t := i.lastCtrlC
2738- i.mu.Unlock()
2739- return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
2740-}
2741-
2742-// clearFileSuggestQuery strips the filter the user typed after the
2743-// last "@", leaving the bare "@" so the picker stays open. Called when
2744-// navigating between directory levels (Right/Left): the filter applied
2745-// to the level the user was on, not the one being entered, so carrying
2746-// it forward would wrongly hide the new directory's contents.
2747-func (i *Interactive) clearFileSuggestQuery() {
2748- val := i.ed.Value()
2749- if idx := strings.LastIndex(val, "@"); idx >= 0 {
2750- i.ed.SetValue(val[:idx+1])
2751- }
2752-}
2753-
2754-// setToolExpansion updates long tool result expansion and replays the
2755-// transcript so already-emitted rows reflect the new state.
2756-func (i *Interactive) setToolExpansion(expanded bool) {
2757- i.mu.Lock()
2758- i.view.ExpandAll = expanded
2759- i.requestRendererClear()
2760- i.mu.Unlock()
2761- i.invalidate()
2762-}
2763-
2764-func (i *Interactive) toggleToolExpansion() {
2765- i.mu.Lock()
2766- expanded := !i.view.ExpandAll
2767- i.mu.Unlock()
2768- i.setToolExpansion(expanded)
2769-}
2770-
2771-func (i *Interactive) toggleBtwToolExpansion() {
2772- i.btwDialog.ToggleToolExpansion()
2773- i.mu.Lock()
2774- i.requestRendererClear()
2775- i.mu.Unlock()
2776- i.invalidate()
2777-}
2778-
2779-// confirmChildActive reports whether an interaction opened from slash input
2780-// currently owns the keyboard while a tool confirmation remains pending.
2781-func (i *Interactive) confirmChildActive() bool {
2782- i.mu.Lock()
2783- defer i.mu.Unlock()
2784- return i.confirmChildActiveLocked()
2785-}
2786-
2787-func (i *Interactive) confirmChildActiveLocked() bool {
2788- return len(i.helpBlock) > 0 ||
2789- len(i.extNotes) > 0 ||
2790- i.dialog.Active() ||
2791- i.modelDialog.Active() ||
2792- i.llamaDialog.Active() ||
2793- i.rescueDialog.Active() ||
2794- i.sessionDialog.Active() ||
2795- i.residentSubagentsDialog.Active() ||
2796- i.residentChildSession != nil ||
2797- i.jumpDialog.Active() ||
2798- i.btwDialog.Active() ||
2799- i.skillsDialog.Active() ||
2800- i.changelogDialog.Active() ||
2801- i.logoutDialog.Active() ||
2802- i.telegramDialog.Active() ||
2803- i.settingsDialog.Active() ||
2804- i.sessionOpsDialog.Active() ||
2805- i.sessionTreeDialog.Active() ||
2806- i.extPanel.Active()
2807-}
2808-
2809-// restoreConfirmFocus returns input to confirmation after slash input is
2810-// cleared or a child interaction closes. A non-empty editor keeps command
2811-// input focused, and an active child continues to own its keys.
2812-func (i *Interactive) restoreConfirmFocus() {
2813- if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
2814- return
2815- }
2816- if !i.ed.IsEmpty() || i.confirmChildActive() {
2817- return
2818- }
2819- i.confirmDialog.Focus()
2820- i.invalidate()
2821-}
2822-
2823-func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2824- defer i.restoreConfirmFocus()
2825-
2826- // A bare Escape is eligible for the session-tree gesture only while the
2827- // main input owns the key. Any other key, modified Escape, or visible
2828- // child interaction resets the pending first tap before normal routing.
2829- if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() {
2830- i.mu.Lock()
2831- i.doubleEscape.Reset()
2832- i.mu.Unlock()
2833- }
2834-
2835- // Dialogs route keys before the main clipboard handler below. Resolve text
2836- // here when a child interaction owns input so every editor and filter sees
2837- // the same KeyPaste event as terminal-native bracketed paste. Main chat is
2838- // left image-first to preserve macOS clipboard image attachments.
2839- if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
2840- resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
2841- if err != nil {
2842- i.mu.Lock()
2843- i.statusErr = "clipboard paste failed: " + err.Error()
2844- i.statusOK = ""
2845- i.mu.Unlock()
2846- return false
2847- }
2848- if pastedText {
2849- k = resolved
2850- i.mu.Lock()
2851- i.statusErr = ""
2852- i.statusOK = ""
2853- i.mu.Unlock()
2854- }
2855- }
2856-
2857- // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
2858- // pressing ctrl+c then typing then ctrl+c much later doesn't quit
2859- // unexpectedly. The hint message also goes stale; clear it.
2860- if k.Kind != tui.KeyCtrlC {
2861- i.mu.Lock()
2862- if !i.lastCtrlC.IsZero() {
2863- i.lastCtrlC = time.Time{}
2864- if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
2865- i.statusOK = ""
2866- }
2867- }
2868- i.mu.Unlock()
2869- }
2870-
2871- // Dialogs consume keys while open (except ctrl+c, which always closes them).
2872-
2873- // Confirmation owns input by default. Pressing / deliberately moves
2874- // focus to the main editor without answering the pending request, so
2875- // slash commands and the dialogs they open can run first.
2876- if i.confirmDialog.Focused() {
2877- if k.Kind == tui.KeyRune && k.Rune == '/' {
2878- i.confirmDialog.Blur()
2879- i.ed.Insert("/")
2880- i.suggest.Reset()
2881- i.invalidate()
2882- return false
2883- }
2884- if k.Kind == tui.KeyCtrlO {
2885- if i.btwDialog != nil && i.btwDialog.Active() {
2886- i.toggleBtwToolExpansion()
2887- } else {
2888- i.toggleToolExpansion()
2889- }
2890- return false
2891- }
2892- i.confirmDialog.HandleKey(k)
2893- i.invalidate()
2894- return false
2895- }
2896- if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
2897- i.ed.Clear()
2898- i.suggest.Reset()
2899- i.fileSuggest.Reset()
2900- i.confirmDialog.Focus()
2901- i.invalidate()
2902- return false
2903- }
2904- if i.dialog.Active() {
2905- if k.Kind == tui.KeyCtrlC {
2906- i.dialog.Close()
2907- if i.cfg.AuthManager != nil {
2908- i.cfg.AuthManager.CancelOAuth()
2909- }
2910- return false
2911- }
2912- act := i.dialog.HandleKey(k)
2913- if act.StartAPIKey {
2914- i.startAPIKeyFlow(act.Provider)
2915- }
2916- if act.StartOAuth {
2917- i.startOAuthFlow(act.Provider)
2918- }
2919- if act.StartManual {
2920- i.startManualOAuthFlow(act.Provider)
2921- }
2922- if act.SubmitCode != "" {
2923- i.submitManualOAuthCode(act.SubmitCode)
2924- }
2925- if act.SaveLlama {
2926- i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
2927- }
2928- return false
2929- }
2930- if i.modelDialog.Active() {
2931- if k.Kind == tui.KeyCtrlC {
2932- i.modelDialog.Close()
2933- i.quickModelAssign = 0
2934- return false
2935- }
2936- act := i.modelDialog.HandleKey(k)
2937- if act.Close {
2938- i.quickModelAssign = 0
2939- }
2940- if act.ReasoningChanged {
2941- i.applyReasoningSetting(act.Reasoning)
2942- }
2943- if act.Select {
2944- if i.quickModelAssign > 0 {
2945- i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
2946- i.quickModelAssign = 0
2947- } else {
2948- i.applyModelSelection(act.Provider, act.Model)
2949- }
2950- }
2951- return false
2952- }
2953- if i.llamaDialog.Active() {
2954- i.llamaDialog.HandleKey(k)
2955- i.invalidate()
2956- return false
2957- }
2958- if i.rescueDialog.Active() {
2959- if k.Kind == tui.KeyCtrlC {
2960- i.rescueDialog.Close()
2961- i.invalidate()
2962- return false
2963- }
2964- act := i.rescueDialog.HandleKey(k)
2965- if act.Select {
2966- i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
2967- }
2968- i.invalidate()
2969- return false
2970- }
2971- i.mu.Lock()
2972- sessionDialogActive := i.sessionDialog.Active()
2973- i.mu.Unlock()
2974- if sessionDialogActive {
2975- if k.Kind == tui.KeyCtrlC {
2976- i.mu.Lock()
2977- i.sessionDialog.Close()
2978- i.sessionLoads = nil
2979- i.mu.Unlock()
2980- i.invalidate()
2981- return false
2982- }
2983- manualRenamePath := ""
2984- i.mu.Lock()
2985- if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
2986- manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
2987- }
2988- i.mu.Unlock()
2989- manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
2990- if manualRenameCurrent {
2991- i.markSessionTitleSwitching()
2992- }
2993- i.mu.Lock()
2994- act := i.sessionDialog.HandleKey(k)
2995- if act.Select || act.Close {
2996- i.sessionLoads = nil
2997- }
2998- i.mu.Unlock()
2999- if act.Select {
3000- i.applySessionSelection(act.Path)
3001- }
3002- if act.Err != nil {
3003- if manualRenameCurrent {
3004- i.restoreFailedSessionTitle()
3005- }
3006- i.mu.Lock()
3007- i.statusErr = "rename: " + act.Err.Error()
3008- i.statusOK = ""
3009- i.mu.Unlock()
3010- } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
3011- i.setManualSessionTitle(act.RenameTitle)
3012- }
3013- // Always request a redraw after handling a key here: when esc
3014- // closes the picker, the overlay-close detection in the render
3015- // pass needs to run so the tall dialog rows get repainted from
3016- // the chat (otherwise VS Code's retained scrollback leaves a
3017- // duplicate frame on screen).
3018- i.invalidate()
3019- return false
3020- }
3021- i.mu.Lock()
3022- residentDialog := i.residentSubagentsDialog
3023- residentDialogActive := residentDialog != nil && residentDialog.Active()
3024- residentSession := i.residentChildSession
3025- i.mu.Unlock()
3026- if residentDialogActive {
3027- if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
3028- i.mu.Lock()
3029- if i.residentSubagentsDialog == residentDialog {
3030- residentDialog.Close()
3031- }
3032- i.mu.Unlock()
3033- i.invalidate()
3034- return false
3035- }
3036- i.mu.Lock()
3037- childID := residentDialog.HandleKey(k)
3038- i.mu.Unlock()
3039- if childID != "" {
3040- i.openResidentChildSession(childID)
3041- }
3042- i.invalidate()
3043- return false
3044- }
3045- if residentSession != nil {
3046- switch k.Kind {
3047- case tui.KeyCtrlC, tui.KeyEsc:
3048- i.mu.Lock()
3049- if i.residentChildSession == residentSession {
3050- i.residentChildSession = nil
3051- }
3052- i.mu.Unlock()
3053- case tui.KeyPageUp:
3054- session := residentSession
3055- if session.BeginLoad() {
3056- go func() {
3057- err := session.LoadOlder(200)
3058- if session.FinishLoad(err) {
3059- i.reloadResidentChildSession(session)
3060- }
3061- i.invalidate()
3062- }()
3063- }
3064- case tui.KeyPageDown:
3065- residentSession.FollowTail()
3066- case tui.KeyUp:
3067- residentSession.Scroll(1)
3068- case tui.KeyDown:
3069- residentSession.Scroll(-1)
3070- default:
3071- session := residentSession
3072- if prompt, submit := session.HandleKey(k); submit {
3073- go func() {
3074- err := i.cfg.ResidentManager.Resume(ctx, session.childID, prompt)
3075- session.FinishSubmission(err)
3076- i.invalidate()
3077- }()
3078- }
3079- }
3080- i.invalidate()
3081- return false
3082- }
3083- if i.logoutDialog.Active() {
3084- if k.Kind == tui.KeyCtrlC {
3085- i.logoutDialog.Close()
3086- i.invalidate()
3087- return false
3088- }
3089- act := i.logoutDialog.HandleKey(k)
3090- if act.Select {
3091- i.doLogout(act.Target)
3092- }
3093- i.invalidate()
3094- return false
3095- }
3096- if i.telegramDialog.Active() {
3097- if k.Kind == tui.KeyCtrlC {
3098- i.telegramDialog.Close()
3099- i.invalidate()
3100- return false
3101- }
3102- act := i.telegramDialog.HandleKey(k)
3103- if act.Select {
3104- i.doTelegram(act.Action)
3105- }
3106- i.invalidate()
3107- return false
3108- }
3109- if i.settingsDialog.Active() {
3110- if k.Kind == tui.KeyCtrlC {
3111- i.settingsDialog.Close()
3112- i.invalidate()
3113- return false
3114- }
3115- act := i.settingsDialog.HandleKey(k)
3116- if act.ModelShortcutSlot > 0 {
3117- i.openQuickModelPicker(act.ModelShortcutSlot)
3118- }
3119- if act.Toggle {
3120- i.applySettingChange(act)
3121- }
3122- i.invalidate()
3123- return false
3124- }
3125- if i.sessionOpsDialog.Active() {
3126- if k.Kind == tui.KeyCtrlC {
3127- i.sessionOpsDialog.Close()
3128- i.invalidate()
3129- return false
3130- }
3131- act := i.sessionOpsDialog.HandleKey(k)
3132- if act.Select {
3133- i.doSessionOp(act.Action, "")
3134- }
3135- i.invalidate()
3136- return false
3137- }
3138- if i.sessionTreeDialog.Active() {
3139- if k.Kind == tui.KeyCtrlC {
3140- i.sessionTreeDialog.Close()
3141- i.invalidate()
3142- return false
3143- }
3144- act := i.sessionTreeDialog.HandleKey(k)
3145- if act.Select {
3146- if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
3147- i.applySessionTreeTarget(act.Target, act.TurnNo)
3148- } else {
3149- // Keep package embedders that still return the scalar action
3150- // source-compatible while the dialog migrates to Target.
3151- i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
3152- }
3153- }
3154- i.invalidate()
3155- return false
3156- }
3157- if i.extPanel.Active() {
3158- if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
3159- if i.cfg.Extensions != nil {
3160- _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
3161- }
3162- i.extPanel.Close()
3163- i.invalidate()
3164- return false
3165- }
3166- if i.cfg.Extensions != nil {
3167- _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
3168- }
3169- return false
3170- }
3171- if i.jumpDialog.Active() {
3172- if k.Kind == tui.KeyCtrlC {
3173- i.jumpDialog.Close()
3174- i.pendingFork = false
3175- return false
3176- }
3177- act := i.jumpDialog.HandleKey(k)
3178- if act.Select {
3179- if i.pendingFork {
3180- i.applyForkSelection(act.MessageIdx)
3181- } else {
3182- i.applyJumpSelection(act.MessageIdx, act.TurnNo)
3183- }
3184- }
3185- // If the user dismissed the dialog without selecting, also
3186- // clear the pending-fork flag so a later plain /jump isn't
3187- // hijacked.
3188- if act.Close {
3189- i.pendingFork = false
3190- }
3191- return false
3192- }
3193- if i.btwDialog.Active() {
3194- if k.Kind == tui.KeyCtrlC {
3195- i.btwDialog.Close()
3196- i.invalidate()
3197- return false
3198- }
3199- if k.Kind == tui.KeyCtrlO {
3200- i.toggleBtwToolExpansion()
3201- return false
3202- }
3203- i.btwDialog.HandleKey(k, i.invalidate)
3204- return false
3205- }
3206- if i.skillsDialog.Active() {
3207- if k.Kind == tui.KeyCtrlC {
3208- i.skillsDialog.Close()
3209- i.invalidate()
3210- return false
3211- }
3212- i.skillsDialog.HandleKey(k)
3213- i.invalidate()
3214- return false
3215- }
3216- if i.changelogDialog.Active() {
3217- if closed := i.changelogDialog.HandleKey(k); closed {
3218- // User dismissed; let the parent persist the
3219- // LastChangelogShown marker via the close callback.
3220- if i.cfg.OnChangelogDismiss != nil {
3221- i.cfg.OnChangelogDismiss()
3222- }
3223- }
3224- i.invalidate()
3225- return false
3226- }
3227-
3228- if slot := quickModelShortcutSlot(k); slot > 0 {
3229- i.applyQuickModelShortcut(slot)
3230- return false
3231- }
3232-
3233- // The first eligible bare Escape is deliberately left to the existing
3234- // Escape path (which clears idle input or cancels a turn). A second tap
3235- // within the gesture window only gets first refusal while the cheap,
3236- // local eligibility rules still hold. If a turn started, a message was
3237- // queued, or the input became ineligible between taps, reset the detector
3238- // and fall through so Escape retains its normal cancellation behavior.
3239- // Once those rules still hold, the full tree gate owns the second tap:
3240- // family/read failures are fail-closed but are nevertheless consumed so
3241- // they cannot fall through into ordinary editor handling.
3242- if isUnmodifiedEscape(k) {
3243- now := i.sessionTreeEscapeNow()
3244- i.mu.Lock()
3245- consumed := i.doubleEscape.Consume(now)
3246- i.mu.Unlock()
3247- if consumed {
3248- if i.canArmSessionTreeEscape() {
3249- i.openSessionTree()
3250- return false
3251- }
3252- i.mu.Lock()
3253- i.doubleEscape.Reset()
3254- i.mu.Unlock()
3255- } else if i.canArmSessionTreeEscape() {
3256- i.mu.Lock()
3257- i.doubleEscape.Arm(now)
3258- i.mu.Unlock()
3259- }
3260- }
3261-
3262- // Global keys.
3263- switch k.Kind {
3264- case tui.KeyCtrlC:
3265- i.mu.Lock()
3266- loadingSession := i.sessionLoading
3267- i.mu.Unlock()
3268- if loadingSession {
3269- i.markCtrlCExit()
3270- return true
3271- }
3272- // While busy: do NOT cancel the turn. ctrl+c during a
3273- // running turn is almost always reflex muscle memory
3274- // ("be quiet" in a shell) rather than a deliberate
3275- // decision to kill a multi-minute model call that's
3276- // already cost tokens. Use esc to interrupt a turn; use
3277- // a deliberate double-ctrl+c to exit zut entirely. First
3278- // press arms the exit hint, second press within
3279- // ctrlCExitWindow quits.
3280- if i.busy {
3281- if i.ctrlCExitArmed() {
3282- i.markCtrlCExit()
3283- return true
3284- }
3285- i.mu.Lock()
3286- i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
3287- i.statusErr = ""
3288- i.mu.Unlock()
3289- i.armCtrlCExit()
3290- return false
3291- }
3292- // Idle: first press clears the editor (and any queued
3293- // follow-up messages); a second press within ctrlCExitWindow
3294- // exits. With both an empty editor and no queue the first
3295- // press still just arms — require a deliberate double-tap.
3296- ag := i.agent
3297- pending := 0
3298- if ag != nil {
3299- pending = ag.QueuedMessageCount()
3300- }
3301- hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
3302- if hadInput {
3303- i.ed.Clear()
3304- i.clipboardImages = nil
3305- i.suggest.Reset()
3306- if ag != nil {
3307- ag.DrainQueuedMessages()
3308- }
3309- i.mu.Lock()
3310- i.queued = nil
3311- i.statusOK = "input cleared"
3312- i.statusErr = ""
3313- i.mu.Unlock()
3314- i.armCtrlCExit()
3315- return false
3316- }
3317- if i.ctrlCExitArmed() {
3318- i.markCtrlCExit()
3319- return true
3320- }
3321- i.mu.Lock()
3322- i.statusOK = "press ctrl+c again to exit"
3323- i.statusErr = ""
3324- i.mu.Unlock()
3325- i.armCtrlCExit()
3326- return false
3327- case tui.KeyEsc:
3328- // Esc interrupts a running turn — but only when nothing
3329- // else on screen wants to consume the key first. The slash
3330- // popup has its own Esc behaviour (close + clear editor),
3331- // and transient overlays like the /help block and extension
3332- // notes should dismiss on Esc before we even consider the
3333- // turn. Without these guards, a casual Esc press after
3334- // running /help on a busy turn rips the turn away.
3335- if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
3336- break
3337- }
3338- i.mu.Lock()
3339- hadHelp := len(i.helpBlock) > 0
3340- hadNotes := len(i.extNotes) > 0
3341- if hadHelp {
3342- i.helpBlock = nil
3343- }
3344- if hadNotes {
3345- i.extNotes = nil
3346- }
3347- i.mu.Unlock()
3348- if hadHelp || hadNotes {
3349- i.invalidate()
3350- return false
3351- }
3352- i.mu.Lock()
3353- busyCancel := i.busy && i.cancelTurn != nil
3354- cancelTurn := i.cancelTurn
3355- var restoreQueued string
3356- var handoff json.RawMessage
3357- var persistHandoff bool
3358- if busyCancel {
3359- // Keep the most recently queued follow-up as an editable draft
3360- // instead of losing it with the cancelled turn's stale queue.
3361- if i.agent != nil {
3362- restoreQueued, _ = i.agent.PopQueuedMessage()
3363- }
3364- if restoreQueued == "" && len(i.queued) > 0 {
3365- n := len(i.queued) - 1
3366- restoreQueued = i.queued[n]
3367- i.queued = i.queued[:n]
3368- }
3369- handoff, persistHandoff = i.resetCompactContinuationLocked()
3370- }
3371- i.mu.Unlock()
3372- if busyCancel {
3373- if restoreQueued != "" {
3374- i.ed.SetValue(restoreQueued)
3375- i.inputHistoryIndex = -1
3376- }
3377- i.updateActiveGoal(core.GoalPaused, "interrupted by user")
3378- cancelTurn()
3379- // If a confirm dialog is pending, refuse it so the agent
3380- // goroutine unblocks and the context cancellation can
3381- // actually take effect.
3382- i.confirmDialog.CancelAll("turn cancelled")
3383- if persistHandoff {
3384- i.persistCompactHandoff(handoff)
3385- }
3386- return false
3387- }
3388- case tui.KeyCtrlD:
3389- if i.ed.IsEmpty() && !i.busy {
3390- return true
3391- }
3392- case tui.KeyCtrlB:
3393- i.mu.Lock()
3394- i.rightBarHidden = !i.rightBarHidden
3395- i.mu.Unlock()
3396- i.requestRendererInvalidate()
3397- i.invalidate()
3398- return false
3399- case tui.KeyCtrlL:
3400- i.requestRendererClear()
3401- i.invalidate()
3402- return false
3403- case tui.KeyPasteClipboard:
3404- i.pasteClipboard(ctx)
3405- return false
3406- case tui.KeyCtrlO:
3407- i.toggleToolExpansion()
3408- return false
3409- case tui.KeyPageUp:
3410- i.scrollBy(+i.chatPage())
3411- return false
3412- case tui.KeyPageDown:
3413- i.scrollBy(-i.chatPage())
3414- return false
3415- case tui.KeyUp:
3416- // Alt/Option+Up: pop the most recently queued ("sliding in")
3417- // message back into the editor so the user can edit and
3418- // resend it. Repeated presses keep peeling messages off the
3419- // tail of the queue; each press *replaces* the editor
3420- // contents (we don't append/push). When the queue is empty
3421- // the keypress falls through to the normal scroll behavior.
3422- if k.Alt {
3423- i.mu.Lock()
3424- var text string
3425- if i.agent != nil {
3426- text, _ = i.agent.PopQueuedMessage()
3427- }
3428- if text == "" {
3429- if n := len(i.queued); n > 0 {
3430- text = i.queued[n-1]
3431- i.queued = i.queued[:n-1]
3432- }
3433- }
3434- i.mu.Unlock()
3435- if text != "" {
3436- i.ed.SetValue(text)
3437- i.inputHistoryIndex = -1
3438- i.invalidate()
3439- return false
3440- }
3441- }
3442- // In multi-line / wrapped input, Up first moves inside the editor.
3443- // At the editor's top edge, plain Up can browse input history when
3444- // history browsing is safe/active; otherwise it falls back to chat
3445- // scrolling, preserving the old single-line scroll behavior.
3446- if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3447- if i.ed.MoveVertical(-1) {
3448- i.invalidate()
3449- return false
3450- }
3451- if !k.Alt && i.handleInputHistoryKey(k) {
3452- return false
3453- }
3454- i.scrollBy(+3)
3455- return false
3456- }
3457- case tui.KeyDown:
3458- if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3459- if i.ed.MoveVertical(+1) {
3460- i.invalidate()
3461- return false
3462- }
3463- if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil {
3464- if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 {
3465- i.runResidentSubagents(ctx, nil)
3466- return false
3467- }
3468- }
3469- if !k.Alt && i.handleInputHistoryKey(k) {
3470- return false
3471- }
3472- if i.scrollOffset > 0 {
3473- i.scrollBy(-3)
3474- }
3475- return false
3476- }
3477- }
3478-
3479- // Note: we intentionally do NOT gate the editor on i.busy here.
3480- // Typing while the agent is working is supported — submitted
3481- // messages are queued and delivered as follow-up turns when the
3482- // current turn ends. See the submit handler below.
3483-
3484- if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
3485- i.ed.HandleKey(k)
3486- return false
3487- }
3488-
3489- // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
3490- if i.suggest.Active(i.ed.Value()) {
3491- switch k.Kind {
3492- case tui.KeyUp:
3493- i.suggest.Up()
3494- return false
3495- case tui.KeyDown:
3496- i.suggest.Down()
3497- return false
3498- case tui.KeyPageUp:
3499- i.suggest.PageUp()
3500- return false
3501- case tui.KeyPageDown:
3502- i.suggest.PageDown()
3503- return false
3504- case tui.KeyTab:
3505- if name := i.suggest.Selection(i.ed.Value()); name != "" {
3506- i.ed.SetValue(name)
3507- i.suggest.Reset()
3508- }
3509- return false
3510- case tui.KeyEnter:
3511- // Enter on an ambiguous or partial slash prefix: complete to the
3512- // currently highlighted command and run it. That way typing
3513- // "/lo" + enter picks whichever of /login or /logout is selected
3514- // in the popup instead of submitting "/lo" as unknown. Also
3515- // clear the editor so the command doesn't linger after the
3516- // dialog opens/closes.
3517- if name := i.suggest.Selection(i.ed.Value()); name != "" {
3518- i.ed.Clear()
3519- i.suggest.Reset()
3520- return i.runSlash(ctx, name)
3521- }
3522- case tui.KeyEsc:
3523- i.ed.Clear()
3524- i.suggest.Reset()
3525- return false
3526- }
3527- }
3528-
3529- // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
3530- if i.fileSuggest.Active(i.ed.Value()) {
3531- switch k.Kind {
3532- case tui.KeyUp:
3533- i.fileSuggest.Up()
3534- return false
3535- case tui.KeyDown:
3536- i.fileSuggest.Down()
3537- return false
3538- case tui.KeyRight:
3539- // Open selected directory. The filter the user typed picked
3540- // that directory at the current level; once we descend it no
3541- // longer applies to the directory's contents, so clear it.
3542- // Otherwise typing "@eda" then right would re-filter inside
3543- // eda/ by "eda" and show nothing.
3544- if i.fileSuggest.Right() {
3545- i.clearFileSuggestQuery()
3546- }
3547- return false
3548- case tui.KeyLeft:
3549- // Go back to parent directory. Clear the filter for the same
3550- // reason as Right: it was scoped to the level we just left.
3551- if i.fileSuggest.Left() {
3552- i.clearFileSuggestQuery()
3553- }
3554- return false
3555- case tui.KeyEnter:
3556- if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
3557- var chip string
3558- if entry.isDir {
3559- chip = "[dir:" + entry.rel + "/]"
3560- } else {
3561- chip = "[file:" + entry.rel + "]"
3562- }
3563- val := i.ed.Value()
3564- if idx := strings.LastIndex(val, "@"); idx >= 0 {
3565- val = val[:idx]
3566- }
3567- i.ed.SetValue(val + chip + " ")
3568- i.fileSuggest.Reset()
3569- }
3570- return false
3571- case tui.KeyEsc:
3572- val := i.ed.Value()
3573- if idx := strings.LastIndex(val, "@"); idx >= 0 {
3574- i.ed.SetValue(val[:idx])
3575- }
3576- i.fileSuggest.Reset()
3577- return false
3578- }
3579- }
3580-
3581- // Tab-complete a path token in the editor when no popup is open.
3582- // Recognises tokens that look like paths (start with ~, /, ./, ../
3583- // or contain a slash); shell-style completion expands ~, lists the
3584- // parent dir, and completes the basename to the longest common
3585- // prefix. Single match: full replace and trailing / for dirs.
3586- // No match: no-op. Plain bare words (no slash, no tilde) fall
3587- // through so Tab keeps its current no-op behaviour outside paths.
3588- if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3589- if i.tryPathTabComplete() {
3590- return false
3591- }
3592- }
3593-
3594- if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
3595- i.inputHistoryIndex = -1
3596- }
3597-
3598- if k.Kind == tui.KeyEsc {
3599- i.clipboardImages = nil
3600- }
3601- if submit := i.ed.HandleKey(k); submit {
3602- // SubmitValue() expands any [pasted text #N +L lines]
3603- // placeholders back into their bodies; the raw Value()
3604- // is only what the user sees on screen.
3605- text := strings.TrimRight(i.ed.SubmitValue(), "\n")
3606- // Expand [file:name] and [dir:name/] chips to full paths.
3607- text = expandFileChips(text, i.cfg.CWD)
3608- text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
3609- if text == "" && len(images) == 0 {
3610- return false
3611- }
3612- clearSubmittedInput := func() {
3613- i.clipboardImages = nil
3614- i.ed.Clear()
3615- i.inputHistoryIndex = -1
3616- i.suggest.Reset()
3617- i.fileSuggest.Reset()
3618- }
3619-
3620- // Shell escapes and slash commands are text-only. An image-bearing
3621- // draft remains a normal provider prompt instead of silently dropping
3622- // its attachments.
3623- if len(images) == 0 {
3624- if cmd, ok := shellEscapeCommand(text); ok {
3625- clearSubmittedInput()
3626- i.startShellEscape(ctx, cmd)
3627- return false
3628- }
3629- }
3630-
3631- if len(images) == 0 && looksLikeSlashCommand(text) {
3632- clearSubmittedInput()
3633- head := text
3634- rest := ""
3635- if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3636- head = text[:idx]
3637- rest = strings.TrimSpace(text[idx:])
3638- }
3639- if !isKnownSlashCommand(text) {
3640- // Try extensions before giving up. Extensions register
3641- // commands by bare name (no leading slash); strip it here.
3642- extName := strings.TrimPrefix(head, "/")
3643- if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
3644- go i.invokeExtensionCommand(ctx, extName, rest)
3645- return false
3646- }
3647- i.mu.Lock()
3648- i.statusErr = "unknown command " + head + " — type /help to see the list"
3649- i.statusOK = ""
3650- i.mu.Unlock()
3651- return false
3652- }
3653- // Slash commands run regardless of busy state. Commands that
3654- // would mutate the transcript or replace the agent (/clear,
3655- // /compact, /logout, /login, /model, /fork, /session fork)
3656- // cancel the active turn
3657- // first and wait for the goroutine to wind down so they don't
3658- // race with a streaming response. Safe commands (/help,
3659- // /jump, /sessions, /jail, /unjail, /exit) run immediately
3660- // without disturbing the active turn.
3661- if slashCommandCancelsTurn(text) {
3662- i.cancelAndWaitForIdle()
3663- }
3664- return i.runSlash(ctx, text)
3665- }
3666-
3667- if i.agent == nil {
3668- i.mu.Lock()
3669- i.statusErr = "not logged in. type /login first."
3670- i.mu.Unlock()
3671- return false
3672- }
3673- // Mirror the user's typed prompt into the paired Telegram
3674- // chat (when the bridge is active) so the Telegram thread
3675- // stays a complete record of the session, not just the half
3676- // that originated on the phone. On a goroutine so the
3677- // network write doesn't delay the local turn.
3678- if i.telegramBridge != nil && i.telegramBridge.Active() {
3679- go i.telegramBridge.OnUserTyped(text)
3680- }
3681- i.maybeStartSessionTitle(ctx, text)
3682- // If a turn is already in flight, queue this prompt inside the
3683- // agent loop so it is delivered at the next safe model-call
3684- // boundary instead of waiting for the whole run to finish.
3685- i.mu.Lock()
3686- busy := i.busy
3687- compacting := i.compacting
3688- ag := i.agent
3689- i.mu.Unlock()
3690- if busy {
3691- if len(images) > 0 {
3692- i.mu.Lock()
3693- i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
3694- i.mu.Unlock()
3695- i.invalidate()
3696- return false
3697- }
3698- clearSubmittedInput()
3699- var handoff json.RawMessage
3700- var persistHandoff bool
3701- if ag != nil && !compacting {
3702- i.mu.Lock()
3703- handoff, persistHandoff = i.resetCompactContinuationLocked()
3704- ag.QueueMessage(text)
3705- i.mu.Unlock()
3706- } else {
3707- i.mu.Lock()
3708- handoff, persistHandoff = i.resetCompactContinuationLocked()
3709- i.queued = append(i.queued, text)
3710- i.mu.Unlock()
3711- }
3712- if persistHandoff {
3713- i.persistCompactHandoff(handoff)
3714- }
3715- i.invalidate()
3716- return false
3717- }
3718- clearSubmittedInput()
3719- i.startTurnWithImages(ctx, text, images)
3720- }
3721- return false
3722-}
3723-
3724-func (i *Interactive) pasteClipboard(ctx context.Context) {
3725- image, ok, _ := tui.ReadClipboardImage(ctx)
3726- if ok {
3727- i.mu.Lock()
3728- marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
3729- i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
3730- Marker: marker,
3731- Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
3732- })
3733- i.ed.Insert(marker + " ")
3734- i.statusOK = ""
3735- i.statusErr = ""
3736- i.mu.Unlock()
3737- return
3738- }
3739- key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
3740- i.mu.Lock()
3741- defer i.mu.Unlock()
3742- if err != nil {
3743- i.statusErr = "clipboard paste failed: " + err.Error()
3744- i.statusOK = ""
3745- return
3746- }
3747- if pastedText {
3748- i.ed.HandleKey(key)
3749- i.statusErr = ""
3750- i.statusOK = ""
3751- return
3752- }
3753- i.statusErr = "clipboard does not contain text or an image"
3754- i.statusOK = ""
3755-}
3756-
3757-func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
3758- if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
3759- return false
3760- }
3761- // Do not steal normal vertical cursor movement. History browsing can only
3762- // start from an empty editor; once active, Up/Down keep walking
3763- // the ring so repeated presses work even though the editor now
3764- // contains the selected historical prompt.
3765- if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
3766- return false
3767- }
3768- hist := i.inputHistory()
3769- if len(hist) == 0 {
3770- return false
3771- }
3772-
3773- if i.inputHistoryIndex < 0 {
3774- // Start just after the newest item so Up lands on the most
3775- // recent user prompt. A lone Down from an empty editor is not
3776- // history navigation; let the caller fall through to normal UI
3777- // behavior instead.
3778- if k.Kind != tui.KeyUp {
3779- return false
3780- }
3781- i.inputHistoryIndex = len(hist)
3782- }
3783-
3784- switch k.Kind {
3785- case tui.KeyUp:
3786- if i.inputHistoryIndex > 0 {
3787- i.inputHistoryIndex--
3788- }
3789- case tui.KeyDown:
3790- if i.inputHistoryIndex < len(hist) {
3791- i.inputHistoryIndex++
3792- }
3793- }
3794-
3795- if i.inputHistoryIndex >= len(hist) {
3796- i.ed.Clear()
3797- } else {
3798- i.ed.SetValue(hist[i.inputHistoryIndex])
3799- }
3800- return true
3801-}
3802-
3803-func (i *Interactive) inputHistory() []string {
3804- if i.agent == nil {
3805- return nil
3806- }
3807- msgs := i.agent.Messages()
3808- hist := make([]string, 0, len(msgs))
3809- for _, m := range msgs {
3810- if !isForkableUserMessage(m) {
3811- continue
3812- }
3813- text := userMessageText(m)
3814- if strings.TrimSpace(text) == "" {
3815- continue
3816- }
3817- hist = append(hist, text)
3818- }
3819- return hist
3820-}
3821-
3822-func userMessageText(m provider.Message) string {
3823- var sb strings.Builder
3824- for _, c := range m.Content {
3825- if tb, ok := c.(provider.TextBlock); ok {
3826- if sb.Len() > 0 {
3827- sb.WriteByte('\n')
3828- }
3829- sb.WriteString(tb.Text)
3830- }
3831- }
3832- return sb.String()
3833-}
3834-
3835-// invokeExtensionCommand fires an extension-registered slash command
3836-// in a background goroutine, awaits the response, and applies the
3837-// requested action (prompt / insert / display / noop). Errors and
3838-// timeouts surface as a status_err line.
3839-
3840-func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
3841- resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
3842- if err != nil {
3843- i.mu.Lock()
3844- i.statusErr = "extension /" + name + ": " + err.Error()
3845- i.statusOK = ""
3846- i.mu.Unlock()
3847- i.invalidate()
3848- return
3849- }
3850- if resp.Error != "" {
3851- i.mu.Lock()
3852- i.statusErr = "extension /" + name + ": " + resp.Error
3853- i.statusOK = ""
3854- i.mu.Unlock()
3855- i.invalidate()
3856- return
3857- }
3858- switch resp.Action {
3859- case "open_panel":
3860- if resp.OpenPanel != nil {
3861- extName := name
3862- if i.cfg.Extensions != nil {
3863- if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
3864- extName = owner
3865- }
3866- }
3867- i.OpenPanel(extName, *resp.OpenPanel)
3868- }
3869- case "prompt":
3870- if strings.TrimSpace(resp.Prompt) == "" {
3871- return
3872- }
3873- i.startTurn(i.runCtx, resp.Prompt)
3874- case "insert":
3875- i.ed.Insert(resp.Insert)
3876- i.invalidate()
3877- case "display":
3878- i.appendExtensionNote(name, resp.Display, "info")
3879- case "noop", "":
3880- // nothing
3881- default:
3882- i.mu.Lock()
3883- i.statusErr = "extension /" + name + ": unknown action " + resp.Action
3884- i.mu.Unlock()
3885- i.invalidate()
3886- }
3887-}
3888-
3889-// appendExtensionNote renders an extension-originated note in the
3890-// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
3891-// "success" (tool/ok green).
3892-func (i *Interactive) appendExtensionNote(extName, msg, level string) {
3893- if msg == "" {
3894- return
3895- }
3896- i.mu.Lock()
3897- defer i.mu.Unlock()
3898- color := i.cfg.Theme.Muted
3899- switch level {
3900- case "warn":
3901- color = i.cfg.Theme.Warning
3902- case "error":
3903- color = i.cfg.Theme.Error
3904- case "success":
3905- color = i.cfg.Theme.Tool
3906- }
3907- prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
3908- for _, line := range strings.Split(msg, "\n") {
3909- i.statusOK = "" // clear any stale ok
3910- i.statusErr = ""
3911- i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
3912- }
3913- // Extension slash commands complete asynchronously. If their result is
3914- // displayed while confirmation is waiting, keep Esc assigned to the note
3915- // until it is dismissed rather than letting it answer the tool call.
3916- i.confirmDialog.Blur()
3917-}
3918-
3919-// HostHooks implementation for the extension manager. The manager
3920-// holds an interface, not a concrete *Interactive, so these methods
3921-// are the only thing the manager sees.
3922-
3923-// Notify is the manager's NotifyFromExt entry point.
3924-func (i *Interactive) Notify(extName, level, message string) {
3925- i.appendExtensionNote(extName, message, level)
3926- i.invalidate()
3927-}
3928-
3929-// Alert is the manager's AlertFromExt entry point. Alerts are emitted
3930-// through the same terminal writer as redraws while holding the TUI mutex,
3931-// so a BEL cannot be interleaved with a frame update.
3932-func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) {
3933- i.mu.Lock()
3934- i.emitAlertLocked(alert)
3935- i.mu.Unlock()
3936-}
3937-
3938-func terminalAlertsEnabled(enabled *bool) bool {
3939- return enabled == nil || *enabled
3940-}
3941-
3942-// emitAlertLocked applies the shared terminal-alert policy. The caller must
3943-// hold i.mu because terminal writes share the renderer's output boundary.
3944-func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
3945- if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil {
3946- return
3947- }
3948- _ = tui.WriteBell(i.cfg.Terminal)
3949-}
3950-
3951-// scheduleMainAlert defers a main-session alert until the next redraw
3952-// commits the final frame. Keeping this deferred even when no paced text is
3953-// pending avoids racing the pacer's final state transition.
3954-func (i *Interactive) scheduleMainAlert(reason string) {
3955- if reason == "" {
3956- return
3957- }
3958- i.mu.Lock()
3959- i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
3960- i.mu.Unlock()
3961- i.invalidate()
3962-}
3963-
3964-// ClearNotes removes every note line owned by extName from the
3965-// bottom-sticky ext-notes block. Extensions use this to retract a
3966-// transient status line (e.g. an approval prompt) once it no longer
3967-// applies, instead of leaving it stacked forever. Notes from other
3968-// extensions and internal notes (auto-compact) are left untouched.
3969-func (i *Interactive) ClearNotes(extName string) {
3970- marker := "[" + extName + "] "
3971- i.mu.Lock()
3972- if len(i.extNotes) == 0 {
3973- i.mu.Unlock()
3974- return
3975- }
3976- kept := i.extNotes[:0:0]
3977- changed := false
3978- for _, line := range i.extNotes {
3979- if strings.Contains(line, marker) {
3980- changed = true
3981- continue
3982- }
3983- kept = append(kept, line)
3984- }
3985- if changed {
3986- i.extNotes = kept
3987- }
3988- i.mu.Unlock()
3989- if changed {
3990- i.invalidate()
3991- }
3992-}
3993-
3994-// Submit feeds text through the agent loop as if the user had typed it.
3995-func (i *Interactive) Submit(text string) {
3996- if cmd, ok := shellEscapeCommand(text); ok {
3997- i.startShellEscape(i.runCtx, cmd)
3998- return
3999- }
4000- parent := i.runCtx
4001- if parent == nil {
4002- parent = context.Background()
4003- }
4004- i.mu.Lock()
4005- awaitingStartupPre := i.awaitingStartupPre
4006- i.mu.Unlock()
4007- if !awaitingStartupPre {
4008- i.maybeStartSessionTitle(parent, text)
4009- }
4010- i.startTurn(parent, text)
4011-}
4012-
4013-// completeStartupPre applies deferred InitialInput after entry.pre finishes.
4014-// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
4015-// it only pre-fills the editor (CLI-supplied prompts).
4016-func (i *Interactive) completeStartupPre() {
4017- i.mu.Lock()
4018- if !i.awaitingStartupPre {
4019- i.mu.Unlock()
4020- return
4021- }
4022- i.awaitingStartupPre = false
4023- deferred := i.deferredInitialInput
4024- auto := i.autoSubmitDeferred
4025- i.deferredInitialInput = ""
4026- i.autoSubmitDeferred = false
4027- onDone := i.cfg.OnStartupPreDone
4028- i.mu.Unlock()
4029- if onDone != nil {
4030- onDone()
4031- }
4032- i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
4033- i.invalidate()
4034-}
4035-
4036-// applyStartupPreResult runs on the TUI event loop so the editor remains
4037-// single-threaded. Input entered while resources were reloading wins over the
4038-// deferred prefill rather than being overwritten.
4039-func (i *Interactive) applyStartupPreResult(result startupPreResult) {
4040- if result.deferred == "" {
4041- return
4042- }
4043- if result.autoSubmit {
4044- i.Submit(result.deferred)
4045- return
4046- }
4047- if i.ed.IsEmpty() {
4048- i.ed.SetValue(result.deferred)
4049- }
4050-}
4051-
4052-// ApplySessionAgent swaps the live agent after a validated session resume.
4053-// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
4054-// context; the session loader is changing the transcript/provider only.
4055-func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
4056- i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil)
4057-}
4058-
4059-func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) {
4060- if ag == nil {
4061- return
4062- }
4063- i.agentMu.Lock()
4064- defer i.agentMu.Unlock()
4065- i.mu.Lock()
4066- i.prepareReplacementAgentLocked(ag)
4067- i.compactContinuation = decodeCompactHandoff(compactHandoff)
4068- i.agent = ag
4069- i.cfg.Provider = providerName
4070- i.cfg.Model = model
4071- i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
4072- i.cumUsage = ag.Cost()
4073- last := ag.LastTurnUsage()
4074- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
4075- if len(i.view.Messages) > initialResumeTailLimit {
4076- i.view.TailLimit = initialResumeTailLimit
4077- } else {
4078- i.view.TailLimit = 0
4079- }
4080- i.view.InvalidateRenderCache()
4081- i.toolCalls = map[string]*tui.ToolCallView{}
4082- i.toolOrder = nil
4083- i.toolGate = map[string]int{}
4084- i.helpBlock = nil
4085- i.extNotes = nil
4086- i.parkedTurn = 0
4087- i.parkedTotal = 0
4088- i.mu.Unlock()
4089- i.invalidate()
4090-}
4091-
4092-// SetSubagentSessionScope redraws after a host session transition. Resident
4093-// children retain their explicit parent session in their durable journal.
4094-func (i *Interactive) SetSubagentSessionScope(_ string) {
4095- i.invalidate()
4096-}
4097-
4098-// ApplyChangedCWD is called by hosts after a successful /cd hook that do
4099-// not provide startup context metadata.
4100-func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
4101- i.applyChangedCWD(ag, provider, model, cwd, nil)
4102-}
4103-
4104-// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
4105-// startup context into the running TUI after a successful /cd hook.
4106-func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
4107- i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
4108-}
4109-
4110-func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
4111- home, _ := os.UserHomeDir()
4112- profiles, _ := subagents.Discover(cwd, home)
4113- subagentsAddendum := subagents.SystemPromptAddendum(profiles)
4114-
4115- i.agentMu.Lock()
4116- defer i.agentMu.Unlock()
4117- i.mu.Lock()
4118- i.prepareReplacementAgentLocked(ag)
4119- i.resetCompactContinuationLocked()
4120- i.agent = ag
4121- i.cfg.CWD = cwd
4122- i.cfg.SubagentsSystemAddendum = subagentsAddendum
4123- i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
4124- i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
4125- i.view.StartupContextPaths = nil
4126- i.view.InvalidateRenderCache()
4127- if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
4128- i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
4129- }
4130- // Re-report the working directory to the terminal so "new tab here"
4131- // tracks the /cd change (OSC 7, see issue #38).
4132- if i.cfg.Terminal != nil {
4133- if seq := tui.ReportCWD(cwd); seq != "" {
4134- _, _ = i.cfg.Terminal.Write([]byte(seq))
4135- }
4136- }
4137- i.cfg.Provider = provider
4138- i.cfg.Model = model
4139- titleCancel := i.titleCancel
4140- i.titleCancel = nil
4141- i.titleVersion++
4142- i.sessionTitle = ""
4143- i.titleRealPromptSeen = false
4144- i.titleGenerationStarted = false
4145- i.writeTerminalTitleLocked("")
4146- i.toolCalls = map[string]*tui.ToolCallView{}
4147- i.toolOrder = nil
4148- i.toolGate = map[string]int{}
4149- i.helpBlock = nil
4150- i.parkedTurn = 0
4151- i.statusErr = ""
4152- i.mu.Unlock()
4153- if titleCancel != nil {
4154- titleCancel()
4155- }
4156- i.fileSuggest.Reset()
4157- i.fileSuggest.SetCWD(cwd)
4158- i.invalidate()
4159-}
4160-
4161-// SubmitSlash runs text as a slash command in the TUI as if the user
4162-// had typed it. text must start with '/' — callers that hand it
4163-// plain prose silently get a no-op so a misbehaving extension can't
4164-// run a stray prompt through this path. Read-only commands run in
4165-// place; commands that would mutate the transcript or replace the
4166-// agent cancel the active turn first via the same path the editor
4167-// uses for typed slash commands.
4168-func (i *Interactive) SubmitSlash(text string) {
4169- i.mu.Lock()
4170- i.doubleEscape.Reset()
4171- i.mu.Unlock()
4172- text = strings.TrimSpace(text)
4173- if !strings.HasPrefix(text, "/") {
4174- return
4175- }
4176- if slashCommandCancelsTurn(text) {
4177- i.cancelAndWaitForIdle()
4178- }
4179- i.runSlash(i.runCtx, text)
4180- i.invalidate()
4181-}
4182-
4183-// SubmitOrQueue runs text immediately if the agent is idle, or
4184-// appends it to the pending queue if a turn is already in flight.
4185-// Used by the telegram bridge (and by the editor submit path) so
4186-// both input sources share the same "queue behind an active turn"
4187-// semantics. Images are ignored for now — only the text prompt is
4188-// forwarded — because the queued-prompt path is text-only; a
4189-// follow-up can expand the queue entry to carry images.
4190-func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
4191- i.submitOrQueue(text, images, true)
4192-}
4193-
4194-func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
4195- if cmd, ok := shellEscapeCommand(text); ok {
4196- i.startShellEscape(i.runCtx, cmd)
4197- return
4198- }
4199- i.mu.Lock()
4200- if i.agent == nil {
4201- i.statusErr = "not logged in. type /login first."
4202- i.mu.Unlock()
4203- i.invalidate()
4204- return
4205- }
4206- i.mu.Unlock()
4207- if userInput && i.cfg.EnsureMission != nil {
4208- if err := i.cfg.EnsureMission(text); err != nil {
4209- i.ReportError(fmt.Errorf("persist mission: %w", err))
4210- return
4211- }
4212- }
4213- // User input is an orchestration event, not merely a queue append. When
4214- // the manager is dormant behind a sealed worker wave, the coordinator makes
4215- // this the next manager turn and carries any completed worker summary with it.
4216- if userInput && i.coordinatorAcceptsUserInput() {
4217- if actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}); len(actions) != 0 {
4218- i.executeCoordinatorActions(actions)
4219- return
4220- }
4221- }
4222- i.maybeStartSessionTitle(i.runCtx, text)
4223- i.mu.Lock()
4224- if i.busy {
4225- // Queue text only; images are dropped for queued prompts. Keep the
4226- // interactive mutex held through Agent.QueueMessage so turn teardown
4227- // cannot inspect the agent queue between the busy check and enqueue.
4228- // Compaction uses the host queue because it has no active agent loop
4229- // to drain Agent.QueueMessage entries.
4230- var handoff json.RawMessage
4231- var persistHandoff bool
4232- if i.agent != nil && !i.compacting {
4233- handoff, persistHandoff = i.resetCompactContinuationLocked()
4234- i.agent.QueueMessage(text)
4235- } else {
4236- handoff, persistHandoff = i.resetCompactContinuationLocked()
4237- i.queued = append(i.queued, text)
4238- }
4239- i.mu.Unlock()
4240- if persistHandoff {
4241- i.persistCompactHandoff(handoff)
4242- }
4243- i.invalidate()
4244- return
4245- }
4246- i.mu.Unlock()
4247- i.startTurnWithImages(i.runCtx, text, images)
4248-}
4249-
4250-// CancelTurn aborts the active turn if one is running. Used by the
4251-// telegram bridge when the paired user sends /stop.
4252-// ChangelogVersion returns the version string of the changelog
4253-// currently shown (or last shown). Used by the dismiss callback
4254-// to store the correct version for dev builds.
4255-func (i *Interactive) ChangelogVersion() string {
4256- if i.changelogDialog != nil {
4257- return i.changelogDialog.version
4258- }
4259- return ""
4260-}
4261-
4262-func (i *Interactive) CancelTurn() {
4263- i.mu.Lock()
4264- cancel := i.cancelTurn
4265- i.mu.Unlock()
4266- if cancel != nil {
4267- i.cancelCoordinator()
4268-
4269- i.mu.Lock()
4270- handoff, persistHandoff := i.resetCompactContinuationLocked()
4271- i.mu.Unlock()
4272- cancel()
4273- i.confirmDialog.CancelAll("turn cancelled")
4274- if persistHandoff {
4275- i.persistCompactHandoff(handoff)
4276- }
4277- }
4278-}
4279-
4280-// Insert places text at the cursor in the editor.
4281-func (i *Interactive) Insert(text string) {
4282- i.ed.Insert(text)
4283- i.invalidate()
4284-}
4285-
4286-// Display appends a styled note from extName to the chat without a
4287-// model call.
4288-func (i *Interactive) Display(extName, text string) {
4289- i.appendExtensionNote(extName, text, "info")
4290- i.invalidate()
4291-}
4292-
4293-// ReportError surfaces a host-side error in the interactive status area.
4294-func (i *Interactive) ReportError(err error) {
4295- if err == nil {
4296- return
4297- }
4298- i.mu.Lock()
4299- i.statusOK = ""
4300- i.statusErr = err.Error()
4301- i.mu.Unlock()
4302- i.invalidate()
4303-}
4304-
4305-// SetStatus replaces one persistent status item owned by an extension.
4306-func (i *Interactive) SetStatus(extName, key, level, text string) {
4307- if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
4308- return
4309- }
4310- i.mu.Lock()
4311- if i.extStatuses == nil {
4312- i.extStatuses = map[string]map[string]extensionStatus{}
4313- }
4314- if strings.TrimSpace(text) == "" {
4315- if items := i.extStatuses[extName]; items != nil {
4316- delete(items, key)
4317- if len(items) == 0 {
4318- delete(i.extStatuses, extName)
4319- }
4320- }
4321- } else {
4322- if i.extStatuses[extName] == nil {
4323- i.extStatuses[extName] = map[string]extensionStatus{}
4324- }
4325- i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
4326- }
4327- i.mu.Unlock()
4328- i.invalidate()
4329-}
4330-
4331-// SetWidget replaces one persistent widget owned by an extension.
4332-func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
4333- if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
4334- return
4335- }
4336- position = extproto.NormalizeWidgetPosition(position)
4337- i.mu.Lock()
4338- if i.extWidgets == nil {
4339- i.extWidgets = map[string]map[string]extensionWidget{}
4340- }
4341- if i.extWidgets[extName] == nil {
4342- i.extWidgets[extName] = map[string]extensionWidget{}
4343- }
4344- i.extWidgets[extName][id] = extensionWidget{
4345- Position: position,
4346- Title: title,
4347- Lines: append([]string(nil), lines...),
4348- }
4349- i.mu.Unlock()
4350- i.invalidate()
4351-}
4352-
4353-// ClearWidget removes one persistent widget owned by an extension.
4354-func (i *Interactive) ClearWidget(extName, id string) {
4355- i.mu.Lock()
4356- if items := i.extWidgets[extName]; items != nil {
4357- delete(items, id)
4358- if len(items) == 0 {
4359- delete(i.extWidgets, extName)
4360- }
4361- }
4362- i.mu.Unlock()
4363- i.invalidate()
4364-}
4365-
4366-// ClearExtensionChrome removes every persistent UI item owned by an
4367-// extension that has exited. This keeps crashes, disable/reload operations,
4368-// and startup failures from leaving stale widgets beside the transcript.
4369-func (i *Interactive) ClearExtensionChrome(extName string) {
4370- if strings.TrimSpace(extName) == "" {
4371- return
4372- }
4373- marker := "[" + extName + "] "
4374- i.mu.Lock()
4375- changed := false
4376- if _, ok := i.extStatuses[extName]; ok {
4377- delete(i.extStatuses, extName)
4378- changed = true
4379- }
4380- if _, ok := i.extWidgets[extName]; ok {
4381- delete(i.extWidgets, extName)
4382- changed = true
4383- }
4384- if len(i.extNotes) > 0 {
4385- kept := i.extNotes[:0:0]
4386- for _, line := range i.extNotes {
4387- if strings.Contains(line, marker) {
4388- changed = true
4389- continue
4390- }
4391- kept = append(kept, line)
4392- }
4393- i.extNotes = kept
4394- }
4395- if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
4396- i.extPanel.Close()
4397- changed = true
4398- }
4399- i.mu.Unlock()
4400- if changed {
4401- i.invalidate()
4402- }
4403-}
4404-
4405-func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
4406- i.mu.Lock()
4407- defer i.mu.Unlock()
4408- i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
4409- i.confirmDialog.Blur()
4410- if i.cfg.Extensions != nil {
4411- cols, rows := i.cfg.Terminal.Size()
4412- _ = cols
4413- _ = rows
4414- }
4415- i.invalidate()
4416-}
4417-
4418-func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
4419- i.mu.Lock()
4420- defer i.mu.Unlock()
4421- if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
4422- i.extPanel.Update(title, lines, footer)
4423- i.invalidate()
4424- }
4425-}
4426-
4427-func (i *Interactive) ClosePanel(extName, panelID string) {
4428- i.mu.Lock()
4429- defer i.mu.Unlock()
4430- if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
4431- i.extPanel.Close()
4432- i.invalidate()
4433- }
4434-}
4435-
4436-func effectiveImageProtocol(override *bool) tui.ImageProtocol {
4437- detected := tui.DetectImageProtocol()
4438- if override == nil {
4439- return detected
4440- }
4441- if !*override {
4442- return tui.ImageProtocolNone
4443- }
4444- return detected
4445-}
4446-
4447-func imageProtocolName(p tui.ImageProtocol) string {
4448- switch p {
4449- case tui.ImageProtocolKitty:
4450- return "kitty/ghostty"
4451- case tui.ImageProtocolITerm2:
4452- return "iTerm2"
4453- default:
4454- return "none"
4455- }
4456-}
4457-
4458-func onOff(v bool) string {
4459- if v {
4460- return "enabled"
4461- }
4462- return "disabled"
4463-}
4464-
4465-const modalBackdropDimPercent = 50
4466-
4467-func (i *Interactive) applyInputCursorColor() {
4468- if i == nil || i.cfg.Terminal == nil {
4469- return
4470- }
4471- color := tui.Color256(15)
4472- if i.cursorDimmed {
4473- color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
4474- }
4475- _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
4476-}
4477-
4478-// setInputCursorDimmed updates the terminal-owned cursor only when its layer
4479-// changes. Reapplying cursor controls on every redraw can reset its blink
4480-// timing before the terminal completes a normal blink cycle.
4481-func (i *Interactive) setInputCursorDimmed(dimmed bool) {
4482- if i == nil || i.cursorDimmed == dimmed {
4483- return
4484- }
4485- i.cursorDimmed = dimmed
4486- i.applyInputCursorColor()
4487-}
4488-
4489-func reasoningSettingOptions(levels []string) []settingsOption {
4490- descriptions := map[string]string{
4491- "": "no reasoning",
4492- "minimum": "very brief (~1k tokens)",
4493- "low": "light (~2k tokens)",
4494- "medium": "moderate (~8k tokens)",
4495- "high": "deep (~16k tokens)",
4496- "xhigh": "extra-high effort",
4497- "max": "unconstrained effort",
4498- }
4499- options := make([]settingsOption, 0, len(levels))
4500- for _, level := range levels {
4501- label := level
4502- if label == "" {
4503- label = "off"
4504- }
4505- options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
4506- }
4507- return options
4508-}
4509-
4510-func (i *Interactive) reasoningSettingItem() settingsItem {
4511- levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
4512- reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
4513- desc := "reasoning depth for reasoning-capable models"
4514- hint := ""
4515- if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
4516- levels = provider.AvailableReasoningLevels(m)
4517- reasoning = provider.ClampReasoningForModel(m, reasoning)
4518- if !m.Reasoning {
4519- hint = "current model does not support reasoning"
4520- desc += "; current model does not support reasoning"
4521- } else if len(levels) == 1 {
4522- hint = "current model has no adjustable reasoning levels"
4523- desc += "; current model has no adjustable reasoning levels"
4524- } else {
4525- desc += "; only levels supported by the current model are shown"
4526- }
4527- }
4528- options := reasoningSettingOptions(levels)
4529- choice := 0
4530- for idx, opt := range options {
4531- if opt.value == reasoning {
4532- choice = idx
4533- break
4534- }
4535- }
4536-
4537- return settingsItem{
4538- key: "reasoning",
4539- label: "reasoning level",
4540- desc: desc,
4541- options: options,
4542- choice: choice,
4543- hint: hint,
4544- }
4545-}
4546-
4547-func (i *Interactive) openReasoningDialog() {
4548- i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
4549-}
4550-
4551-func (i *Interactive) openSettingsDialog() {
4552- detected := tui.DetectImageProtocol()
4553- imgEnabled := detected != tui.ImageProtocolNone
4554- if i.cfg.InlineImagesEnabled != nil {
4555- imgEnabled = *i.cfg.InlineImagesEnabled
4556- }
4557- imgDisabled := detected == tui.ImageProtocolNone
4558- imgHint := ""
4559- if imgDisabled {
4560- imgEnabled = false
4561- imgHint = "this terminal does not support inline images"
4562- } else {
4563- imgHint = "terminal supports " + imageProtocolName(detected)
4564- }
4565-
4566- terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
4567- terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
4568- autoSubagents := false
4569- if i.cfg.AutoSubagentsEnabled != nil {
4570- autoSubagents = *i.cfg.AutoSubagentsEnabled
4571- }
4572- autoSubagentsDisabled := !i.autoSubagentsAvailable()
4573- autoSubagentsHint := i.autoSubagentsUnavailableHint()
4574- if autoSubagentsDisabled {
4575- autoSubagents = false
4576- }
4577-
4578- ponytailEnabled := i.ponytailEnabled()
4579- ponytailDisabled := !i.ponytailSettingsAvailable()
4580- ponytailHint := ""
4581- if ponytailDisabled {
4582- ponytailHint = "requires persistent settings and live prompt refresh support"
4583- }
4584-
4585- webSearchEnabled := i.webSearchEffectiveEnabled()
4586- webSearchDisabled := !i.webSearchSettingsAvailable()
4587- webSearchHint := i.webSearchUnavailableHint()
4588-
4589- fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
4590- fastModeHint := "OpenAI service tier"
4591- if !provider.SupportsFastMode(i.cfg.Provider) {
4592- fastModeHint = "only supported for OpenAI providers"
4593- }
4594- lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4595- subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
4596-
4597- jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
4598- recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
4599- respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
4600- compactMode := i.compactModeEnabled()
4601- showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
4602- inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
4603- statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
4604- workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4605- quickItems := i.quickModelSettingItems()
4606-
4607- autoCompactOptions := []settingsOption{
4608- {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
4609- {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
4610- {value: "80", label: "80%", desc: "compact with moderate context headroom"},
4611- {value: "85", label: "85%", desc: "default balance between history and headroom"},
4612- {value: "90", label: "90%", desc: "retain more history before compacting"},
4613- {value: "95", label: "95%", desc: "retain most history before compacting"},
4614- }
4615- autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
4616- autoCompactChoice := 0
4617- for idx, opt := range autoCompactOptions {
4618- if opt.value == strconv.Itoa(autoCompactThreshold) {
4619- autoCompactChoice = idx
4620- break
4621- }
4622- }
4623-
4624- reasoningItem := i.reasoningSettingItem()
4625-
4626- themeName := i.cfg.ThemeName
4627- if themeName == "" {
4628- themeName = "auto"
4629- }
4630- if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) {
4631- themeName = "auto"
4632- i.cfg.ThemeName = ""
4633- if i.cfg.SettingsStore != nil {
4634- _ = i.cfg.SettingsStore.SetTheme("auto")
4635- }
4636- i.applyThemeNow("auto")
4637- }
4638- themeOptions := []settingsOption{}
4639- themeChoice := 0
4640- availableThemes := tui.AvailableThemes(i.cfg.ZutHome)
4641- if i.cfg.ExtensionThemes != nil {
4642- availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
4643- }
4644- for idx, opt := range availableThemes {
4645- themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
4646- if opt.Value == themeName {
4647- themeChoice = idx
4648- }
4649- }
4650-
4651- inputStyleOptions := []settingsOption{
4652- {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
4653- {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
4654- {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
4655- }
4656- inputStyleChoice := 0
4657- for idx, opt := range inputStyleOptions {
4658- if opt.value == inputStyle {
4659- inputStyleChoice = idx
4660- break
4661- }
4662- }
4663- statusPositionOptions := []settingsOption{
4664- {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
4665- {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
4666- }
4667- statusPositionChoice := 0
4668- for idx, opt := range statusPositionOptions {
4669- if opt.value == statusPosition {
4670- statusPositionChoice = idx
4671- break
4672- }
4673- }
4674-
4675- workingPositionOptions := []settingsOption{
4676- {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
4677- {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
4678- }
4679- workingPositionChoice := 0
4680- for idx, opt := range workingPositionOptions {
4681- if opt.value == workingPosition {
4682- workingPositionChoice = idx
4683- break
4684- }
4685- }
4686-
4687- items := []settingsItem{
4688- {
4689- key: "inline_images_enabled",
4690- label: "render images when supported",
4691- desc: "draw screenshots inline instead of showing a text placeholder",
4692- value: imgEnabled,
4693- disabled: imgDisabled,
4694- hint: imgHint,
4695- },
4696- {
4697- key: "terminal_alerts_enabled",
4698- label: "terminal alerts",
4699- desc: "ring the terminal when the main session needs attention or an extension raises an alert",
4700- value: terminalAlerts,
4701- },
4702- {
4703- key: "terminal_title_enabled",
4704- label: "AI terminal titles",
4705- desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
4706- value: terminalTitles,
4707- },
4708- {
4709- key: "auto_subagents_enabled",
4710- label: "Subagent Orchestrator",
4711- desc: "automatically delegate coding work to sub-agents; tools stay available when disabled",
4712- value: autoSubagents,
4713- disabled: autoSubagentsDisabled,
4714- hint: autoSubagentsHint,
4715- },
4716- {
4717- key: "ponytail_enabled",
4718- label: "ponytail coding mode",
4719- desc: "apply compact coding guidance that favors small, validated, maintainable changes",
4720- value: ponytailEnabled,
4721- disabled: ponytailDisabled,
4722- hint: ponytailHint,
4723- },
4724- {
4725- key: "web_search_enabled",
4726- label: "web search",
4727- desc: "search DuckDuckGo for bounded public-web sources; sends queries to the public backend",
4728- value: webSearchEnabled,
4729- disabled: webSearchDisabled,
4730- hint: webSearchHint,
4731- },
4732- {
4733- key: "fast_mode",
4734- label: "fast mode",
4735- desc: "request the provider's fast tier where supported; unsupported providers return an error",
4736- value: fastMode,
4737- hint: fastModeHint,
4738- },
4739- {
4740- key: "lsp_enabled",
4741- label: "lsp in main session",
4742- desc: "enable the lsp tool and code diagnostics for the main agent",
4743- value: lspEnabled,
4744- },
4745- {
4746- key: "subagent_lsp_enabled",
4747- label: "lsp in sub-agents",
4748- desc: "allow newly spawned background sub-agents to use the lsp tool",
4749- value: subagentLSPEnabled,
4750- },
4751- {
4752- key: "auto_compact_threshold",
4753- label: "auto-compact threshold",
4754- desc: "choose how full the model context can get before zut condenses conversation history",
4755- options: autoCompactOptions,
4756- choice: autoCompactChoice,
4757- },
4758- {
4759- key: "jail_by_default",
4760- label: "jail new sessions by default",
4761- desc: "confine tools to the session working directory unless /unjail is used",
4762- value: jailByDefault,
4763- },
4764- {
4765- key: "recursive_file_suggest",
4766- label: "recursive @-file search",
4767- desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
4768- value: recursiveFiles,
4769- },
4770- {
4771- key: "respect_gitignore",
4772- label: "hide gitignored files in @-picker",
4773- desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
4774- value: respectGitignore,
4775- },
4776- {
4777- key: "compact_mode",
4778- label: "compact transcript rendering",
4779- desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
4780- value: compactMode,
4781- },
4782- {
4783- key: "show_instructions_at_startup",
4784- label: "show loaded resources at startup",
4785- desc: "list loaded context files, extensions, and user-installed skills above the transcript",
4786- value: showInstructions,
4787- },
4788- {
4789- key: "tui_settings",
4790- label: "tui settings",
4791- desc: "choose input chrome in its order around the prompt",
4792- children: []settingsItem{
4793- {
4794- key: "tui_status_position",
4795- label: "status position",
4796- desc: "place model, usage, and working directory above or below the input",
4797- options: statusPositionOptions,
4798- choice: statusPositionChoice,
4799- },
4800- {
4801- key: "tui_working_position",
4802- label: "working spinner position",
4803- desc: "place the working spinner above or below the input",
4804- options: workingPositionOptions,
4805- choice: workingPositionChoice,
4806- },
4807- {
4808- key: "tui_input_style",
4809- label: "input style",
4810- desc: "choose between the plain prompt, lines, and a block input area",
4811- options: inputStyleOptions,
4812- choice: inputStyleChoice,
4813- },
4814- },
4815- },
4816- reasoningItem,
4817- {
4818- key: "theme",
4819- label: "color theme",
4820- desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
4821- options: themeOptions,
4822- choice: themeChoice,
4823- },
4824- }
4825- if len(quickItems) > 0 {
4826- items = append(items, settingsItem{
4827- key: "quick_models",
4828- label: "model shortcuts",
4829- desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
4830- children: quickItems,
4831- })
4832- }
4833- i.settingsDialog.Open(items)
4834-}
4835-
4836-func (i *Interactive) applySettingChange(act settingsAction) {
4837- switch {
4838- case strings.HasPrefix(act.Key, "quick_model_"):
4839- i.applyQuickModelSetting(act.Key, act.StringValue)
4840- case act.Key == "reasoning":
4841- i.applyReasoningSetting(act.StringValue)
4842- case act.Key == "theme":
4843- i.applyThemeSetting(act.StringValue)
4844- case act.Key == "auto_compact_threshold":
4845- i.applyAutoCompactThresholdSetting(act.StringValue)
4846- case act.Key == "tui_input_style":
4847- i.applyTUIInputStyleSetting(act.StringValue)
4848- case act.Key == "tui_status_position":
4849- i.applyTUIStatusPositionSetting(act.StringValue)
4850- case act.Key == "tui_working_position":
4851- i.applyTUIWorkingPositionSetting(act.StringValue)
4852- default:
4853- i.applySettingToggle(act.Key, act.Value)
4854- }
4855-}
4856-
4857-func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
4858- threshold, err := strconv.Atoi(value)
4859- if err != nil {
4860- return
4861- }
4862- threshold = NormalizeAutoCompactThreshold(&threshold)
4863- if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
4864- if err := store.SetAutoCompactThreshold(threshold); err != nil {
4865- i.mu.Lock()
4866- i.statusErr = "settings: " + err.Error()
4867- i.mu.Unlock()
4868- return
4869- }
4870- }
4871- i.mu.Lock()
4872- i.cfg.AutoCompactThreshold = &threshold
4873- if threshold == 0 {
4874- i.statusOK = "auto-compact threshold off"
4875- } else {
4876- i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
4877- }
4878- i.statusErr = ""
4879- i.mu.Unlock()
4880- i.invalidate()
4881-}
4882-
4883-func (i *Interactive) quickModelSettingItems() []settingsItem {
4884- if len(i.cfg.QuickModelShortcuts) < 9 {
4885- next := make([]QuickModelShortcut, 9)
4886- copy(next, i.cfg.QuickModelShortcuts)
4887- i.cfg.QuickModelShortcuts = next
4888- }
4889- items := make([]settingsItem, 0, 9)
4890- for slot := 1; slot <= 9; slot++ {
4891- items = append(items, i.quickModelSettingItem(slot))
4892- }
4893- return items
4894-}
4895-
4896-func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
4897- current := QuickModelShortcut{}
4898- if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
4899- current = i.cfg.QuickModelShortcuts[slot-1]
4900- }
4901- hint := "not assigned"
4902- if current.Provider != "" && current.Model != "" {
4903- hint = current.Provider + " / " + current.Model
4904- }
4905- return settingsItem{
4906- key: "quick_model_" + strconv.Itoa(slot),
4907- label: "model " + strconv.Itoa(slot),
4908- desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
4909- picker: true,
4910- hint: hint,
4911- }
4912-}
4913-
4914-func (i *Interactive) compactModeEnabled() bool {
4915- return i.cfg.CompactMode != nil && *i.cfg.CompactMode
4916-}
4917-
4918-func quickModelShortcutSlot(k tui.Key) int {
4919- if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
4920- return 0
4921- }
4922- if runtime.GOOS == "darwin" {
4923- if !k.Super && !k.Ctrl {
4924- return 0
4925- }
4926- } else if !k.Ctrl {
4927- return 0
4928- }
4929- return int(k.Rune - '0')
4930-}
4931-
4932-func quickModelShortcutPrefix() string {
4933- return "Ctrl"
4934-}
4935-
4936-func quickModelShortcutLabel(slot int) string {
4937- return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
4938-}
4939-
4940-func (i *Interactive) openQuickModelPicker(slot int) {
4941- if slot < 1 || slot > 9 {
4942- return
4943- }
4944- i.quickModelAssign = slot
4945- current := i.cfg.Model
4946- if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
4947- current = i.cfg.QuickModelShortcuts[slot-1].Model
4948- }
4949- var loggedIn []string
4950- if i.cfg.LoggedInProviders != nil {
4951- loggedIn = i.cfg.LoggedInProviders()
4952- }
4953- i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
4954-}
4955-
4956-func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
4957- i.setQuickModelShortcut(slot, providerName, model)
4958-}
4959-
4960-func (i *Interactive) applyQuickModelShortcut(slot int) {
4961- if slot < 1 || slot > 9 {
4962- return
4963- }
4964- if i.busy {
4965- i.mu.Lock()
4966- i.statusErr = "cannot switch model while a turn is running"
4967- i.statusOK = ""
4968- i.mu.Unlock()
4969- i.invalidate()
4970- return
4971- }
4972- if len(i.cfg.QuickModelShortcuts) < slot {
4973- i.mu.Lock()
4974- i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
4975- i.statusOK = ""
4976- i.mu.Unlock()
4977- i.invalidate()
4978- return
4979- }
4980- shortcut := i.cfg.QuickModelShortcuts[slot-1]
4981- if shortcut.Provider == "" || shortcut.Model == "" {
4982- i.mu.Lock()
4983- i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
4984- i.statusOK = ""
4985- i.mu.Unlock()
4986- i.invalidate()
4987- return
4988- }
4989- i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
4990- i.invalidate()
4991-}
4992-
4993-func (i *Interactive) applyQuickModelSetting(key, value string) {
4994- slotText := strings.TrimPrefix(key, "quick_model_")
4995- slot, err := strconv.Atoi(slotText)
4996- if err != nil || slot < 1 || slot > 9 {
4997- return
4998- }
4999- providerName, model := "", ""
5000- if value != "" {
5001- parts := strings.SplitN(value, "\t", 2)
5002- if len(parts) == 2 {
5003- providerName, model = parts[0], parts[1]
5004- }
5005- }
5006- i.setQuickModelShortcut(slot, providerName, model)
5007-}
5008-
5009-func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
5010- if len(i.cfg.QuickModelShortcuts) < slot {
5011- next := make([]QuickModelShortcut, slot)
5012- copy(next, i.cfg.QuickModelShortcuts)
5013- i.cfg.QuickModelShortcuts = next
5014- }
5015- i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
5016- if i.cfg.SettingsStore != nil {
5017- if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
5018- i.mu.Lock()
5019- i.statusErr = "settings: " + err.Error()
5020- i.mu.Unlock()
5021- return
5022- }
5023- }
5024- i.mu.Lock()
5025- if model == "" {
5026- i.statusOK = quickModelShortcutLabel(slot) + " cleared"
5027- } else {
5028- i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
5029- }
5030- i.statusErr = ""
5031- i.mu.Unlock()
5032- i.refreshQuickModelSettingsItem(slot)
5033- i.invalidate()
5034-}
5035-
5036-func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
5037- if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
5038- return
5039- }
5040- key := "quick_model_" + strconv.Itoa(slot)
5041- for idx, it := range i.settingsDialog.items {
5042- if it.key == key {
5043- i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
5044- return
5045- }
5046- }
5047-}
5048-
5049-func (i *Interactive) resetSettingsToggle(key string, value bool) {
5050- if i.settingsDialog == nil {
5051- return
5052- }
5053- for idx := range i.settingsDialog.items {
5054- if i.settingsDialog.items[idx].key == key {
5055- i.settingsDialog.items[idx].value = value
5056- return
5057- }
5058- }
5059-}
5060-
5061-func (i *Interactive) ponytailEnabled() bool {
5062- return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
5063-}
5064-
5065-func (i *Interactive) ponytailSettingsAvailable() bool {
5066- _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
5067- return persistent && i.cfg.RefreshPrompt != nil
5068-}
5069-
5070-func (i *Interactive) webSearchEnabled() bool {
5071- return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled
5072-}
5073-
5074-func (i *Interactive) webSearchToolAllowed() bool {
5075- return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed
5076-}
5077-
5078-func (i *Interactive) webSearchEffectiveEnabled() bool {
5079- return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled())
5080-}
5081-
5082-func (i *Interactive) webSearchSettingsAvailable() bool {
5083- _, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
5084- return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil
5085-}
5086-
5087-func (i *Interactive) webSearchUnavailableHint() string {
5088- if !i.webSearchToolAllowed() {
5089- return "unavailable in this session due to the launch-time tool capability ceiling"
5090- }
5091- if i.cfg.WebSearchInvocationOverride {
5092- return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools"
5093- }
5094- if !i.webSearchSettingsAvailable() {
5095- return "requires persistent settings and live tool refresh support"
5096- }
5097- return ""
5098-}
5099-
5100-func (i *Interactive) setWebSearchAvailable(available bool) {
5101- // Advance before publishing the new gate/child ceiling. A resolver that
5102- // captured the prior generation can no longer commit after this transition.
5103- // Callers may hold agentMu and i.mu; the callback must complete without
5104- // calling back into Interactive or requiring either lock.
5105- i.webSearchPolicyGeneration.Add(1)
5106- if i.cfg.SetWebSearchAvailable != nil {
5107- i.cfg.SetWebSearchAvailable(available)
5108- }
5109-}
5110-
5111-func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) {
5112- previous := i.webSearchEnabled()
5113- store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
5114- if !i.webSearchSettingsAvailable() || !persistent {
5115- i.resetSettingsToggle(key, i.webSearchEffectiveEnabled())
5116- i.mu.Lock()
5117- i.statusOK = ""
5118- i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint()
5119- i.mu.Unlock()
5120- return
5121- }
5122- if err := store.SetWebSearchEnabled(value); err != nil {
5123- i.resetSettingsToggle(key, previous)
5124- i.mu.Lock()
5125- i.statusOK = ""
5126- i.statusErr = "settings: " + err.Error()
5127- i.mu.Unlock()
5128- return
5129- }
5130- val := value
5131- i.cfg.WebSearchEnabled = &val
5132- // Revoke and advance the policy generation for both directions. Enabling
5133- // remains fail-closed until the freshly resolved registry commits, while a
5134- // stale resolve from before this persisted transition is rejected.
5135- i.stripWebSearchTool()
5136- if err := i.cfg.RefreshTools(); err != nil {
5137- // Refresh failure leaves the old registry's state unknown. Revoke both
5138- // live execution and generic-child inheritance before rollback.
5139- i.stripWebSearchTool()
5140- errMsg := "settings: web search tool refresh: " + err.Error()
5141- if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil {
5142- failClosed := false
5143- i.cfg.WebSearchEnabled = &failClosed
5144- i.resetSettingsToggle(key, false)
5145- errMsg += "; rollback persistence: " + rollbackErr.Error()
5146- } else {
5147- previousVal := previous
5148- i.cfg.WebSearchEnabled = &previousVal
5149- i.resetSettingsToggle(key, previous)
5150- if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
5151- errMsg += "; rollback refresh: " + refreshErr.Error()
5152- i.stripWebSearchTool()
5153- }
5154- }
5155- i.mu.Lock()
5156- i.statusOK = ""
5157- i.statusErr = errMsg
5158- i.mu.Unlock()
5159- return
5160- }
5161- i.mu.Lock()
5162- i.statusOK = "web search " + onOff(value)
5163- i.statusErr = ""
5164- i.mu.Unlock()
5165-}
5166-
5167-// stripWebSearchTool removes the callable web_search entry from the live
5168-// registry without asking the resolver to run again. It is the fail-closed
5169-// fallback when persistence rollback fails after a refresh failure.
5170-func (i *Interactive) stripWebSearchTool() {
5171- // Revoke first so a stale tool snapshotted by core cannot execute while
5172- // the advertised registry is being replaced.
5173- i.setWebSearchAvailable(false)
5174- i.agentMu.Lock()
5175- defer i.agentMu.Unlock()
5176- i.mu.Lock()
5177- defer i.mu.Unlock()
5178- if i.agent == nil {
5179- return
5180- }
5181- tools := i.agent.ToolsSnapshot()
5182- if _, ok := tools["web_search"]; !ok {
5183- return
5184- }
5185- delete(tools, "web_search")
5186- i.agent.SetTools(tools)
5187-}
5188-
5189-func (i *Interactive) applySettingToggle(key string, value bool) {
5190- // Every setting toggle forces a full repaint at the end — same
5191- // effect as the user pressing Ctrl+L — so any per-setting visual
5192- // change (image rendering, status copy, future toggles) lands
5193- // immediately instead of waiting for the next diff frame.
5194- defer func() {
5195- i.requestRendererClear()
5196- i.invalidate()
5197- }()
5198- switch key {
5199- case "inline_images_enabled":
5200- val := value
5201- i.cfg.InlineImagesEnabled = &val
5202- if i.cfg.SettingsStore != nil {
5203- if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
5204- i.mu.Lock()
5205- i.statusErr = "settings: " + err.Error()
5206- i.mu.Unlock()
5207- return
5208- }
5209- }
5210- i.mu.Lock()
5211- i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
5212- i.view.InvalidateRenderCache()
5213- i.statusOK = "inline image rendering " + onOff(value)
5214- i.statusErr = ""
5215- i.mu.Unlock()
5216- case "terminal_alerts_enabled":
5217- val := value
5218- i.mu.Lock()
5219- i.cfg.TerminalAlertsEnabled = &val
5220- i.mu.Unlock()
5221- if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
5222- if err := store.SetTerminalAlertsEnabled(value); err != nil {
5223- i.mu.Lock()
5224- i.statusErr = "settings: " + err.Error()
5225- i.mu.Unlock()
5226- return
5227- }
5228- }
5229- i.mu.Lock()
5230- i.statusOK = "terminal alerts " + onOff(value)
5231- i.statusErr = ""
5232- i.mu.Unlock()
5233- case "terminal_title_enabled":
5234- val := value
5235- var cancel context.CancelFunc
5236- i.mu.Lock()
5237- i.cfg.TerminalTitleEnabled = &val
5238- if value {
5239- i.writeTerminalTitleLocked(i.sessionTitle)
5240- } else {
5241- cancel = i.titleCancel
5242- i.titleCancel = nil
5243- i.titleVersion++
5244- i.writeTerminalTitleLocked("")
5245- }
5246- i.mu.Unlock()
5247- if cancel != nil {
5248- cancel()
5249- }
5250- if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
5251- if err := store.SetTerminalTitleEnabled(value); err != nil {
5252- i.mu.Lock()
5253- i.statusErr = "settings: " + err.Error()
5254- i.mu.Unlock()
5255- return
5256- }
5257- }
5258- i.mu.Lock()
5259- i.statusOK = "AI terminal titles " + onOff(value)
5260- i.statusErr = ""
5261- i.mu.Unlock()
5262- case "web_search_enabled":
5263- apply := func() { i.applyWebSearchSettingToggle(key, value) }
5264- if i.cfg.SessionTransition != nil {
5265- i.cfg.SessionTransition(apply)
5266- } else {
5267- apply()
5268- }
5269- case "ponytail_enabled":
5270- previous := i.ponytailEnabled()
5271- store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
5272- if !available || i.cfg.RefreshPrompt == nil {
5273- i.resetSettingsToggle(key, previous)
5274- i.mu.Lock()
5275- i.statusOK = ""
5276- i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
5277- i.mu.Unlock()
5278- return
5279- }
5280- if err := store.SetPonytailEnabled(value); err != nil {
5281- i.resetSettingsToggle(key, previous)
5282- i.mu.Lock()
5283- i.statusOK = ""
5284- i.statusErr = "settings: " + err.Error()
5285- i.mu.Unlock()
5286- return
5287- }
5288- val := value
5289- i.cfg.PonytailEnabled = &val
5290- if err := i.cfg.RefreshPrompt(); err != nil {
5291- errMsg := "settings: ponytail prompt refresh: " + err.Error()
5292- if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
5293- // The first write succeeded, so keep the in-memory setting at
5294- // the value that remains durable instead of claiming that the
5295- // rollback took effect.
5296- i.resetSettingsToggle(key, value)
5297- errMsg += "; rollback persistence: " + rollbackErr.Error()
5298- if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
5299- errMsg += "; durable-state refresh: " + reconcileErr.Error()
5300- }
5301- } else {
5302- previousVal := previous
5303- i.cfg.PonytailEnabled = &previousVal
5304- i.resetSettingsToggle(key, previous)
5305- if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
5306- errMsg += "; rollback refresh: " + refreshErr.Error()
5307- }
5308- }
5309- i.mu.Lock()
5310- i.statusOK = ""
5311- i.statusErr = errMsg
5312- i.mu.Unlock()
5313- return
5314- }
5315- i.mu.Lock()
5316- i.statusOK = "ponytail coding mode " + onOff(value)
5317- i.statusErr = ""
5318- i.mu.Unlock()
5319- case "auto_subagents_enabled":
5320- if value && !i.autoSubagentsAvailable() {
5321- i.mu.Lock()
5322- i.statusOK = ""
5323- i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint()
5324- i.mu.Unlock()
5325- return
5326- }
5327- i.mu.Lock()
5328- previousFlag := i.cfg.AutoSubagentsEnabled
5329- previous := previousFlag != nil && *previousFlag
5330- val := value
5331- i.cfg.AutoSubagentsEnabled = &val
5332- i.mu.Unlock()
5333- if i.cfg.SettingsStore != nil {
5334- if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
5335- i.mu.Lock()
5336- i.cfg.AutoSubagentsEnabled = previousFlag
5337- i.statusOK = ""
5338- i.statusErr = "settings: " + err.Error()
5339- i.mu.Unlock()
5340- i.resetSettingsToggle(key, previous)
5341- return
5342- }
5343- }
5344- // The subagent tools remain available for explicit user requests.
5345- // Swap only the system-prompt guidance between strict orchestration and
5346- // on-demand delegation.
5347- i.applyAutoSubagentsTool()
5348- // Also swap the system-prompt addendum so the model knows whether it
5349- // should orchestrate proactively or delegate only on user request.
5350- i.applyAutoSubagentsSystemPrompt(value)
5351- i.mu.Lock()
5352- i.statusOK = "subagent orchestrator " + onOff(value)
5353- i.statusErr = ""
5354- i.mu.Unlock()
5355- case "fast_mode":
5356- previous := i.cfg.FastMode != nil && *i.cfg.FastMode
5357- if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
5358- i.resetSettingsToggle(key, previous)
5359- i.mu.Lock()
5360- i.statusErr = "settings: " + err.Error()
5361- i.mu.Unlock()
5362- return
5363- }
5364- if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
5365- if err := store.SetFastMode(value); err != nil {
5366- i.resetSettingsToggle(key, previous)
5367- i.mu.Lock()
5368- i.statusErr = "settings: " + err.Error()
5369- i.mu.Unlock()
5370- return
5371- }
5372- }
5373- val := value
5374- i.cfg.FastMode = &val
5375- if i.agent != nil {
5376- i.agent.SetFastMode(value)
5377- }
5378- i.mu.Lock()
5379- i.statusOK = "fast mode " + onOff(value)
5380- i.statusErr = ""
5381- i.mu.Unlock()
5382- case "lsp_enabled":
5383- previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
5384- store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
5385- if hasStore {
5386- if err := store.SetLSPEnabled(value); err != nil {
5387- i.mu.Lock()
5388- i.statusErr = "settings: " + err.Error()
5389- i.mu.Unlock()
5390- return
5391- }
5392- }
5393- val := value
5394- i.cfg.LSPEnabled = &val
5395- if i.cfg.RefreshTools != nil {
5396- if err := i.cfg.RefreshTools(); err != nil {
5397- errMsg := "settings: refresh tools: " + err.Error()
5398- rollbackErr := error(nil)
5399- if hasStore {
5400- rollbackErr = store.SetLSPEnabled(previous)
5401- }
5402- if rollbackErr != nil {
5403- // The first write succeeded, so keep the in-memory setting at
5404- // the value that remains durable instead of claiming that the
5405- // rollback took effect.
5406- errMsg += "; rollback persistence: " + rollbackErr.Error()
5407- if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
5408- errMsg += "; durable-state refresh: " + reconcileErr.Error()
5409- }
5410- } else {
5411- previousVal := previous
5412- i.cfg.LSPEnabled = &previousVal
5413- if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
5414- errMsg += "; rollback refresh: " + refreshErr.Error()
5415- }
5416- }
5417- i.mu.Lock()
5418- i.statusOK = ""
5419- i.statusErr = errMsg
5420- i.mu.Unlock()
5421- return
5422- }
5423- }
5424- i.mu.Lock()
5425- i.statusOK = "lsp in main session " + onOff(value)
5426- i.statusErr = ""
5427- i.mu.Unlock()
5428- case "subagent_lsp_enabled":
5429- if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
5430- if err := store.SetSubagentLSPEnabled(value); err != nil {
5431- i.mu.Lock()
5432- i.statusErr = "settings: " + err.Error()
5433- i.mu.Unlock()
5434- return
5435- }
5436- }
5437- val := value
5438- i.cfg.SubagentLSPEnabled = &val
5439- i.mu.Lock()
5440- i.statusOK = "lsp in sub-agents " + onOff(value)
5441- i.statusErr = ""
5442- i.mu.Unlock()
5443- case "jail_by_default":
5444- val := value
5445- i.cfg.JailByDefault = &val
5446- if i.cfg.SettingsStore != nil {
5447- if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
5448- i.mu.Lock()
5449- i.statusErr = "settings: " + err.Error()
5450- i.mu.Unlock()
5451- return
5452- }
5453- }
5454- if i.cfg.Sandbox != nil {
5455- if value {
5456- i.cfg.Sandbox.Lock()
5457- } else {
5458- i.cfg.Sandbox.Unlock()
5459- }
5460- }
5461- i.mu.Lock()
5462- i.statusOK = "jail by default " + onOff(value)
5463- i.statusErr = ""
5464- i.mu.Unlock()
5465- case "recursive_file_suggest":
5466- val := value
5467- i.cfg.RecursiveFileSuggest = &val
5468- if i.cfg.SettingsStore != nil {
5469- if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
5470- i.mu.Lock()
5471- i.statusErr = "settings: " + err.Error()
5472- i.mu.Unlock()
5473- return
5474- }
5475- }
5476- // Flip the live picker so the next @ reflects the new mode
5477- // without restarting zut. SetRecursive drops its cache.
5478- i.fileSuggest.SetRecursive(value)
5479- i.mu.Lock()
5480- i.statusOK = "recursive @-file search " + onOff(value)
5481- i.statusErr = ""
5482- i.mu.Unlock()
5483- case "respect_gitignore":
5484- val := value
5485- i.cfg.RespectGitignore = &val
5486- if i.cfg.SettingsStore != nil {
5487- if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
5488- i.mu.Lock()
5489- i.statusErr = "settings: " + err.Error()
5490- i.mu.Unlock()
5491- return
5492- }
5493- }
5494- i.fileSuggest.SetRespectGitignore(value)
5495- i.mu.Lock()
5496- i.statusOK = "hide gitignored files in @-picker " + onOff(value)
5497- i.statusErr = ""
5498- i.mu.Unlock()
5499- case "compact_mode":
5500- val := value
5501- i.cfg.CompactMode = &val
5502- if i.cfg.SettingsStore != nil {
5503- if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
5504- i.mu.Lock()
5505- i.statusErr = "settings: " + err.Error()
5506- i.mu.Unlock()
5507- return
5508- }
5509- }
5510- i.mu.Lock()
5511- i.view.CompactMode = value
5512- i.view.InvalidateRenderCache()
5513- i.statusOK = "compact transcript rendering " + onOff(value)
5514- i.statusErr = ""
5515- i.mu.Unlock()
5516- case "show_instructions_at_startup":
5517- val := value
5518- i.cfg.ShowInstructionsAtStartup = &val
5519- if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
5520- if err := store.SetShowInstructionsAtStartup(value); err != nil {
5521- i.mu.Lock()
5522- i.statusErr = "settings: " + err.Error()
5523- i.mu.Unlock()
5524- return
5525- }
5526- }
5527- i.mu.Lock()
5528- i.view.StartupAgentName = ""
5529- i.view.StartupContextPaths = nil
5530- i.view.StartupExtensionNames = nil
5531- i.view.StartupSkillNames = nil
5532- i.view.InvalidateRenderCache()
5533- if value {
5534- i.view.StartupAgentName = i.cfg.StartupAgentName
5535- i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
5536- i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
5537- i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
5538- }
5539- i.statusOK = "show loaded resources at startup " + onOff(value)
5540- i.statusErr = ""
5541- i.mu.Unlock()
5542- }
5543-}
1411+ hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1412+ autoCompactContinueMetaKey = "auto_compact_continue"
1413+ shellEscapeMetaKey = "shell_escape"
1414+)
55441415
5545-func (i *Interactive) applyTUIInputStyleSetting(style string) {
5546- defer func() {
5547- i.requestRendererClear()
5548- i.invalidate()
5549- }()
5550- style = tui.NormalizeInputStyle(style)
5551- i.cfg.TUIInputStyle = style
5552- i.applyInputCursorColor()
5553- if i.cfg.SettingsStore != nil {
5554- if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
5555- i.mu.Lock()
5556- i.statusErr = "settings: " + err.Error()
5557- i.mu.Unlock()
5558- return
5559- }
5560- }
5561- i.mu.Lock()
5562- i.statusOK = "input style " + style
5563- i.statusErr = ""
5564- i.mu.Unlock()
5565-}
1416+// isBoxBlankLine reports whether line is visually empty after
1417+// stripping ANSI escape sequences, surrounding whitespace, and the
1418+// vertical box edges drawn by the tool-box renderer. Used by
1419+// clipBottomClippedImages so an image's reservation rows still count
1420+// as blank when those rows are wrapped in "│ ... │" inside a tool box.
55661421
5567-func (i *Interactive) applyTUIStatusPositionSetting(position string) {
5568- defer func() {
5569- i.requestRendererClear()
5570- i.invalidate()
5571- }()
5572- position = tui.NormalizeStatusPosition(position)
5573- i.cfg.TUIStatusPosition = position
5574- if i.cfg.SettingsStore != nil {
5575- if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
5576- i.mu.Lock()
5577- i.statusErr = "settings: " + err.Error()
5578- i.mu.Unlock()
5579- return
5580- }
5581- }
5582- i.mu.Lock()
5583- label := "above input"
5584- if position == tui.StatusPositionBelowInput {
5585- label = "below input"
5586- }
5587- i.statusOK = "status position " + label
5588- i.statusErr = ""
5589- i.mu.Unlock()
5590-}
1422+// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
1423+// byte) from s without pulling in the regexp package. Mirrors the
1424+// internal helper in package tui; the duplicated copy avoids exporting
1425+// it just for one caller.
55911426
5592-func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5593- defer func() {
5594- i.requestRendererClear()
5595- i.invalidate()
5596- }()
5597- position = tui.NormalizeWorkingPosition(position)
5598- i.cfg.TUIWorkingPosition = position
5599- if i.cfg.SettingsStore != nil {
5600- if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
5601- i.mu.Lock()
5602- i.statusErr = "settings: " + err.Error()
5603- i.mu.Unlock()
5604- return
5605- }
5606- }
5607- i.mu.Lock()
5608- label := "above input"
5609- if position == tui.WorkingPositionBelowInput {
5610- label = "below input"
5611- }
5612- i.statusOK = "working spinner position " + label
5613- i.statusErr = ""
5614- i.mu.Unlock()
5615-}
1427+// truncateLine shortens s so it fits within n display cells, with an
1428+// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
1429+// novel doesn't blow past the status line.
56161430
5617-func (i *Interactive) applyThemeSetting(name string) {
5618- if i.cfg.SettingsStore != nil {
5619- if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
5620- i.mu.Lock()
5621- i.statusErr = "settings: " + err.Error()
5622- i.mu.Unlock()
5623- return
5624- }
5625- }
5626- i.cfg.ThemeName = name
5627- if name == "auto" {
5628- i.cfg.ThemeName = ""
5629- }
5630- i.applyThemeNow(name)
5631-}
1431+// ctrlCExitWindow is how long after a ctrl+c press a *second* press
1432+// will exit instead of just clearing input. Long enough to be
1433+// deliberate (rules out accidental key chord), short enough that the
1434+// hint stays meaningful.
1435+const ctrlCExitWindow = 2 * time.Second
56321436
5633-func (i *Interactive) applyThemeNow(name string) {
5634- if name == "" {
5635- name = "auto"
5636- }
5637- detected := tui.Dark
5638- if tui.IsLightTheme(i.cfg.Theme) {
5639- detected = tui.Light
5640- }
5641- // Keep the startup terminal snapshot available when a user switches
5642- // themes live; inherited mode must not issue OSC queries while the TUI
5643- // already owns raw stdin.
5644- detected.Terminal = i.cfg.Theme.Terminal
5645- th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
5646- if err != nil {
5647- if i.cfg.SettingsStore != nil {
5648- _ = i.cfg.SettingsStore.SetTheme("auto")
5649- }
5650- i.cfg.ThemeName = ""
5651- th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)
5652- i.mu.Lock()
5653- i.statusErr = "theme missing; reset to default"
5654- i.mu.Unlock()
5655- } else {
5656- i.mu.Lock()
5657- label := applied
5658- if label == "" {
5659- label = "auto"
5660- }
5661- i.statusOK = "theme " + label
5662- i.statusErr = ""
5663- i.mu.Unlock()
5664- }
5665- i.mu.Lock()
5666- i.cfg.Theme = th
5667- i.view.Theme = th
5668- i.view.InvalidateRenderCache()
5669- i.mu.Unlock()
5670- i.ed.Prompt = th.AccentBar(th.Accent)
5671- i.applyInputCursorColor()
5672- i.spin.Configure(th)
5673- i.requestRendererTheme(th)
5674- i.requestRendererClear()
5675- i.invalidate()
5676-}
1437+// armCtrlCExit records the timestamp of the current ctrl+c so the next
1438+// one within ctrlCExitWindow exits.
56771439
5678-func (i *Interactive) applyReasoningSetting(level string) {
5679- defer func() {
5680- i.requestRendererClear()
5681- i.invalidate()
5682- }()
5683- level = provider.NormalizeReasoning(level)
5684- i.cfg.Reasoning = level
5685- if i.cfg.SettingsStore != nil {
5686- if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
5687- i.mu.Lock()
5688- i.statusErr = "settings: " + err.Error()
5689- i.mu.Unlock()
5690- return
5691- }
5692- }
5693- if i.cfg.OnReasoningChanged != nil {
5694- i.cfg.OnReasoningChanged(level)
5695- }
5696- i.mu.Lock()
5697- if i.agent != nil {
5698- i.agent.Reasoning = level
5699- }
5700- label := level
5701- if label == "" {
5702- label = "off"
5703- }
5704- i.statusOK = "reasoning level " + label
5705- i.statusErr = ""
5706- i.mu.Unlock()
5707-}
1440+// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
1441+// that another press should now exit.
1442+
1443+// clearFileSuggestQuery strips the filter the user typed after the
1444+// last "@", leaving the bare "@" so the picker stays open. Called when
1445+// navigating between directory levels (Right/Left): the filter applied
1446+// to the level the user was on, not the one being entered, so carrying
1447+// it forward would wrongly hide the new directory's contents.
1448+
1449+// setToolExpansion updates long tool result expansion and replays the
1450+// transcript so already-emitted rows reflect the new state.
1451+
1452+// confirmChildActive reports whether an interaction opened from slash input
1453+// currently owns the keyboard while a tool confirmation remains pending.
1454+
1455+// restoreConfirmFocus returns input to confirmation after slash input is
1456+// cleared or a child interaction closes. A non-empty editor keeps command
1457+// input focused, and an active child continues to own its keys.
1458+
1459+// invokeExtensionCommand fires an extension-registered slash command
1460+// in a background goroutine, awaits the response, and applies the
1461+// requested action (prompt / insert / display / noop). Errors and
1462+// timeouts surface as a status_err line.
1463+
1464+// appendExtensionNote renders an extension-originated note in the
1465+// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
1466+// "success" (tool/ok green).
1467+
1468+// HostHooks implementation for the extension manager. The manager
1469+// holds an interface, not a concrete *Interactive, so these methods
1470+// are the only thing the manager sees.
1471+
1472+// ApplySessionAgent swaps the live agent after a validated session resume.
1473+// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
1474+// context; the session loader is changing the transcript/provider only.
1475+
1476+// SetSubagentSessionScope redraws after a host session transition. Resident
1477+// children retain their explicit parent session in their durable journal.
1478+
1479+// ApplyChangedCWD is called by hosts after a successful /cd hook that do
1480+// not provide startup context metadata.
1481+
1482+// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
1483+// startup context into the running TUI after a successful /cd hook.
1484+
1485+// SubmitSlash runs text as a slash command in the TUI as if the user
1486+// had typed it. text must start with '/' — callers that hand it
1487+// plain prose silently get a no-op so a misbehaving extension can't
1488+// run a stray prompt through this path. Read-only commands run in
1489+// place; commands that would mutate the transcript or replace the
1490+// agent cancel the active turn first via the same path the editor
1491+// uses for typed slash commands.
1492+
1493+// SubmitOrQueue runs text immediately if the agent is idle, or
1494+// appends it to the pending queue if a turn is already in flight.
1495+// Used by the telegram bridge (and by the editor submit path) so
1496+// both input sources share the same "queue behind an active turn"
1497+// semantics. Images are ignored for now — only the text prompt is
1498+// forwarded — because the queued-prompt path is text-only; a
1499+// follow-up can expand the queue entry to carry images.
1500+
1501+// CancelTurn aborts the active turn if one is running. Used by the
1502+// telegram bridge when the paired user sends /stop.
1503+// ChangelogVersion returns the version string of the changelog
1504+// currently shown (or last shown). Used by the dismiss callback
1505+// to store the correct version for dev builds.
1506+
1507+// Insert places text at the cursor in the editor.
1508+
1509+// Display appends a styled note from extName to the chat without a
1510+// model call.
1511+
1512+// ReportError surfaces a host-side error in the interactive status area.
1513+
1514+// SetStatus replaces one persistent status item owned by an extension.
1515+
1516+// SetWidget replaces one persistent widget owned by an extension.
1517+
1518+// ClearWidget removes one persistent widget owned by an extension.
1519+
1520+// ClearExtensionChrome removes every persistent UI item owned by an
1521+// extension that has exited. This keeps crashes, disable/reload operations,
1522+// and startup failures from leaving stale widgets beside the transcript.
1523+
1524+const modalBackdropDimPercent = 50
1525+
1526+// setInputCursorDimmed updates the terminal-owned cursor only when its layer
1527+// changes. Reapplying cursor controls on every redraw can reset its blink
1528+// timing before the terminal completes a normal blink cycle.
1529+
1530+// stripWebSearchTool removes the callable web_search entry from the live
1531+// registry without asking the resolver to run again. It is the fail-closed
1532+// fallback when persistence rollback fails after a refresh failure.
57081533
57091534 // buildStudyPrompt returns the canned prompt the /study command
57101535 // submits to the agent.
@@ -5724,24 +1549,6 @@ func (i *Interactive) applyReasoningSetting(level string) {
57241549 // prompt rather than erroring — the agent will surface the
57251550 // missing-file failure itself when it tries to read it, which is
57261551 // more useful than a refusal here.
5727-func buildStudyPrompt(arg, cwd string) string {
5728- arg = strings.TrimSpace(arg)
5729- if arg == "" {
5730- return "Read and understand everything in the current directory."
5731- }
5732- abs := arg
5733- if !filepath.IsAbs(abs) {
5734- abs = filepath.Join(cwd, abs)
5735- }
5736- display := arg
5737- if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
5738- display = rel
5739- }
5740- if info, err := os.Stat(abs); err == nil && !info.IsDir() {
5741- return "Read and understand the file " + display + "."
5742- }
5743- return "Read and understand everything in the directory " + display + "."
5744-}
57451552
57461553 // tryPathTabCompleteEditor looks at ed's current value, finds the
57471554 // path-like token immediately before the cursor (the cursor is always
@@ -5766,548 +1573,30 @@ func buildStudyPrompt(arg, cwd string) string {
57661573 // Free function (not a method) so the same logic runs against the
57671574 // editor instances owned by local dialogs without each
57681575 // dialog needing its own copy.
5769-func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
5770- if ed == nil {
5771- return false
5772- }
5773- val := ed.Value()
5774- // Find the trailing run of non-whitespace.
5775- start := len(val)
5776- for start > 0 {
5777- r := val[start-1]
5778- if r == ' ' || r == '\t' || r == '\n' {
5779- break
5780- }
5781- start--
5782- }
5783- token := val[start:]
5784- if token == "" {
5785- return false
5786- }
5787- if !looksLikePathToken(token) {
5788- return false
5789- }
5790-
5791- // Resolve the absolute parent directory + base prefix to match.
5792- parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
5793- if !ok {
5794- return true
5795- }
5796- entries, err := os.ReadDir(parentAbs)
5797- if err != nil {
5798- return true
5799- }
5800- var names []string
5801- var isDir []bool
5802- for _, e := range entries {
5803- name := e.Name()
5804- if !strings.HasPrefix(name, basePrefix) {
5805- continue
5806- }
5807- // Hide dotfiles unless the user explicitly typed a leading dot,
5808- // mirroring bash's default behaviour.
5809- if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
5810- continue
5811- }
5812- names = append(names, name)
5813- isDir = append(isDir, e.IsDir())
5814- }
5815- if len(names) == 0 {
5816- return true
5817- }
5818-
5819- var completed string
5820- var completedIsDir bool
5821- if len(names) == 1 {
5822- completed = names[0]
5823- completedIsDir = isDir[0]
5824- } else {
5825- completed = longestCommonPrefix(names)
5826- if completed == basePrefix {
5827- // Already at the deepest unambiguous prefix; nothing to add.
5828- return true
5829- }
5830- }
5831-
5832- // Build the replacement token in the same display form the user
5833- // typed (preserve ~ vs absolute vs relative).
5834- newToken := displayParent + completed
5835- if len(names) == 1 && completedIsDir {
5836- newToken += "/"
5837- }
5838-
5839- ed.SetValue(val[:start] + newToken)
5840- return true
5841-}
58421576
58431577 // tryPathTabComplete is the Interactive-bound convenience wrapper.
5844-// It calls the free helper against the main editor and invalidates
5845-// the frame on a successful rewrite.
5846-func (i *Interactive) tryPathTabComplete() bool {
5847- if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
5848- i.invalidate()
5849- return true
5850- }
5851- return false
5852-}
5853-
5854-// looksLikePathToken reports whether tok is shaped like a filesystem
5855-// path. Paths must either start with ~, /, ./, ../ or contain a /.
5856-// Plain words are excluded so Tab on "hello" stays a no-op.
5857-func looksLikePathToken(tok string) bool {
5858- if tok == "" {
5859- return false
5860- }
5861- if tok[0] == '~' || tok[0] == '/' {
5862- return true
5863- }
5864- if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
5865- return true
5866- }
5867- return strings.Contains(tok, "/")
5868-}
5869-
5870-// resolvePathTabToken splits tok into (absolute parent dir, basename
5871-// prefix to match, display-form parent the user typed). ok is false
5872-// when the parent dir can't be resolved (e.g. ~ with no $HOME).
5873-func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
5874- // Detect ~ expansion.
5875- expanded := tok
5876- homePrefix := ""
5877- if tok == "~" {
5878- home, err := os.UserHomeDir()
5879- if err != nil || home == "" {
5880- return "", "", "", false
5881- }
5882- // "~" alone: complete in $HOME. parent = home, base = "".
5883- return home, "", "~/", true
5884- }
5885- if strings.HasPrefix(tok, "~/") {
5886- home, err := os.UserHomeDir()
5887- if err != nil || home == "" {
5888- return "", "", "", false
5889- }
5890- expanded = home + tok[1:]
5891- homePrefix = "~"
5892- }
5893-
5894- dir, base := splitDirBase(expanded)
5895- if !filepath.IsAbs(dir) {
5896- dir = filepath.Join(cwd, dir)
5897- }
5898-
5899- // Reconstruct the display form the user typed for the parent,
5900- // keeping ~ when they used it. The base is dropped — the caller
5901- // substitutes the completed name.
5902- display := tok[:len(tok)-len(base)]
5903- if homePrefix != "" && !strings.HasPrefix(display, "~") {
5904- display = homePrefix + display[len(homePrefix):]
5905- }
5906- return dir, base, display, true
5907-}
5908-
5909-// splitDirBase is like filepath.Split but preserves the trailing
5910-// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
5911-// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
5912-// the trailing separator when non-empty so callers can rebuild paths
5913-// by concatenation.
5914-func splitDirBase(p string) (dir, base string) {
5915- if p == "" {
5916- return ".", ""
5917- }
5918- i := strings.LastIndex(p, "/")
5919- if i < 0 {
5920- return ".", p
5921- }
5922- return p[:i+1], p[i+1:]
5923-}
5924-
5925-func longestCommonPrefix(ss []string) string {
5926- if len(ss) == 0 {
5927- return ""
5928- }
5929- prefix := ss[0]
5930- for _, s := range ss[1:] {
5931- n := 0
5932- for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
5933- n++
5934- }
5935- prefix = prefix[:n]
5936- if prefix == "" {
5937- return ""
5938- }
5939- }
5940- return prefix
5941-}
5942-
5943-func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5944- parts := strings.Fields(cmd)
5945- if len(parts) == 0 {
5946- return false
5947- }
5948-
5949- if isSkillCommand(cmd) {
5950- var available []*skills.Skill
5951- if i.cfg.SkillSnapshot != nil {
5952- available = i.cfg.SkillSnapshot()
5953- }
5954- prompt, _, err := expandSkillCommand(cmd, available)
5955- if err != nil {
5956- i.mu.Lock()
5957- i.statusErr = err.Error()
5958- i.statusOK = ""
5959- i.mu.Unlock()
5960- return false
5961- }
5962- i.submitOrQueuePrompt(ctx, prompt)
5963- return false
5964- }
1578+// It calls the free helper against the main editor and invalidates
1579+// the frame on a successful rewrite.
59651580
5966- switch strings.ToLower(parts[0]) {
5967- case "/goal":
5968- i.runGoalCommand(ctx, cmd, parts)
5969- case "/exit":
5970- return true
5971- case "/clear":
5972- if i.agent != nil {
5973- i.agent.SetMessages(nil)
5974- }
5975- i.mu.Lock()
5976- i.toolCalls = map[string]*tui.ToolCallView{}
5977- i.toolOrder = nil
5978- i.toolGate = map[string]int{}
5979- i.statusErr = ""
5980- i.statusOK = ""
5981- i.helpBlock = nil
5982- i.parkedTurn = 0
5983- i.parkedTotal = 0
5984- i.scrollOffset = 0
5985- i.extNotes = nil
5986- i.reloadErrors = nil
5987- handoff, persistHandoff := i.resetCompactContinuationLocked()
5988- i.view.InvalidateRenderCache()
5989- i.mu.Unlock()
5990- if persistHandoff {
5991- i.persistCompactHandoff(handoff)
5992- }
5993- case "/help":
5994- i.mu.Lock()
5995- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
5996- i.statusErr = ""
5997- i.statusOK = ""
5998- // Pin the viewport to the newest content so the help block,
5999- // which we just appended to the end of the transcript, is
6000- // what the user actually sees.
6001- i.scrollOffset = 0
6002- i.mu.Unlock()
6003- case "/login":
6004- i.dialog.Open(i.cfg.ZutHome)
6005- case "/logout":
6006- if len(parts) >= 2 {
6007- // Explicit target: /logout anthropic | openai | all
6008- i.doLogout(parts[1])
6009- break
6010- }
6011- // No arg: open the picker over whichever providers are
6012- // currently logged in. If nothing's logged in, bail with a
6013- // status line.
6014- i.openLogoutDialog()
6015- case "/model":
6016- if len(parts) >= 2 {
6017- i.applyModelSelection("", parts[1])
6018- } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
6019- i.mu.Lock()
6020- i.modelRefreshing = true
6021- i.statusOK = "Refreshing models"
6022- i.statusErr = ""
6023- i.mu.Unlock()
6024- go func() {
6025- refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
6026- defer cancel()
6027- i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
6028- }()
6029- } else {
6030- i.openModelPickerAfterRefresh(nil)
6031- }
6032- case "/llama":
6033- if i.cfg.LlamaCPPConfig == nil {
6034- i.mu.Lock()
6035- i.statusErr = "llama.cpp configuration is unavailable"
6036- i.statusOK = ""
6037- i.mu.Unlock()
6038- break
6039- }
6040- baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
6041- if configErr == nil {
6042- configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
6043- }
6044- if configErr != nil {
6045- i.mu.Lock()
6046- i.statusErr = configErr.Error()
6047- i.statusOK = ""
6048- i.mu.Unlock()
6049- }
6050- case "/reasoning":
6051- i.openReasoningDialog()
6052- case "/fast":
6053- enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
6054- i.applySettingToggle("fast_mode", enabled)
6055- case "/orchestrator":
6056- enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled
6057- i.applySettingToggle("auto_subagents_enabled", enabled)
6058- case "/settings":
6059- i.openSettingsDialog()
6060- case "/sessions":
6061- i.mu.Lock()
6062- i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
6063- i.mu.Unlock()
6064- case "/fork":
6065- i.doSessionFork()
6066- case "/jump":
6067- i.openJumpDialog(parts[1:])
6068- case "/btw":
6069- i.openBtwDialog(parts[1:])
6070- case "/skills":
6071- i.openSkillsDialog()
6072- case "/compact":
6073- i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
6074- case "/study":
6075- // Canned prompt that tells the agent to read every file
6076- // in some target so its later turns have the whole thing
6077- // in context. With no argument, the target is the current
6078- // directory. With an argument, the target is whatever the
6079- // user passed — typed by hand, drag-dropped, or selected
6080- // via the @ file picker (which is why we accept both files
6081- // and directories; the @-picker chips for both have already
6082- // been expanded to absolute paths by expandFileChips above).
6083- // Dispatched through the normal queue-or-start path so it
6084- // behaves identically to typing the prompt by hand.
6085- studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
6086- i.mu.Lock()
6087- busy := i.busy
6088- compacting := i.compacting
6089- ag := i.agent
6090- i.mu.Unlock()
6091- if busy {
6092- var handoff json.RawMessage
6093- var persistHandoff bool
6094- if ag != nil && !compacting {
6095- i.mu.Lock()
6096- handoff, persistHandoff = i.resetCompactContinuationLocked()
6097- ag.QueueMessage(studyPrompt)
6098- i.mu.Unlock()
6099- } else {
6100- i.mu.Lock()
6101- handoff, persistHandoff = i.resetCompactContinuationLocked()
6102- i.queued = append(i.queued, studyPrompt)
6103- i.mu.Unlock()
6104- }
6105- if persistHandoff {
6106- i.persistCompactHandoff(handoff)
6107- }
6108- i.invalidate()
6109- break
6110- }
6111- i.startTurn(ctx, studyPrompt)
6112- case "/cd":
6113- // Hidden command: switch the running session's cwd. Not in
6114- // slash_suggest, not in /help. Used by the workspaces
6115- // extension's panel-key Enter handler so picking a row
6116- // jumps zut into that directory without relaunching.
6117- //
6118- // Recovers the raw argument (path) from the original cmd
6119- // string rather than parts, so paths with spaces survive.
6120- // The host's ChangeCWD hook handles validation, session
6121- // close + reopen, agent rebuild, sandbox re-rooting, and
6122- // re-jail-if-jailed semantics.
6123- if i.cfg.ChangeCWD == nil {
6124- i.mu.Lock()
6125- i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
6126- i.mu.Unlock()
6127- break
6128- }
6129- path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
6130- if path == "" {
6131- i.mu.Lock()
6132- i.statusErr = "/cd: missing path"
6133- i.mu.Unlock()
6134- break
6135- }
6136- if err := i.cfg.ChangeCWD(path); err != nil {
6137- i.mu.Lock()
6138- i.statusErr = "/cd: " + err.Error()
6139- i.statusOK = ""
6140- i.mu.Unlock()
6141- break
6142- }
6143- // ChangeCWD has already updated i.cfg.CWD and swapped the
6144- // agent + session. Reset transient TUI state so the new
6145- // session opens clean.
6146- i.mu.Lock()
6147- i.toolCalls = map[string]*tui.ToolCallView{}
6148- i.toolOrder = nil
6149- i.toolGate = map[string]int{}
6150- i.helpBlock = nil
6151- i.parkedTurn = 0
6152- i.statusOK = "cwd " + i.cfg.CWD
6153- i.statusErr = ""
6154- i.mu.Unlock()
6155- i.fileSuggest.Reset()
6156- i.fileSuggest.SetCWD(i.cfg.CWD)
6157- i.invalidate()
6158- case "/jail":
6159- if i.cfg.Sandbox == nil {
6160- i.mu.Lock()
6161- i.statusErr = "sandbox not available in this build"
6162- i.mu.Unlock()
6163- break
6164- }
6165- i.cfg.Sandbox.Lock()
6166- i.mu.Lock()
6167- i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
6168- i.statusErr = ""
6169- i.mu.Unlock()
6170- case "/unjail":
6171- if i.cfg.Sandbox == nil {
6172- i.mu.Lock()
6173- i.statusErr = "sandbox not available in this build"
6174- i.mu.Unlock()
6175- break
6176- }
6177- i.cfg.Sandbox.Unlock()
6178- i.mu.Lock()
6179- i.statusOK = "unjailed"
6180- i.statusErr = ""
6181- i.mu.Unlock()
6182- case "/reload-ext":
6183- i.runReloadExt(ctx)
6184- case "/telegram", "/tg":
6185- if len(parts) >= 2 {
6186- i.doTelegram(parts[1])
6187- break
6188- }
6189- i.openTelegramDialog()
6190- case "/session":
6191- if len(parts) >= 2 {
6192- action := parts[1]
6193- arg := ""
6194- if len(parts) >= 3 {
6195- arg = strings.Join(parts[2:], " ")
6196- }
6197- i.doSessionOp(action, arg)
6198- break
6199- }
6200- i.openSessionOpsDialog()
6201- case "/subagents":
6202- i.runSubagents(ctx, parts[1:])
6203- default:
6204- // Last-resort fallback: try the extension manager. Built-in
6205- // cases above always win; this branch only fires for slash
6206- // commands the extension manager registered. Same routing as
6207- // the editor's submit-handler dispatch path so the autocomplete
6208- // "enter on highlighted suggestion" flow also works.
6209- extName := strings.TrimPrefix(parts[0], "/")
6210- if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
6211- rest := ""
6212- if len(parts) > 1 {
6213- rest = strings.Join(parts[1:], " ")
6214- }
6215- go i.invokeExtensionCommand(ctx, extName, rest)
6216- return false
6217- }
6218- i.mu.Lock()
6219- i.statusErr = "unknown command: " + parts[0]
6220- i.mu.Unlock()
6221- }
6222- return false
6223-}
1581+// looksLikePathToken reports whether tok is shaped like a filesystem
1582+// path. Paths must either start with ~, /, ./, ../ or contain a /.
1583+// Plain words are excluded so Tab on "hello" stays a no-op.
62241584
6225-func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
6226- var loggedIn []string
6227- if i.cfg.LoggedInProviders != nil {
6228- loggedIn = i.cfg.LoggedInProviders()
6229- }
6230- i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
6231- i.mu.Lock()
6232- if refreshErr != nil {
6233- i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
6234- i.statusOK = ""
6235- } else if i.statusOK == "Refreshing models" {
6236- i.statusOK = ""
6237- }
6238- i.mu.Unlock()
6239-}
1585+// resolvePathTabToken splits tok into (absolute parent dir, basename
1586+// prefix to match, display-form parent the user typed). ok is false
1587+// when the parent dir can't be resolved (e.g. ~ with no $HOME).
1588+
1589+// splitDirBase is like filepath.Split but preserves the trailing
1590+// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
1591+// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
1592+// the trailing separator when non-empty so callers can rebuild paths
1593+// by concatenation.
62401594
62411595 // openLogoutDialog shows the provider picker for `/logout` with no
62421596 // argument. Only providers the user is currently logged into are
62431597 // listed, plus an "all" entry when more than one is present. If
62441598 // nothing's logged in, writes a status line instead of opening an
62451599 // empty dialog.
6246-func (i *Interactive) openLogoutDialog() {
6247- if i.cfg.AuthManager == nil {
6248- i.mu.Lock()
6249- i.statusErr = "no auth manager configured"
6250- i.mu.Unlock()
6251- i.invalidate()
6252- return
6253- }
6254- store := i.cfg.AuthManager.Store()
6255- if store == nil {
6256- i.mu.Lock()
6257- i.statusErr = "auth store is not available"
6258- i.mu.Unlock()
6259- i.invalidate()
6260- return
6261- }
6262- creds, err := store.Load()
6263- if err != nil {
6264- i.mu.Lock()
6265- i.statusErr = "read auth store: " + err.Error()
6266- i.mu.Unlock()
6267- i.invalidate()
6268- return
6269- }
6270-
6271- var items []logoutItem
6272- for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
6273- if creds.Has(p) {
6274- method := creds.Method(p)
6275- if method == "oauth" {
6276- method = "subscription"
6277- }
6278- items = append(items, logoutItem{
6279- label: providerLabel(p),
6280- target: p,
6281- method: method,
6282- })
6283- }
6284- }
6285- if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
6286- items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
6287- }
6288- if creds.OpenAI.OAuth != nil {
6289- items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
6290- }
6291- for p, c := range creds.AdditionalAPIKeyCreds {
6292- if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
6293- items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
6294- }
6295- }
6296- if len(items) == 0 {
6297- i.mu.Lock()
6298- i.statusOK = "no credentials stored; already logged out"
6299- i.statusErr = ""
6300- i.mu.Unlock()
6301- i.invalidate()
6302- return
6303- }
6304- if len(items) > 1 {
6305- items = append(items, logoutItem{label: "all", target: "all"})
6306- }
6307-
6308- i.logoutDialog.Open(items)
6309- i.invalidate()
6310-}
63111600
63121601 // doLogout clears credentials for the given provider (or all providers)
63131602 // from auth.json. If the active agent was using those credentials, it
@@ -6315,263 +1604,6 @@ func (i *Interactive) openLogoutDialog() {
63151604 // prompt.
63161605 //
63171606 // target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
6318-func (i *Interactive) doLogout(target string) {
6319- if i.cfg.AuthManager == nil {
6320- i.mu.Lock()
6321- i.statusErr = "no auth manager configured"
6322- i.mu.Unlock()
6323- return
6324- }
6325- store := i.cfg.AuthManager.Store()
6326- if store == nil {
6327- i.mu.Lock()
6328- i.statusErr = "auth store is not available"
6329- i.mu.Unlock()
6330- return
6331- }
6332-
6333- var providers []string
6334- switch target {
6335- case "", "all":
6336- providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
6337- case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
6338- providers = []string{target}
6339- default:
6340- known := false
6341- for _, p := range auth.APIKeyProviders() {
6342- if target == p {
6343- known = true
6344- break
6345- }
6346- }
6347- if !known {
6348- i.mu.Lock()
6349- i.statusErr = "unknown provider: " + target
6350- i.mu.Unlock()
6351- return
6352- }
6353- providers = []string{target}
6354- }
6355-
6356- var errs []string
6357- clearedCurrent := false
6358- for _, p := range providers {
6359- var err error
6360- switch p {
6361- case "openai":
6362- err = store.ClearAPIKey("openai")
6363- case "openai-codex":
6364- err = store.ClearOAuth("openai")
6365- default:
6366- err = store.Clear(p)
6367- }
6368- if err != nil {
6369- errs = append(errs, p+": "+err.Error())
6370- continue
6371- }
6372- if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6373- if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
6374- errs = append(errs, p+": "+err.Error())
6375- continue
6376- }
6377- }
6378- if p == i.cfg.Provider {
6379- clearedCurrent = true
6380- }
6381- }
6382-
6383- llamaConfigured := i.llamaConfigured
6384- if i.cfg.LlamaCPPConfig != nil {
6385- baseURL, _, err := i.cfg.LlamaCPPConfig()
6386- llamaConfigured = err == nil && baseURL != ""
6387- }
6388-
6389- i.mu.Lock()
6390- i.llamaConfigured = llamaConfigured
6391- if len(errs) > 0 {
6392- i.statusErr = "logout errors: " + strings.Join(errs, "; ")
6393- i.mu.Unlock()
6394- return
6395- }
6396- i.statusErr = ""
6397- var handoff json.RawMessage
6398- var persistHandoff bool
6399- if clearedCurrent {
6400- // The running agent was using a credential we just wiped. Drop
6401- // it so prompts can't go out with the stale client, and hint at
6402- // /login. Close any LSP processes owned by its tools first.
6403- if i.agent != nil {
6404- _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6405- }
6406- i.agent = nil
6407- handoff, persistHandoff = i.resetCompactContinuationLocked()
6408- i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
6409- } else {
6410- i.statusOK = "logged out of " + strings.Join(providers, ", ")
6411- }
6412- i.mu.Unlock()
6413- if persistHandoff {
6414- i.persistCompactHandoff(handoff)
6415- }
6416-}
6417-
6418-func providerSetupInfo(provider string) (string, []string, bool) {
6419- const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
6420- switch provider {
6421- case "amazon-bedrock":
6422- return "Amazon Bedrock setup", []string{
6423- "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
6424- "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
6425- "",
6426- "For Bedrock API keys, set:",
6427- " AWS_BEARER_TOKEN_BEDROCK=...",
6428- " AWS_REGION=us-east-1",
6429- "",
6430- "Docs:",
6431- " " + docsURL,
6432- }, true
6433- case "google-vertex":
6434- return "Google Vertex AI setup", []string{
6435- "Google Vertex AI usually uses Google Cloud credentials and project settings.",
6436- "Set a Google API key, application-default credentials, or a service account.",
6437- "",
6438- "Common environment:",
6439- " GOOGLE_CLOUD_API_KEY=...",
6440- " GOOGLE_CLOUD_PROJECT=...",
6441- " GOOGLE_CLOUD_LOCATION=us-central1",
6442- "",
6443- "Docs:",
6444- " " + docsURL,
6445- }, true
6446- case "cloudflare-workers-ai":
6447- return "Cloudflare Workers AI setup", []string{
6448- "Cloudflare Workers AI needs both an API token and an account ID.",
6449- "",
6450- "Set:",
6451- " CLOUDFLARE_API_KEY=...",
6452- " CLOUDFLARE_ACCOUNT_ID=...",
6453- "",
6454- "Docs:",
6455- " " + docsURL,
6456- }, true
6457- case "cloudflare-ai-gateway":
6458- return "Cloudflare AI Gateway setup", []string{
6459- "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
6460- "",
6461- "Set:",
6462- " CLOUDFLARE_API_KEY=...",
6463- " CLOUDFLARE_ACCOUNT_ID=...",
6464- " CLOUDFLARE_GATEWAY_ID=...",
6465- "",
6466- "Docs:",
6467- " " + docsURL,
6468- }, true
6469- case "azure-openai-responses":
6470- return "Azure OpenAI Responses setup", []string{
6471- "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
6472- "",
6473- "Set:",
6474- " AZURE_OPENAI_API_KEY=...",
6475- " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
6476- " AZURE_OPENAI_API_VERSION=2024-02-01",
6477- "",
6478- "Docs:",
6479- " " + docsURL,
6480- }, true
6481- default:
6482- return "", nil, false
6483- }
6484-}
6485-
6486-func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
6487- if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
6488- i.dialog.ShowResult(false, "auth store is unavailable")
6489- return
6490- }
6491- if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
6492- i.dialog.ShowResult(false, err.Error())
6493- return
6494- }
6495- i.mu.Lock()
6496- i.llamaConfigured = true
6497- i.statusErr = ""
6498- i.statusOK = "configured llama.cpp router"
6499- i.mu.Unlock()
6500- i.dialog.ShowResult(true, "")
6501-}
6502-
6503-func (i *Interactive) startAPIKeyFlow(provider string) {
6504- if title, lines, ok := providerSetupInfo(provider); ok {
6505- i.dialog.ShowInfo(title, lines)
6506- return
6507- }
6508- if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6509- _ = i.cfg.SetKimiCLIFallbackDisabled(false)
6510- }
6511- url, err := i.cfg.AuthManager.StartAPIKey(provider)
6512- if err != nil {
6513- i.dialog.ShowResult(false, err.Error())
6514- return
6515- }
6516- i.dialog.ShowWaiting(url)
6517-}
6518-
6519-func (i *Interactive) startOAuthFlow(provider string) {
6520- if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6521- _ = i.cfg.SetKimiCLIFallbackDisabled(false)
6522- }
6523- // Device-code providers already support headless login and must only
6524- // start one polling flow.
6525- if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
6526- loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
6527- if err != nil {
6528- i.dialog.ShowResult(false, err.Error())
6529- return
6530- }
6531- i.dialog.ShowWaiting(loginURL)
6532- return
6533- }
6534- // Always run the manual/copy-code flow in parallel with the local
6535- // callback server so headless environments (docker, SSH) can paste
6536- // the authorization code directly without first pressing 'p'.
6537- _, err := i.cfg.AuthManager.StartOAuth(provider)
6538- if err != nil {
6539- i.dialog.ShowResult(false, err.Error())
6540- return
6541- }
6542- manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
6543- if mErr == nil {
6544- i.dialog.ShowWaiting(manualURL)
6545- } else {
6546- i.dialog.ShowResult(false, mErr.Error())
6547- }
6548-}
6549-
6550-func (i *Interactive) startManualOAuthFlow(provider string) {
6551- if i.cfg.AuthManager == nil {
6552- return
6553- }
6554- i.cfg.AuthManager.CancelOAuth()
6555- url, err := i.cfg.AuthManager.StartManualOAuth(provider)
6556- if err != nil {
6557- i.dialog.ShowResult(false, err.Error())
6558- return
6559- }
6560- i.dialog.url = url
6561- i.invalidate()
6562-}
6563-
6564-func (i *Interactive) submitManualOAuthCode(code string) {
6565- if i.cfg.AuthManager == nil {
6566- return
6567- }
6568- go func() {
6569- if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
6570- i.dialog.ShowResult(false, err.Error())
6571- i.invalidate()
6572- }
6573- }()
6574-}
65751607
65761608 // applyModelSelection switches the active model (and provider, if the
65771609 // new model belongs to a different one). It rebuilds the underlying
@@ -6585,541 +1617,59 @@ func (i *Interactive) submitManualOAuthCode(code string) {
65851617 // The wait is bounded; if the turn doesn't release within the timeout
65861618 // we proceed anyway. Worst case is a brief overlap that the agent's
65871619 // own mutex protects against.
6588-func (i *Interactive) cancelAndWaitForIdle() {
6589- i.mu.Lock()
6590- busy := i.busy
6591- cancel := i.cancelTurn
6592- i.mu.Unlock()
6593- if !busy {
6594- return
6595- }
6596- if cancel != nil {
6597- i.mu.Lock()
6598- handoff, persistHandoff := i.resetCompactContinuationLocked()
6599- i.mu.Unlock()
6600- cancel()
6601- if persistHandoff {
6602- i.persistCompactHandoff(handoff)
6603- }
6604- }
6605- // ConfirmToolCall waits on its response channel rather than the turn
6606- // context, so cancellation must also resolve every pending prompt.
6607- i.confirmDialog.CancelAll("turn cancelled")
6608- deadline := time.Now().Add(2 * time.Second)
6609- for time.Now().Before(deadline) {
6610- i.mu.Lock()
6611- done := !i.busy
6612- i.mu.Unlock()
6613- if done {
6614- return
6615- }
6616- time.Sleep(10 * time.Millisecond)
6617- }
6618-}
66191620
66201621 // openBtwDialog opens the side-chat overlay with a frozen snapshot
66211622 // of the current main session. The optional argument is auto-
66221623 // submitted as the first question, so '/btw does X work?' fires the
66231624 // model call immediately instead of just opening an empty dialog.
6624-func (i *Interactive) openBtwDialog(args []string) {
6625- i.agentMu.Lock()
6626- defer i.agentMu.Unlock()
6627- i.mu.Lock()
6628- ag := i.agent
6629- system := ""
6630- if ag != nil {
6631- system, _ = ag.PromptConfig()
6632- }
6633- theme := i.cfg.Theme
6634- model := i.cfg.Model
6635- cwd := i.cfg.CWD
6636- compact := i.compactModeEnabled()
6637- flatTools := i.cfg.FlatTools
6638- lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
6639- i.mu.Unlock()
6640- if ag == nil {
6641- i.mu.Lock()
6642- i.statusErr = "not logged in. type /login first."
6643- i.mu.Unlock()
6644- return
6645- }
6646- seed := strings.TrimSpace(strings.Join(args, " "))
6647- i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
6648- i.invalidate()
6649-}
66501625
66511626 // submitOrQueuePrompt submits a slash command's expanded prompt immediately,
66521627 // or queues it behind the active turn using the normal text-only queue.
6653-func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6654- i.mu.Lock()
6655- if i.agent == nil {
6656- i.statusErr = "not logged in. type /login first."
6657- i.statusOK = ""
6658- i.mu.Unlock()
6659- i.invalidate()
6660- return
6661- }
6662- if i.busy {
6663- // Keep the mutex held while enqueueing so the turn-completion
6664- // goroutine cannot publish idle and inspect an empty queue between
6665- // this check and the append.
6666- var handoff json.RawMessage
6667- var persistHandoff bool
6668- if i.compacting {
6669- handoff, persistHandoff = i.resetCompactContinuationLocked()
6670- i.queued = append(i.queued, prompt)
6671- } else {
6672- handoff, persistHandoff = i.resetCompactContinuationLocked()
6673- i.agent.QueueMessage(prompt)
6674- }
6675- i.mu.Unlock()
6676- if persistHandoff {
6677- i.persistCompactHandoff(handoff)
6678- }
6679- i.invalidate()
6680- return
6681- }
6682- i.mu.Unlock()
6683- i.startTurn(ctx, prompt)
6684-}
66851628
66861629 // openSkillsDialog opens the skill inspector. The picker reflects
66871630 // whatever SkillSnapshot returns at call time, so edits to a
66881631 // SKILL.md made during a session show up on the next /skills.
6689-func (i *Interactive) openSkillsDialog() {
6690- var list []*skills.Skill
6691- if i.cfg.SkillSnapshot != nil {
6692- list = i.cfg.SkillSnapshot()
6693- }
6694- i.skillsDialog.Open(list)
6695- i.invalidate()
6696-}
66971632
66981633 // openJumpDialog builds a /jump picker from the current transcript.
66991634 // If the user typed "/jump foo" with a filter and it matches exactly
67001635 // one turn, jump there directly without showing the dialog.
6701-func (i *Interactive) openJumpDialog(args []string) {
6702- if i.view == nil || len(i.view.Messages) == 0 {
6703- i.mu.Lock()
6704- i.statusErr = "nothing to jump to \u2014 the session is empty"
6705- i.mu.Unlock()
6706- return
6707- }
6708- filter := strings.TrimSpace(strings.Join(args, " "))
6709- i.jumpDialog.Open(i.view.Messages, filter)
6710- // Shortcut: with a filter argument that matches exactly one turn,
6711- // jump immediately and skip the picker.
6712- if filter != "" {
6713- if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
6714- t := tgts[0]
6715- i.jumpDialog.Close()
6716- i.applyJumpSelection(t.MessageIdx, t.TurnNo)
6717- }
6718- }
6719-}
6720-
6721-// applyJumpSelection scrolls the chat viewport so the user message at
6722-// msgIdx is visible at (or near) the top of the chat area. Uses the
6723-// anchor slice returned by view.BuildWithAnchors so the mapping from
6724-// message index to row is exact, regardless of variable-height tool
6725-// blocks above the target.
6726-func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
6727- cols := i.lastCols()
6728- chat, anchors := i.view.BuildWithAnchors(cols)
6729- var row int
6730- found := false
6731- for _, a := range anchors {
6732- if a.MessageIdx == msgIdx {
6733- row = a.Row
6734- found = true
6735- break
6736- }
6737- }
6738- if !found {
6739- i.mu.Lock()
6740- i.statusErr = "could not resolve jump target"
6741- i.mu.Unlock()
6742- return
6743- }
67441636
6745- chatLen := len(chat)
6746- page := i.chatPage()
6747- if page < 1 {
6748- page = 1
6749- }
6750- // scrollOffset is measured from the bottom of the chat slice, so
6751- // to place `row` at the top of the viewport we want:
6752- // chatLen - scrollOffset - page == row
6753- // Solve for scrollOffset and clamp to [0, chatLen-page].
6754- offset := chatLen - (row + page)
6755- if offset < 0 {
6756- offset = 0
6757- }
6758- maxOffset := chatLen - page
6759- if maxOffset < 0 {
6760- maxOffset = 0
6761- }
6762- if offset > maxOffset {
6763- offset = maxOffset
6764- }
6765-
6766- i.mu.Lock()
6767- i.scrollOffset = offset
6768- i.parkedTurn = turnNo
6769- i.parkedTotal = totalTurnsLocked(i.view.Messages)
6770- i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
6771- i.statusErr = ""
6772- i.mu.Unlock()
6773-}
6774-
6775-// totalTurnsLocked counts user messages in the transcript. Caller is
6776-// assumed to hold i.mu (the name is a mild reminder; this function
6777-// itself doesn't touch shared state beyond the slice it's handed).
6778-func totalTurnsLocked(msgs []provider.Message) int {
6779- n := 0
6780- for _, m := range msgs {
6781- if m.Role == provider.RoleUser {
6782- n++
6783- }
6784- }
6785- return n
6786-}
6787-
6788-// applySessionSelection loads the given session via the cli-provided
6789-// callback and snaps the viewport to the bottom (the latest message)
6790-// so the user lands at the live tail of the resumed conversation.
6791-func (i *Interactive) applySessionSelection(path string) {
6792- if i.cfg.LoadSession == nil {
6793- i.mu.Lock()
6794- i.statusErr = "session loading is not wired in this build"
6795- i.mu.Unlock()
6796- return
6797- }
6798- i.mu.Lock()
6799- if i.sessionLoading {
6800- i.statusErr = "already resuming a session"
6801- i.mu.Unlock()
6802- i.invalidate()
6803- return
6804- }
6805- i.sessionLoading = true
6806- i.statusOK = "resuming session: " + path
6807- i.statusErr = ""
6808- i.mu.Unlock()
6809- i.invalidate()
6810- i.markSessionTitleSwitching()
6811-
6812- go func() {
6813- err := i.cfg.LoadSession(path)
6814- if err != nil {
6815- i.restoreFailedSessionTitle()
6816- i.mu.Lock()
6817- i.sessionLoading = false
6818- i.statusErr = err.Error()
6819- i.statusOK = ""
6820- i.mu.Unlock()
6821- i.invalidate()
6822- return
6823- }
6824- i.restoreLoadedSessionTitle()
6825- state := decodeCompactHandoff(i.currentCompactHandoff())
6826- i.mu.Lock()
6827- i.compactContinuation = state
6828- i.sessionLoading = false
6829- i.statusOK = "resumed session: " + path
6830- i.statusErr = ""
6831- i.parkedTurn = 0
6832- i.parkedTotal = 0
6833- i.scrollOffset = 0
6834- // Fresh transcript swapped in: drop the auto-follow baseline so
6835- // the next render's follow guard doesn't see the wholesale
6836- // length change as a delta and jump the viewport.
6837- i.prevChatLen = 0
6838- i.prevChatCols = 0
6839- i.extNotes = nil
6840- i.view.InvalidateRenderCache()
6841- if i.agent != nil {
6842- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
6843- i.cumUsage = i.agent.Cost()
6844- if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
6845- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
6846- } else {
6847- i.lastCtxInput = 0
6848- }
6849- // Snap to the tail again — the swap brought in a fresh
6850- // transcript whose markdown / chroma cost we don't want
6851- // blocking the redraw.
6852- if len(i.view.Messages) > initialResumeTailLimit {
6853- i.view.TailLimit = initialResumeTailLimit
6854- } else {
6855- i.view.TailLimit = 0
6856- }
6857- }
6858- i.mu.Unlock()
6859- i.invalidate()
6860- if state.reason != compactContinuationNone {
6861- i.startRestoredCompactHandoff(i.runCtx)
6862- }
6863- }()
6864-}
6865-
6866-func (i *Interactive) applyModelSelection(prov, model string) {
6867- i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
6868-}
6869-
6870-// applyRescueModelSelection is like applyModelSelection but routes
6871-// through BuildAgentForRescue so launch-time --api-key / --base-url
6872-// overrides are dropped before the new agent is built. Falls back to
6873-// the regular builder when the host doesn't wire a rescue builder.
6874-func (i *Interactive) applyRescueModelSelection(prov, model string) {
6875- builder := i.cfg.BuildAgentForRescue
6876- if builder == nil {
6877- builder = i.cfg.BuildAgentFor
6878- }
6879- i.swapModel(prov, model, builder, true)
6880-}
6881-
6882-// swapModel applies a /model selection (or a rescue selection) using
6883-// the supplied builder. rescue=true tags the success message so the
6884-// user can see that launch-time overrides were ignored.
6885-func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6886- var replaced bool
6887- swap := func() {
6888- replaced = i.swapModelUnserialized(prov, model, builder, rescue)
6889- }
6890- if i.cfg.SessionTransition != nil {
6891- i.cfg.SessionTransition(swap)
6892- } else {
6893- swap()
6894- }
6895- if replaced {
6896- i.resetCompactHandoff()
6897- }
6898-}
6899-
6900-func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool {
6901- if model == "" {
6902- return false
6903- }
6904- m, err := provider.FindModel(prov, model)
6905- if err != nil {
6906- i.mu.Lock()
6907- i.statusErr = err.Error()
6908- i.mu.Unlock()
6909- return false
6910- }
6911- // Same provider AND not a rescue retry: just swap the model on
6912- // the existing agent. Mixed-API providers dispatch from model metadata,
6913- // so the client remains reusable. Rescue retries always rebuild so a stale
6914- // auth header / base URL can't carry over.
6915- if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
6916- i.mu.Lock()
6917- i.cfg.Model = m.ID
6918- i.agent.Model = m.ID
6919- i.statusOK = "model: " + m.ID
6920- i.statusErr = ""
6921- i.mu.Unlock()
6922- if i.cfg.PersistModel != nil {
6923- i.cfg.PersistModel(i.cfg.Provider, m.ID)
6924- }
6925- return false
6926- }
6927- if builder == nil {
6928- i.mu.Lock()
6929- i.statusErr = "cannot switch provider: no builder configured"
6930- i.mu.Unlock()
6931- return false
6932- }
6933- // Snapshot the current transcript and cumulative usage BEFORE we
6934- // build the replacement agent so we can hand them off. Without
6935- // this the user perceives the entire session as wiped on a
6936- // cross-provider /model swap.
6937- var carryMsgs []provider.Message
6938- var carryCost provider.Usage
6939- if i.agent != nil {
6940- carryMsgs = i.agent.Messages()
6941- carryCost = i.agent.Cost()
6942- }
1637+// applyJumpSelection scrolls the chat viewport so the user message at
1638+// msgIdx is visible at (or near) the top of the chat area. Uses the
1639+// anchor slice returned by view.BuildWithAnchors so the mapping from
1640+// message index to row is exact, regardless of variable-height tool
1641+// blocks above the target.
69431642
6944- ag, p, md, err := builder(m.Provider, m.ID)
6945- if err != nil {
6946- i.mu.Lock()
6947- i.statusErr = err.Error()
6948- i.mu.Unlock()
6949- return false
6950- }
1643+// totalTurnsLocked counts user messages in the transcript. Caller is
1644+// assumed to hold i.mu (the name is a mild reminder; this function
1645+// itself doesn't touch shared state beyond the slice it's handed).
69511646
6952- // Replay the transcript and seed the cost on the freshly-built
6953- // agent. Messages travel cleanly between providers because they
6954- // use the same provider.Message shape; tool-call ids are local
6955- // to a turn so cross-provider continuation never confuses the
6956- // new model (it just sees the assistant's reply, no orphan
6957- // tool_use blocks because /model swaps are gated to idle state).
6958- if len(carryMsgs) > 0 {
6959- ag.SetMessages(carryMsgs)
6960- }
6961- ag.SeedCost(carryCost)
1647+// applySessionSelection loads the given session via the cli-provided
1648+// callback and snaps the viewport to the bottom (the latest message)
1649+// so the user lands at the live tail of the resumed conversation.
69621650
6963- if i.agent != nil && i.agent != ag {
6964- _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6965- }
6966- i.agentMu.Lock()
6967- i.mu.Lock()
6968- i.prepareReplacementAgentLocked(ag)
6969- i.agent = ag
6970- i.cfg.Provider = p
6971- i.cfg.Model = md
6972- if rescue {
6973- i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
6974- } else {
6975- i.statusOK = "switched to " + p + " / " + md
6976- }
6977- i.statusErr = ""
6978- // Render cache keys are width+content based, so the new agent's
6979- // identical messages will reuse the existing entries. Nothing
6980- // to invalidate.
6981- i.mu.Unlock()
6982- i.agentMu.Unlock()
6983- // The new agent was built off the base tool registry, so any
6984- // dynamically-registered tools need to be reattached. The apply
6985- // helpers are no-ops when their feature is inactive, so the
6986- // cross-provider path still works on a vanilla setup.
6987- i.applyAutoSubagentsTool()
6988- i.applyTelegramTools(i.telegramBridge != nil)
6989- if i.cfg.PersistModel != nil {
6990- i.cfg.PersistModel(p, md)
6991- }
6992- return true
6993-}
1651+// applyRescueModelSelection is like applyModelSelection but routes
1652+// through BuildAgentForRescue so launch-time --api-key / --base-url
1653+// overrides are dropped before the new agent is built. Falls back to
1654+// the regular builder when the host doesn't wire a rescue builder.
69941655
6995-func (i *Interactive) handleAuthEvent(ev auth.Event) {
6996- switch ev.Kind {
6997- case "started":
6998- i.dialog.ShowWaiting(ev.URL)
6999- case "browser_open":
7000- // no-op
7001- case "error":
7002- i.dialog.ShowResult(false, ev.Message)
7003- case "success":
7004- // Keep the credential-driven resolve and its final replacement in the
7005- // same transition as settings/session changes. Otherwise a build that
7006- // started under the old web-search policy could commit afterward.
7007- var buildErr error
7008- var clearHandoff bool
7009- buildAndCommitLogin := func() {
7010- ag, prov, model, err := i.cfg.BuildAgent()
7011- if err != nil {
7012- buildErr = err
7013- return
7014- }
7015- i.agentMu.Lock()
7016- i.mu.Lock()
7017- oldAgent := i.agent
7018- i.prepareReplacementAgentLocked(ag)
7019- _, clearHandoff = i.resetCompactContinuationLocked()
7020- i.agent = ag
7021- i.cfg.Provider = prov
7022- i.cfg.Model = model
7023- i.statusErr = ""
7024- i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
7025- i.mu.Unlock()
7026- i.agentMu.Unlock()
7027- if oldAgent != nil {
7028- _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
7029- }
7030- i.applyAutoSubagentsTool()
7031- i.applyTelegramTools(i.telegramBridge != nil)
7032- // Authentication can change the provider used by the live
7033- // agent. Persist it on the active session just like /model.
7034- if i.cfg.PersistModel != nil {
7035- i.cfg.PersistModel(prov, model)
7036- }
7037- }
7038- if i.cfg.SessionTransition != nil {
7039- i.cfg.SessionTransition(buildAndCommitLogin)
7040- } else {
7041- buildAndCommitLogin()
7042- }
7043- if buildErr != nil {
7044- i.dialog.ShowResult(false, buildErr.Error())
7045- return
7046- }
7047- if clearHandoff {
7048- i.persistCompactHandoff(nil)
7049- }
7050- i.dialog.ShowResult(true, "")
7051- }
7052-}
1656+// swapModel applies a /model selection (or a rescue selection) using
1657+// the supplied builder. rescue=true tags the success message so the
1658+// user can see that launch-time overrides were ignored.
70531659
70541660 // clearPendingCompactTurnLocked drops work that only makes sense after the
70551661 // current compaction. The caller must hold i.mu.
7056-func (i *Interactive) clearPendingCompactTurnLocked() {
7057- i.pendingCompactPrompt = ""
7058- i.pendingCompactImages = nil
7059- i.hasPendingCompactPrompt = false
7060- i.continueAfterCompact = false
7061- i.pendingPostCompactNote = ""
7062-}
70631662
70641663 // compactHandoffLocked returns a self-contained persistence snapshot. The
70651664 // caller must hold i.mu.
7066-func (i *Interactive) compactHandoffLocked() json.RawMessage {
7067- return encodeCompactHandoff(i.compactContinuation)
7068-}
70691665
70701666 // setCompactContinuationLocked updates the private handoff state and returns
70711667 // its persistence snapshot plus whether persistence is needed. The caller must
70721668 // hold i.mu.
7073-func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
7074- if i.compactContinuation == state {
7075- return i.compactHandoffLocked(), false
7076- }
7077- i.compactContinuation = state
7078- return i.compactHandoffLocked(), true
7079-}
70801669
70811670 // resetCompactContinuationLocked clears the private handoff state and returns
70821671 // its persistence snapshot plus whether persistence is needed. The caller must
70831672 // hold i.mu.
7084-func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
7085- return i.setCompactContinuationLocked(compactContinuationState{})
7086-}
7087-
7088-func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
7089- if persist := i.cfg.PersistCompactHandoff; persist != nil {
7090- if err := persist(state); err != nil {
7091- i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
7092- }
7093- }
7094-}
7095-
7096-func (i *Interactive) resetCompactHandoff() {
7097- i.mu.Lock()
7098- state, persist := i.resetCompactContinuationLocked()
7099- i.mu.Unlock()
7100- if persist {
7101- i.persistCompactHandoff(state)
7102- }
7103-}
7104-
7105-func (i *Interactive) currentCompactHandoff() json.RawMessage {
7106- if current := i.cfg.CurrentCompactHandoff; current != nil {
7107- return current()
7108- }
7109- return nil
7110-}
7111-
7112-func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
7113- i.mu.Lock()
7114- i.compactContinuation = state
7115- i.mu.Unlock()
7116-}
7117-
7118-func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
7119- state := decodeCompactHandoff(i.currentCompactHandoff())
7120- i.restoreCompactHandoff(state)
7121- return state
7122-}
71231673
71241674 // autoCompactContinuationPrompt is an interactive-only compact-handoff
71251675 // instruction. Its completion criteria must stay separate from Ponytail so
@@ -7139,147 +1689,16 @@ const (
71391689 // explicit user message supersedes the checkpoint; tool activity remains an
71401690 // unfinished existing turn. No hidden prompt means the process stopped after
71411691 // recording the handoff but before appending its context message.
7142-func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
7143- lastHandoff := -1
7144- for idx := len(messages) - 1; idx >= 0; idx-- {
7145- message := messages[idx]
7146- if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
7147- lastHandoff = idx
7148- break
7149- }
7150- }
7151- if lastHandoff < 0 {
7152- return compactHandoffAppendPrompt
7153- }
7154- for _, message := range messages[lastHandoff+1:] {
7155- switch message.Role {
7156- case provider.RoleTool:
7157- continue
7158- case provider.RoleAssistant:
7159- hasToolCall := false
7160- for _, content := range message.Content {
7161- if _, ok := content.(provider.ToolCallBlock); ok {
7162- hasToolCall = true
7163- break
7164- }
7165- }
7166- if !hasToolCall {
7167- return compactHandoffDiscard
7168- }
7169- default:
7170- return compactHandoffDiscard
7171- }
7172- }
7173- return compactHandoffContinueExisting
7174-}
71751692
71761693 // startRestoredCompactHandoff resumes a handoff checkpoint restored from the
71771694 // active session. When its hidden context message was already persisted, the
71781695 // agent continues that existing turn rather than appending it again.
7179-func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
7180- if parent == nil {
7181- parent = i.runCtx
7182- }
7183- if parent == nil {
7184- parent = context.Background()
7185- }
7186- i.mu.Lock()
7187- state := i.compactContinuation
7188- ag := i.agent
7189- busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
7190- if state.reason == compactContinuationNone || ag == nil || busy {
7191- i.mu.Unlock()
7192- return
7193- }
7194-
7195- var next string
7196- continueQueued := false
7197- var resume compactHandoffResume
7198- var handoff json.RawMessage
7199- var persistHandoff bool
7200- switch {
7201- case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
7202- next, i.queued = i.queued[0], i.queued[1:]
7203- handoff, persistHandoff = i.resetCompactContinuationLocked()
7204- case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
7205- continueQueued = true
7206- handoff, persistHandoff = i.resetCompactContinuationLocked()
7207- default:
7208- resume = classifyCompactHandoffResume(ag.Messages())
7209- if resume == compactHandoffDiscard {
7210- handoff, persistHandoff = i.resetCompactContinuationLocked()
7211- }
7212- }
7213- // Reserve the turn slot before persisting or dispatching so concurrent input
7214- // can only queue behind the restored handoff, never start a competing turn.
7215- starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
7216- i.busy = starting
7217- i.mu.Unlock()
7218- if persistHandoff {
7219- i.persistCompactHandoff(handoff)
7220- }
7221- if !starting {
7222- i.invalidate()
7223- return
7224- }
7225- switch {
7226- case next != "":
7227- i.startTurn(parent, next)
7228- case continueQueued:
7229- i.startTurnRequest(parent, "", nil, true, false)
7230- case resume == compactHandoffContinueExisting:
7231- i.startTurnRequest(parent, "", nil, true, false)
7232- case resume == compactHandoffAppendPrompt:
7233- i.startAutoCompactContinuation(parent)
7234- }
7235-}
72361696
72371697 // startAutoCompactContinuation adds an internal user turn that tells the
72381698 // model to resume the task after threshold compaction. Provider requests
72391699 // cannot continue from an assistant tail on every supported API, so the
72401700 // continuation must be represented as a user message. It is hidden from
72411701 // the rendered transcript but remains in the persisted/model context.
7242-func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
7243- i.mu.Lock()
7244- ag := i.agent
7245- if ag == nil {
7246- i.busy = false
7247- pendingIdleWork := i.takePendingIdleWorkLocked()
7248- i.mu.Unlock()
7249- runPendingIdleWork(pendingIdleWork)
7250- i.invalidate()
7251- return
7252- }
7253- // The caller selected the continuation while holding i.mu. Keep that
7254- // ordering through the hidden append so an explicitly queued user prompt
7255- // cannot be overtaken by an old-task handoff. Forced truncation is the
7256- // existing exception: it keeps priority and the queued prompt runs after it.
7257- if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
7258- next := i.queued[0]
7259- i.queued = i.queued[1:]
7260- handoff, persistHandoff := i.resetCompactContinuationLocked()
7261- i.mu.Unlock()
7262- if persistHandoff {
7263- i.persistCompactHandoff(handoff)
7264- }
7265- i.startTurn(parent, next)
7266- return
7267- }
7268- if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
7269- handoff, persistHandoff := i.resetCompactContinuationLocked()
7270- i.mu.Unlock()
7271- if persistHandoff {
7272- i.persistCompactHandoff(handoff)
7273- }
7274- i.startTurnRequest(parent, "", nil, true, false)
7275- return
7276- }
7277- ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
7278- autoCompactContinueMetaKey: "true",
7279- })
7280- i.mu.Unlock()
7281- i.startTurnRequest(parent, "", nil, true, false)
7282-}
72831702
72841703 // runCompact invokes core.Agent.Compact and reflects the progress in
72851704 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
@@ -7290,222 +1709,12 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
72901709 // percentage so it's obvious the system triggered this, not the user.
72911710 // request.force tells the post-compact hand-off to resume even if the
72921711 // transcript tail has visible assistant text, for example after StopLength.
7293-func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
7294- auto := request.origin != compactOriginManual
7295- if i.agent == nil {
7296- i.mu.Lock()
7297- i.statusErr = "not logged in. type /login first."
7298- i.mu.Unlock()
7299- return
7300- }
7301- ctx, cancel := context.WithCancel(parent)
7302- i.mu.Lock()
7303- i.busy = true
7304- i.compacting = true
7305- i.spin.Start()
7306- i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
7307- if auto {
7308- i.activity.kind = activityCondensingHistory
7309- i.autoCompacting = true
7310- } else {
7311- i.activity.kind = activityCompactingHistory
7312- }
7313- i.cancelTurn = cancel
7314- var initialHandoff json.RawMessage
7315- persistInitialHandoff := false
7316- if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
7317- initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
7318- }
7319- i.statusErr = ""
7320- i.statusOK = ""
7321- // Do NOT set streamOn: the summary text should not be visible
7322- // in the chat while compacting. The user just sees the spinner
7323- // and can keep typing / queue prompts.
7324- i.scrollOffset = 0
7325- i.helpBlock = nil
7326- i.mu.Unlock()
7327- if persistInitialHandoff {
7328- i.persistCompactHandoff(initialHandoff)
7329- }
7330- i.invalidate()
7331-
7332- go func() {
7333- // Sink discards deltas — we don't stream the summary to the UI.
7334- sink := func(delta string) {}
7335- msgsBefore := i.agent.Messages()
7336- i.mu.Lock()
7337- statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7338- i.mu.Unlock()
7339- continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
7340- if request.force {
7341- continuationReason = compactContinuationForcedLength
7342- }
7343- // Keep the usual recent tail when possible, but never let it cover
7344- // the whole transcript. A short session can still be at 70–90% of
7345- // a model's window because one prompt or tool result is large; the
7346- // automatic path must summarize that session instead of failing
7347- // before it reaches the compaction provider.
7348- keepTail := 4
7349- if n := len(msgsBefore); n > 0 && keepTail >= n {
7350- keepTail = n - 1
7351- }
7352- summary, err := i.agent.Compact(ctx, keepTail, sink)
7353- _ = summary
7354- goalMessage, goalActive := i.goalContinuationMessage()
7355- i.mu.Lock()
7356- // Keep busy/compacting asserted while cleanup and queue selection run.
7357- // Completion updates can arrive in this window; clearing busy before
7358- // inspecting the queues lets them race a new turn or be stranded.
7359- i.resetStreamingStateLocked()
7360- i.cancelTurn = nil
7361- i.autoCompacting = false
7362- pendingIdleWork := i.takePendingIdleWorkLocked()
7363-
7364- // Drain pending work after the transcript is clean. Overflow recovery
7365- // continues the user message already in the transcript. Pre-turn
7366- // compaction preserves its complete text-and-images request. Regular
7367- // prompts typed during compaction remain in the host queue.
7368- var next string
7369- var nextImages []provider.ImageBlock
7370- var hasNext bool
7371- var continueExisting bool
7372- var continueAutomatically bool
7373- var continueGoal bool
7374- var handoff json.RawMessage
7375- var persistHandoff bool
7376-
7377- switch {
7378- case err != nil && ctx.Err() != nil:
7379- i.statusErr = ""
7380- if auto {
7381- i.statusOK = "auto-condense cancelled"
7382- } else {
7383- i.statusOK = "compaction cancelled"
7384- }
7385- i.queued = nil // drop queue on cancel
7386- i.clearPendingCompactTurnLocked()
7387- handoff, persistHandoff = i.resetCompactContinuationLocked()
7388- if i.agent != nil {
7389- i.agent.DrainQueuedMessages()
7390- }
7391- case err != nil:
7392- i.statusErr = "compaction failed: " + err.Error()
7393- i.statusOK = ""
7394- i.queued = nil // drop queue on error
7395- i.clearPendingCompactTurnLocked()
7396- handoff, persistHandoff = i.resetCompactContinuationLocked()
7397- if i.agent != nil {
7398- i.agent.DrainQueuedMessages()
7399- }
7400- default:
7401- i.statusErr = ""
7402- // Read token count from the compaction message meta.
7403- tokens := ""
7404- msgs := i.agent.Messages()
7405- if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
7406- tokens = msgs[0].Meta["tokens_before"]
7407- }
7408- switch {
7409- case i.pendingPostCompactNote != "":
7410- i.statusOK = i.pendingPostCompactNote
7411- case tokens != "":
7412- i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
7413- default:
7414- i.statusOK = "compacted (ctrl+o to expand)"
7415- }
7416- i.pendingPostCompactNote = ""
7417- i.extNotes = stripAutoCompactNotes(i.extNotes)
7418- i.lastCtxInput = 0
7419- i.toolCalls = map[string]*tui.ToolCallView{}
7420- i.toolOrder = nil
7421- i.toolGate = map[string]int{}
7422- i.resetTranscriptRenderLocked()
7423- switch {
7424- case i.continueAfterCompact:
7425- continueExisting = true
7426- i.continueAfterCompact = false
7427- case i.hasPendingCompactPrompt:
7428- next = i.pendingCompactPrompt
7429- nextImages = i.pendingCompactImages
7430- i.pendingCompactPrompt = ""
7431- i.pendingCompactImages = nil
7432- i.hasPendingCompactPrompt = false
7433- hasNext = true
7434- case continuationReason == compactContinuationForcedLength:
7435- // Forced truncated-output continuation keeps its existing
7436- // priority over an explicitly queued prompt.
7437- handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7438- continueAutomatically = true
7439- case len(i.queued) > 0:
7440- next, i.queued = i.queued[0], i.queued[1:]
7441- hasNext = true
7442- handoff, persistHandoff = i.resetCompactContinuationLocked()
7443- case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
7444- if continuationReason == compactContinuationStatusRescue {
7445- attempts := 1
7446- if i.compactContinuation.reason == compactContinuationStatusRescue {
7447- attempts = i.compactContinuation.rescueAttempts + 1
7448- }
7449- if attempts > maxStatusRescueContinuations {
7450- handoff, persistHandoff = i.resetCompactContinuationLocked()
7451- break
7452- }
7453- handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7454- reason: continuationReason,
7455- rescueAttempts: attempts,
7456- })
7457- } else {
7458- handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7459- }
7460- continueAutomatically = true
7461- case auto && goalActive && !i.coordinatorHasPendingWorkers():
7462- continueGoal = true
7463- handoff, persistHandoff = i.resetCompactContinuationLocked()
7464- default:
7465- handoff, persistHandoff = i.resetCompactContinuationLocked()
7466- }
7467- }
7468- // Keep the host busy until the hand-off decision is committed under
7469- // the mutex. A completion update arriving after the compaction result
7470- // but before this assignment is then queued for the selected follow-up
7471- // instead of starting a competing turn.
7472- i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
7473- i.compacting = false
7474- i.autoCompacting = false
7475- i.mu.Unlock()
7476- if persistHandoff {
7477- i.persistCompactHandoff(handoff)
7478- }
7479- runPendingIdleWork(pendingIdleWork)
7480- i.invalidate()
7481-
7482- if hasNext || continueExisting || continueAutomatically || continueGoal {
7483- p := i.runCtx
7484- if p == nil {
7485- p = context.Background()
7486- }
7487- switch {
7488- case continueExisting:
7489- i.startTurnRequest(p, "", nil, true, true)
7490- case hasNext:
7491- i.startTurnWithImages(p, next, nextImages)
7492- case continueAutomatically:
7493- i.startAutoCompactContinuation(p)
7494- case continueGoal:
7495- i.startGoalContinuation(p, goalMessage)
7496- }
7497- }
7498- }()
7499-}
75001712
75011713 // shellEscapeCommand reports whether text is a "!command" shell
75021714 // escape and, if so, returns the command with the leading '!' (and
75031715 // surrounding whitespace) stripped. A bare "!" with no command is
75041716 // treated as not an escape so it falls through to the normal prompt
75051717 // path rather than running an empty shell.
7506-func shellEscapeCommand(text string) (string, bool) {
7507- return ShellEscapeCommand(text)
7508-}
75091718
75101719 // ShellEscapeCommand reports whether text is a "!command" shell
75111720 // escape and, if so, returns the command with the leading '!' (and
@@ -7514,129 +1723,12 @@ func shellEscapeCommand(text string) (string, bool) {
75141723 // path rather than running an empty shell.
75151724 const ShellEscapeTimeoutSeconds = 600
75161725
7517-func ShellEscapeCommand(text string) (string, bool) {
7518- trimmed := strings.TrimLeft(text, " \t")
7519- if !strings.HasPrefix(trimmed, "!") {
7520- return "", false
7521- }
7522- cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
7523- if cmd == "" {
7524- return "", false
7525- }
7526- return cmd, true
7527-}
7528-
75291726 // startShellEscape runs a "!command" in the same shell the bash tool
75301727 // uses, in the session working directory, honoring the /jail sandbox.
75311728 // It shares the busy/cancel state with the agent: esc cancels it, and
75321729 // it refuses to start while a turn or another shell escape is already
75331730 // in flight. Its terminal-log output is appended to the transcript as
75341731 // user context without automatically starting a model turn.
7535-func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
7536- i.mu.Lock()
7537- if i.busy || i.shellRunning {
7538- i.statusErr = "busy — wait for the current turn to finish before running a shell command"
7539- i.statusOK = ""
7540- i.mu.Unlock()
7541- i.invalidate()
7542- return
7543- }
7544- if parent == nil {
7545- parent = i.runCtx
7546- }
7547- if parent == nil {
7548- parent = context.Background()
7549- }
7550- ctx, cancel := context.WithCancel(parent)
7551- i.busy = true
7552- i.shellRunning = true
7553- i.shellLive = "$ " + cmd + "\n\n"
7554- i.cancelTurn = cancel
7555- i.statusErr = ""
7556- i.statusOK = ""
7557- i.spin.Start()
7558- i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
7559- // Clear stale extension notes the same way a new turn would so the
7560- // screen doesn't accumulate transient state.
7561- i.extNotes = nil
7562- i.scrollOffset = 0
7563- i.parkedTurn = 0
7564- i.parkedTotal = 0
7565- i.helpBlock = nil
7566- sandbox := i.cfg.Sandbox
7567- cwd := i.cfg.CWD
7568- i.mu.Unlock()
7569- i.invalidate()
7570-
7571- go func() {
7572- defer cancel()
7573- raw, _ := json.Marshal(map[string]any{
7574- "command": cmd,
7575- "timeout": ShellEscapeTimeoutSeconds,
7576- })
7577- bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
7578- progress := func(chunk string) {
7579- i.mu.Lock()
7580- i.shellLive += chunk
7581- i.mu.Unlock()
7582- i.invalidate()
7583- }
7584- res, err := bash.Execute(ctx, raw, progress)
7585-
7586- var out string
7587- if err != nil {
7588- out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
7589- } else {
7590- for _, c := range res.Content {
7591- if tb, ok := c.(provider.TextBlock); ok {
7592- out += tb.Text
7593- }
7594- }
7595- }
7596- cancelled := ctx.Err() != nil
7597- failed := err != nil || res.IsError || cancelled
7598- if cancelled {
7599- out += "\n\n[cancelled]"
7600- }
7601-
7602- if i.agent != nil {
7603- i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
7604- }
7605-
7606- i.mu.Lock()
7607- i.shellRunning = false
7608- i.shellLive = ""
7609- i.busy = false
7610- i.cancelTurn = nil
7611- pendingIdleWork := i.takePendingIdleWorkLocked()
7612- awaitingPre := i.awaitingStartupPre
7613- if failed {
7614- if cancelled {
7615- i.statusErr = "shell command cancelled"
7616- } else {
7617- i.statusErr = "shell command failed"
7618- }
7619- i.statusOK = ""
7620- } else {
7621- i.statusOK = "shell command finished"
7622- i.statusErr = ""
7623- }
7624- i.mu.Unlock()
7625- runPendingIdleWork(pendingIdleWork)
7626- i.invalidate()
7627- if awaitingPre {
7628- i.completeStartupPre()
7629- }
7630- }()
7631-}
7632-
7633-func (i *Interactive) startTurn(parent context.Context, prompt string) {
7634- i.startTurnWithImages(parent, prompt, nil)
7635-}
7636-
7637-func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
7638- i.startTurnRequest(parent, prompt, images, false, false)
7639-}
76401732
76411733 // startTurnRequest starts a new prompt or continues an existing user turn.
76421734 // continueExisting selects Agent.Continue so callers can retry a transcript
@@ -7644,313 +1736,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
76441736 // the pre-turn threshold guard and the one-shot overflow recovery path; a
76451737 // normal post-compaction continuation leaves it false so a still-too-large
76461738 // rebuilt context can recover once more.
7647-func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
7648- if i.agent == nil {
7649- // Text startup pre cannot run without credentials; continue so
7650- // deferred InitialInput (pre-fill or auto-submit) still applies.
7651- i.mu.Lock()
7652- awaitingPre := i.awaitingStartupPre
7653- i.mu.Unlock()
7654- if awaitingPre {
7655- i.completeStartupPre()
7656- }
7657- return
7658- }
7659- // Pre-turn safety: if the most recent context measurement is
7660- // already past the auto-compact threshold, condense before
7661- // sending so the next outbound request stays under the limit.
7662- // The condense flow re-fires the user's queued prompt for us, so
7663- // we just hand it off and exit.
7664- i.mu.Lock()
7665- var resetHandoff json.RawMessage
7666- var persistResetHandoff bool
7667- if !continueExisting {
7668- resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
7669- }
7670- needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
7671- if needsPreCompact {
7672- // Reserve the compaction hand-off before releasing i.mu. A completion
7673- // update arriving in the small gap before runCompact acquires the
7674- // mutex must join the host queue rather than starting a competing turn.
7675- i.busy = true
7676- i.compacting = true
7677- i.autoCompacting = true
7678- if continueExisting {
7679- i.continueAfterCompact = true
7680- } else {
7681- i.pendingCompactPrompt = prompt
7682- i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
7683- i.hasPendingCompactPrompt = true
7684- }
7685- i.statusErr = ""
7686- i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
7687- i.pendingPostCompactNote = "context auto-compacted; sending your last message"
7688- i.mu.Unlock()
7689- if persistResetHandoff {
7690- i.persistCompactHandoff(resetHandoff)
7691- }
7692- i.invalidate()
7693- i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
7694- return
7695- }
7696- i.mu.Unlock()
7697- if persistResetHandoff {
7698- i.persistCompactHandoff(resetHandoff)
7699- }
7700-
7701- ctx, cancel := context.WithCancel(parent)
7702- i.mu.Lock()
7703- i.busy = true
7704- i.spin.Start()
7705- i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
7706- i.cancelTurn = cancel
7707- i.statusErr = ""
7708- i.statusOK = ""
7709- i.streaming.Reset()
7710- i.streamOn = true
7711- i.pendingAlert = nil
7712- i.toolCalls = map[string]*tui.ToolCallView{}
7713- i.toolOrder = nil
7714- i.toolGate = map[string]int{}
7715- i.extNotes = nil // ext notes are one-shot; a new prompt clears them
7716- i.scrollOffset = 0 // jump back to the bottom on new turn
7717- // Lift the resume tail cap once the user starts interacting. The
7718- // cap is purely a first-paint optimization (don't markdown the
7719- // whole history before showing anything). Keeping it active during
7720- // a turn makes the rendered chat a sliding window: appended
7721- // messages push older ones off the TOP of the buffer, which the
7722- // renderer must treat as a change above the viewport and repaint
7723- // fully, snapping the terminal's native scrollback to the bottom on
7724- // every streamed chunk. A fresh session has no cap (append-only),
7725- // which is why the jump only shows up in resumed sessions. Dropping
7726- // the cap here makes resumed turns append-only too.
7727- i.view.TailLimit = 0
7728- // Reset the auto-follow baseline so the very next render at
7729- // interactive.go:1053 doesn't see a synthetic shrink between
7730- // "last frame had the previous turn's tool overlay" and
7731- // "this frame had it cleared above". Without this, the guard
7732- // reads delta = -(rows in cleared overlay) and decrements
7733- // scrollOffset, which on terminals that mirror zut's pane
7734- // scroll into the host scrollbar visibly yanks the viewport.
7735- // See autofollow_shrink_test.go for the exact arithmetic.
7736- i.prevChatLen = 0
7737- i.prevChatCols = 0
7738- i.parkedTurn = 0 // starting a turn clears the /jump parked state
7739- i.parkedTotal = 0
7740- i.helpBlock = nil // hide the help block once the user asks something
7741- i.mu.Unlock()
7742- i.invalidate()
7743-
7744- var (
7745- lastStop provider.StopReason
7746- lastTurnErr error
7747- )
7748- sink := func(ev core.AgentEvent) {
7749- if e, ok := ev.(core.EvTurnEnd); ok {
7750- lastStop = e.Stop
7751- lastTurnErr = e.Err
7752- }
7753- i.handleEventForPresentation(ev)
7754- }
7755-
7756- releaseCompletionHold := i.beginCompletionDeliveryHold()
7757- go func() {
7758- defer releaseCompletionHold()
7759- var err error
7760- if continueExisting {
7761- err = i.agent.Continue(ctx, sink)
7762- } else {
7763- err = i.agent.Prompt(ctx, prompt, images, sink)
7764- }
7765- i.mu.Lock()
7766- // Keep busy asserted through final cleanup and queue selection. A
7767- // watcher may enqueue a completion summary concurrently with the
7768- // provider's final event; publishing idle before inspecting queues can
7769- // strand that summary in the core agent queue or start an overlapping
7770- // turn.
7771- // Don't touch streamPending / streamFlushPending here — the
7772- // pacer may still be draining the final deltas and needs to
7773- // paint them even though Prompt has returned. It will reset
7774- // streamOn on its own once the buffer empties.
7775- if len(i.streamPending) == 0 {
7776- i.streamOn = false
7777- }
7778- i.cancelTurn = nil
7779- pendingIdleWork := i.takePendingIdleWorkLocked()
7780- if err != nil && ctx.Err() == nil {
7781- i.statusErr = err.Error()
7782- }
7783- // Decide whether to offer a model rescue picker for recoverable
7784- // provider failures (auth/rate/temporary). The picker opens after
7785- // the mutex is released so it can take its own locks freely.
7786- var (
7787- offer bool
7788- rescueWhy string
7789- rescueImgs []provider.ImageBlock
7790- rescueModel string
7791- rescueProv string
7792- rescueFprov string
7793- )
7794- if err != nil && ctx.Err() == nil {
7795- if ok, reason := classifyRescueError(err); ok {
7796- offer = true
7797- rescueWhy = reason
7798- rescueImgs = images
7799- rescueModel = i.cfg.Model
7800- rescueProv = i.cfg.Provider
7801- rescueFprov = extractFailedProvider(err)
7802- if rescueFprov == "" {
7803- rescueFprov = i.cfg.Provider
7804- }
7805- // Suppress the red banner — the rescue dialog already
7806- // surfaces the failure.
7807- i.statusErr = ""
7808- }
7809- }
7810- // Detect responses that reject the current context, either as an
7811- // HTTP 413 payload limit or a model context-window error. Token-
7812- // based auto-compact can miss both when metadata is stale or the
7813- // limit is measured in raw bytes. Compact once, then continue the
7814- // user message already present in the transcript.
7815- contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
7816- recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
7817- if recoverContextOverflow {
7818- i.statusErr = ""
7819- i.continueAfterCompact = true
7820- i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
7821- i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
7822- }
7823- // Persist the assistant's reply (and every tool row before
7824- // it) to the session file while the turn memory is hot.
7825- // Without this, WriteNewTranscript only fires at zut exit,
7826- // meaning a crash or ungraceful kill drops the whole
7827- // conversation. FlushSession is idempotent (it advances the
7828- // baseline so subsequent flushes only write new rows).
7829- flush := i.cfg.FlushSession
7830- i.mu.Unlock()
7831- runPendingIdleWork(pendingIdleWork)
7832- if flush != nil {
7833- flush()
7834- }
7835- terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
7836- if terminalGoalError {
7837- i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
7838- }
7839- i.mu.Lock()
7840- awaitingPre := i.awaitingStartupPre
7841- // A newer explicit prompt may have cleared the handoff while the
7842- // completed turn was being flushed. Re-read it under the mutex before
7843- // deciding whether this continuation may spend another rescue attempt.
7844- statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7845- // Pop the next queued message, if any, and relaunch.
7846- var next string
7847- var hasNext bool
7848- if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
7849- next, i.queued = i.queued[0], i.queued[1:]
7850- hasNext = true
7851- }
7852- // If the turn was cancelled or errored, drop the queue so the
7853- // user isn't bombarded with stale messages after an interrupt.
7854- if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
7855- i.queued = nil
7856- if i.agent != nil {
7857- i.agent.DrainQueuedMessages()
7858- }
7859- }
7860- // Decide whether the next thing to do is an auto-compaction.
7861- // Only fires when the turn completed cleanly AND no host-side
7862- // or agent-side queued messages are waiting (otherwise a queued
7863- // message would race the condense).
7864- agentQueued := 0
7865- if i.agent != nil {
7866- agentQueued = i.agent.QueuedMessageCount()
7867- }
7868- continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7869- shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7870- continueStatusRescue := false
7871- var handoff json.RawMessage
7872- var persistHandoff bool
7873- if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7874- followUpMessages := i.agent.Messages()
7875- reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7876- if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7877- handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7878- reason: compactContinuationStatusRescue,
7879- rescueAttempts: i.compactContinuation.rescueAttempts + 1,
7880- })
7881- continueStatusRescue = true
7882- } else {
7883- handoff, persistHandoff = i.resetCompactContinuationLocked()
7884- }
7885- }
7886- if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7887- handoff, persistHandoff = i.resetCompactContinuationLocked()
7888- }
7889- // The agent run can finish before the paced final text reaches the
7890- // transcript. A compaction replaces that transcript, so it must never
7891- // race the still-live stream frame; otherwise stale deltas can repaint
7892- // after the replacement and corrupt the scrollback renderer's model.
7893- if recoverContextOverflow || shouldAutoCompact {
7894- i.resetStreamingStateLocked()
7895- }
7896- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7897- i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7898- i.mu.Unlock()
7899- if persistHandoff {
7900- i.persistCompactHandoff(handoff)
7901- }
7902- if alertReason != "" {
7903- i.scheduleMainAlert(alertReason)
7904- }
7905- i.invalidate()
7906- parent := i.runCtx
7907- if parent == nil {
7908- parent = context.Background()
7909- }
7910- if awaitingPre {
7911- i.completeStartupPre()
7912- return
7913- }
7914- switch {
7915- case hasNext:
7916- i.startTurn(parent, next)
7917- case continueQueued:
7918- i.startTurnRequest(parent, "", nil, true, false)
7919- case continueStatusRescue:
7920- i.startAutoCompactContinuation(parent)
7921- case offer:
7922- i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7923- case recoverContextOverflow:
7924- i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
7925- case shouldAutoCompact:
7926- i.runCompact(parent, compactContinuationRequest{
7927- origin: compactOriginAfterTurnThreshold,
7928- force: lastStop == provider.StopLength,
7929- lastStop: lastStop,
7930- turnError: lastTurnErr,
7931- })
7932- }
7933- }()
7934-}
7935-
7936-func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
7937- if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
7938- return ""
7939- }
7940- if rescue {
7941- return "rescue_required"
7942- }
7943- if stop == provider.StopAborted {
7944- return ""
7945- }
7946- if err != nil || turnErr != nil || stop == provider.StopError {
7947- return "agent_error"
7948- }
7949- if stop == provider.StopLength {
7950- return "response_truncated"
7951- }
7952- return "agent_done"
7953-}
79541739
79551740 // openRescueDialog surfaces the rescue model picker after a recoverable
79561741 // provider failure. The pending prompt + images are stashed on the
@@ -7958,546 +1743,61 @@ func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.Stop
79581743 // against the freshly-picked model. activeProvider/failedProvider are
79591744 // usually the same, but some clients embed different prefixes in their
79601745 // errors than the configured provider id, so we accept both.
7961-func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
7962- if i.rescueDialog == nil {
7963- return
7964- }
7965- loggedIn := []string{}
7966- if i.cfg.LoggedInProviders != nil {
7967- loggedIn = i.cfg.LoggedInProviders()
7968- }
7969- fprov := failedProvider
7970- if fprov == "" {
7971- fprov = activeProvider
7972- }
7973- i.mu.Lock()
7974- i.pendingRescuePrompt = prompt
7975- i.pendingRescueImages = images
7976- i.mu.Unlock()
7977- i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
7978- i.invalidate()
7979-}
79801746
79811747 // applyRescueSelection switches model (cross-provider if needed) and
79821748 // re-runs the same prompt+images that just failed. Mirrors
79831749 // applyModelSelection's transcript-carry logic so the user keeps full
79841750 // session continuity across the swap.
7985-func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
7986- if model == "" {
7987- return
7988- }
7989- i.applyRescueModelSelection(prov, model)
7990- i.mu.Lock()
7991- images := i.pendingRescueImages
7992- if prompt == "" {
7993- prompt = i.pendingRescuePrompt
7994- }
7995- i.pendingRescuePrompt = ""
7996- i.pendingRescueImages = nil
7997- i.mu.Unlock()
7998- parent := i.runCtx
7999- if parent == nil {
8000- parent = context.Background()
8001- }
8002- i.startTurnWithImages(parent, prompt, images)
8003-}
8004-
8005-func stripAutoCompactNotes(notes []string) []string {
8006- if len(notes) == 0 {
8007- return notes
8008- }
8009- out := notes[:0]
8010- for _, n := range notes {
8011- if strings.Contains(n, "condensing history") {
8012- continue
8013- }
8014- out = append(out, n)
8015- }
8016- return out
8017-}
80181751
80191752 // autoCompactNoteLine returns a styled chat-area note for the
80201753 // inline auto-compact heads-up. Lives in extNotes so it survives
80211754 // the busy-spinner overwrite of the status row.
8022-func autoCompactNoteLine(th tui.Theme, msg string) string {
8023- return " " + th.FGColor(th.Warning, "⚠ "+msg)
8024-}
80251755
80261756 // NormalizeAutoCompactThreshold applies the persisted auto-compaction
80271757 // setting's supported values and default. It is exported so non-interactive
80281758 // hosts can share the interactive mode's exact configuration semantics.
8029-func NormalizeAutoCompactThreshold(threshold *int) int {
8030- const defaultAutoCompactThreshold = 85
8031- if threshold == nil {
8032- return defaultAutoCompactThreshold
8033- }
8034- switch *threshold {
8035- case 0, 70, 80, 85, 90, 95:
8036- return *threshold
8037- default:
8038- return defaultAutoCompactThreshold
8039- }
8040-}
80411759
80421760 // ShouldAutoCompact reports whether the latest request context reached the
80431761 // configured percentage of the model's advertised context window.
8044-func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
8045- if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
8046- return false
8047- }
8048- return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
8049-}
80501762
80511763 // classifyCompactionContinuation chooses a private handoff reason from the
80521764 // successful turn tail. Structural unfinished-tail handling is independent of
80531765 // the status matcher. A status rescue is admitted only for an after-turn
80541766 // threshold compaction or an already-active status handoff, and only after a
80551767 // normal successful end.
8056-func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
8057- if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
8058- return compactContinuationStructuralTail
8059- }
8060- if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
8061- return compactContinuationNone
8062- }
8063- if likelyForwardWorkStatus(msgs) {
8064- return compactContinuationStatusRescue
8065- }
8066- return compactContinuationNone
8067-}
80681768
8069-// structuralCompactionContinuation preserves the pre-existing unfinished-tail
8070-// behavior without consulting the status matcher.
8071-func structuralCompactionContinuation(msgs []provider.Message) bool {
8072- if len(msgs) == 0 {
8073- return false
8074- }
8075- last := msgs[len(msgs)-1]
8076- if last.Role != provider.RoleAssistant {
8077- return true
8078- }
8079- hasText := false
8080- for _, content := range last.Content {
8081- switch block := content.(type) {
8082- case provider.ToolCallBlock:
8083- return true
8084- case provider.TextBlock:
8085- if strings.TrimSpace(block.Text) != "" {
8086- hasText = true
8087- }
8088- case provider.ReasoningBlock:
8089- // Reasoning without a visible answer is not terminal.
8090- default:
8091- return true
8092- }
8093- }
8094- return !hasText
8095-}
1769+// structuralCompactionContinuation preserves the pre-existing unfinished-tail
1770+// behavior without consulting the status matcher.
80961771
80971772 // likelyForwardWorkStatus is deliberately narrow. It recognizes only clear
80981773 // first-person future-work commitments and never treats generic plan prose as
80991774 // a host control signal.
8100-func likelyForwardWorkStatus(msgs []provider.Message) bool {
8101- if len(msgs) == 0 {
8102- return false
8103- }
8104- last := msgs[len(msgs)-1]
8105- if last.Role != provider.RoleAssistant {
8106- return false
8107- }
8108- var text strings.Builder
8109- for _, content := range last.Content {
8110- block, ok := content.(provider.TextBlock)
8111- if !ok {
8112- if _, hasTool := content.(provider.ToolCallBlock); hasTool {
8113- return false
8114- }
8115- continue
8116- }
8117- if strings.TrimSpace(block.Text) == "" {
8118- continue
8119- }
8120- if text.Len() > 0 {
8121- text.WriteByte(' ')
8122- }
8123- text.WriteString(block.Text)
8124- }
8125- if text.Len() == 0 {
8126- return false
8127- }
8128- normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
8129- normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
8130- for _, phrase := range []string{
8131- "next i will",
8132- "next, i will",
8133- "i'll now",
8134- "i will now",
8135- "i am going to",
8136- "i need to continue",
8137- "then i will",
8138- "then, i will",
8139- "i will proceed",
8140- } {
8141- if containsFutureWorkPhrase(normalized, phrase) {
8142- return true
8143- }
8144- }
8145- return false
8146-}
8147-
8148-func containsFutureWorkPhrase(text, phrase string) bool {
8149- for offset := 0; offset < len(text); {
8150- index := strings.Index(text[offset:], phrase)
8151- if index < 0 {
8152- return false
8153- }
8154- index += offset
8155- end := index + len(phrase)
8156- if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
8157- return true
8158- }
8159- offset = end
8160- }
8161- return false
8162-}
8163-
8164-func wordRuneBefore(text string, index int) bool {
8165- if index == 0 {
8166- return false
8167- }
8168- rune_, _ := utf8.DecodeLastRuneInString(text[:index])
8169- return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
8170-}
8171-
8172-func wordRuneAfter(text string, index int) bool {
8173- if index == len(text) {
8174- return false
8175- }
8176- rune_, _ := utf8.DecodeRuneInString(text[index:])
8177- return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
8178-}
81791775
81801776 // shouldAutoCompactLocked reports whether the last turn pushed context
81811777 // usage past the auto-compact threshold. Must be called with i.mu
81821778 // held; it reads lastCtxInput and the current model's context window.
8183-func (i *Interactive) shouldAutoCompactLocked() bool {
8184- if i.agent == nil {
8185- return false
8186- }
8187- if i.autoCompacting {
8188- return false
8189- }
8190- m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
8191- if err != nil || m.ContextWindow <= 0 {
8192- return false
8193- }
8194- threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
8195- return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
8196-}
8197-
8198-func eventAffectsPresentation(ev core.AgentEvent) bool {
8199- switch ev.(type) {
8200- case core.EvToolProgress:
8201- return false
8202- default:
8203- return true
8204- }
8205-}
8206-
8207-func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
8208- i.toolRenderRevision++
8209- if i.toolRenderRevision == 0 {
8210- // Overflow is practically unreachable, but resetting the sequence must
8211- // also drop old revisions before they can alias new frames.
8212- if i.view != nil {
8213- i.view.InvalidateRenderCache()
8214- }
8215- i.toolRenderRevision = 1
8216- }
8217- tc.Revision = i.toolRenderRevision
8218-}
8219-
8220-func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
8221- i.handleEvent(ev)
8222- // Progress payloads are still delivered to the core sink and applied
8223- // in order, but they have no interactive-visible representation.
8224- if eventAffectsPresentation(ev) {
8225- i.invalidate()
8226- }
8227-}
8228-
8229-func (i *Interactive) handleEvent(ev core.AgentEvent) {
8230- i.mu.Lock()
8231- defer i.mu.Unlock()
8232- i.activity.apply(ev)
8233- switch e := ev.(type) {
8234- case core.EvAssistantStart:
8235- // Fires at the top of every oneTurn, including follow-up
8236- // turns after tool use. Without this, the streaming buffer
8237- // is still marked off from the previous assistant message
8238- // and the final summary text pops in all at once instead
8239- // of typewriter-streaming delta by delta.
8240- i.streaming.Reset()
8241- i.streamPending = i.streamPending[:0]
8242- i.streamFlushPending = false
8243- i.streamOn = true
8244- // Clear the live tool-call overlay. Any tools from the
8245- // previous round are now fully folded into the transcript
8246- // (assistant tool_use block + tool role message with the
8247- // result), so keeping them in the overlay would duplicate
8248- // them in the view — once inside the finalised transcript
8249- // and once below the streaming block, with the streaming
8250- // summary sandwiched in between. The next EvToolUseStart
8251- // will populate fresh entries for this turn's tools.
8252- i.toolCalls = map[string]*tui.ToolCallView{}
8253- i.toolOrder = nil
8254- i.toolGate = map[string]int{}
8255- case core.EvTextDelta:
8256- // Buffer into streamPending; the paintPace ticker drains
8257- // it into i.streaming a few runes at a time for a smooth
8258- // typewriter effect independent of upstream chunk size.
8259- i.streamPending = append(i.streamPending, []rune(e.Delta)...)
8260- i.streamOn = true
8261- case core.EvAssistantMessage:
8262- // OnAssistant + telegram mirroring always fire on message
8263- // arrival — they read the FINAL message content, which is
8264- // complete regardless of what's still in the pacer.
8265- i.assistantMessageSideEffects(e.Message)
8266- // If the pacer still has characters to drain, keep streamOn
8267- // true and mark flush pending; the paintPace ticker will
8268- // drain the remainder and reset streaming state when done.
8269- // Otherwise (rare: full-replay sessions, abort paths) clear
8270- // synchronously so a later render doesn't show stale text.
8271- if len(i.streamPending) > 0 {
8272- i.streamFlushPending = true
8273- return
8274- }
8275- i.resetStreamingStateLocked()
8276- case core.EvToolUseStart:
8277- // Live streaming: pre-create the view so the user sees the
8278- // tool call being composed in real time. Any subsequent
8279- // EvToolCall for the same ID updates the same struct (the
8280- // final parsed args + name are already known here).
8281- if _, exists := i.toolCalls[e.ID]; !exists {
8282- tc := &tui.ToolCallView{
8283- ID: e.ID,
8284- Name: e.Name,
8285- Streaming: true,
8286- }
8287- i.bumpToolRevisionLocked(tc)
8288- i.toolCalls[e.ID] = tc
8289- i.toolOrder = append(i.toolOrder, e.ID)
8290- i.gateToolLocked(e.ID)
8291- }
8292- case core.EvToolUseArgs:
8293- if tc, ok := i.toolCalls[e.ID]; ok {
8294- tc.RawJSONBuf += e.Delta
8295- i.bumpToolRevisionLocked(tc)
8296- // Refresh the live path as soon as it parses; used in
8297- // the header (write /Users/example/Desktop/demo.ts)
8298- // while the content is still streaming.
8299- if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
8300- tc.LivePath = p
8301- } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
8302- tc.LivePath = p
8303- }
8304- }
8305- case core.EvToolUseEnd:
8306- if tc, ok := i.toolCalls[e.ID]; ok {
8307- tc.Streaming = false
8308- i.bumpToolRevisionLocked(tc)
8309- }
8310- case core.EvToolCall:
8311- // If we already pre-created the view during streaming, just
8312- // refresh the final Args summary. Otherwise create a new one
8313- // (non-streaming providers or legacy paths).
8314- if tc, ok := i.toolCalls[e.ID]; ok {
8315- tc.Args = tui.ShortArgs(e.Name, e.Args)
8316- i.bumpToolRevisionLocked(tc)
8317- if tc.RawJSONBuf == "" {
8318- tc.RawJSONBuf = string(e.Args)
8319- }
8320- tc.Streaming = false
8321- } else {
8322- tc := &tui.ToolCallView{
8323- ID: e.ID,
8324- Name: e.Name,
8325- Args: tui.ShortArgs(e.Name, e.Args),
8326- RawJSONBuf: string(e.Args),
8327- }
8328- i.bumpToolRevisionLocked(tc)
8329- i.toolCalls[e.ID] = tc
8330- i.toolOrder = append(i.toolOrder, e.ID)
8331- i.gateToolLocked(e.ID)
8332- }
8333- case core.EvToolResult:
8334- if tc, ok := i.toolCalls[e.ID]; ok {
8335- tc.Done = true
8336- tc.Error = e.Result.IsError
8337- tc.Preview = ""
8338- var text strings.Builder
8339- for _, c := range e.Result.Content {
8340- if tb, ok := c.(provider.TextBlock); ok {
8341- if text.Len() > 0 {
8342- text.WriteString("\n")
8343- }
8344- text.WriteString(tb.Text)
8345- }
8346- }
8347- tc.Result = text.String()
8348- i.bumpToolRevisionLocked(tc)
8349- }
8350- if update, ok := tools.GoalUpdateFromResult(e.Result); ok {
8351- if update.Status == core.GoalActive {
8352- // A manager may advance a terminal goal to the next persisted
8353- // goal in the same mission. Do not let a late tool result resume
8354- // a goal explicitly paused by the user.
8355- if i.goalStatus != core.GoalPaused {
8356- i.goalStatus = core.GoalActive
8357- }
8358- } else if i.goalStatus == core.GoalActive {
8359- i.goalStatus = update.Status
8360- }
8361- }
8362- case core.EvUsage:
8363- i.cumUsage = e.Cumulative
8364- if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
8365- i.lastCtxInput = contextUsed
8366- }
8367- case core.EvTurnEnd:
8368- if e.Stop == provider.StopAborted {
8369- i.resetStreamingStateLocked()
8370- i.statusErr = ""
8371- i.statusOK = "cancelled"
8372- return
8373- }
8374- if e.Stop == provider.StopLength {
8375- // The model hit its output-token cap mid-response, so the
8376- // reply (often a long write/edit) is truncated. Surface it
8377- // explicitly, otherwise the turn just ends and reads like
8378- // the UI gave up. The agent already requests the model's
8379- // full MaxOutput budget, so this means the response genuinely
8380- // exceeded that ceiling; ask the user to continue.
8381- i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
8382- i.statusOK = ""
8383- return
8384- }
8385- // Don't surface mid-loop stream errors as a red banner here.
8386- // EvTurnEnd fires after every step in a multi-step tool loop,
8387- // so a transient 503 / network blip would briefly paint a red
8388- // banner over the still-streaming chat before the agent loop
8389- // either retries or exits. The final error (if any) is set by
8390- // startTurnWithImages once Prompt() returns, and recoverable
8391- // failures are routed to the rescue picker instead — which
8392- // keeps the chat clean while the agent is working.
8393- _ = e.Err
8394- }
8395-}
83961779
83971780 // WebSearchPolicyGeneration returns the generation a resolver must present at
83981781 // its final interactive commit. Policy transitions advance it before changing
83991782 // the gate or generic-child ceiling.
8400-func (i *Interactive) WebSearchPolicyGeneration() uint64 {
8401- return i.webSearchPolicyGeneration.Load()
8402-}
84031783
84041784 // ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
84051785 // ag is still the live interactive agent. Callers without a long-running
84061786 // resolve use the current policy generation.
8407-func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
8408- return i.applyAgentPromptConfig(ag, system, tools, 0, false)
8409-}
84101787
84111788 // ApplyAgentPromptConfigAtWebSearchGeneration additionally rejects a registry
84121789 // resolved before a web-search policy transition. This final check is made in
84131790 // the same critical section as live-agent replacement and the core prompt swap.
8414-func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
8415- return i.applyAgentPromptConfig(ag, system, tools, generation, true)
8416-}
8417-
8418-func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
8419- if ag == nil {
8420- return nil, false
8421- }
8422- // Match the lock order used by dynamic tool mutations so a refresh
8423- // cannot race a snapshot-and-replace operation that would reintroduce
8424- // the old registry after the atomic prompt swap.
8425- i.agentMu.Lock()
8426- defer i.agentMu.Unlock()
8427- i.mu.Lock()
8428- defer i.mu.Unlock()
8429- if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
8430- return nil, false
8431- }
8432- if i.telegramBridge != nil {
8433- // Telegram prompts arrive over an external messaging channel without a
8434- // per-request confirmation surface. Keep the normal interactive
8435- // registry from reintroducing web_search from the moment the bridge is
8436- // attached, including while its startup handshake is in flight.
8437- delete(tools, "web_search")
8438- }
8439- oldTools := ag.SetPromptConfig(system, tools)
8440- _, webSearchAvailable := tools["web_search"]
8441- i.setWebSearchAvailable(webSearchAvailable)
8442- return oldTools, true
8443-}
84441791
84451792 // prepareReplacementAgentLocked applies session-wide capability policy before
84461793 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
8447-func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
8448- if ag == nil {
8449- i.setWebSearchAvailable(false)
8450- return
8451- }
8452- registry := ag.ToolsSnapshot()
8453- if i.telegramBridge != nil {
8454- // External Telegram prompts may arrive as soon as the replacement is
8455- // published, so post-swap cleanup is too late.
8456- delete(registry, "web_search")
8457- ag.SetTools(registry)
8458- }
8459- _, webSearchAvailable := registry["web_search"]
8460- i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
8461-}
84621794
84631795 // DeferUntilIdle runs work immediately when the interactive agent is idle,
84641796 // or after the current turn/compaction/shell operation releases the busy
84651797 // state. Callbacks run outside i.mu.
8466-func (i *Interactive) DeferUntilIdle(fn func()) {
8467- if fn == nil {
8468- return
8469- }
8470- i.mu.Lock()
8471- if i.busy {
8472- i.pendingIdleWork = append(i.pendingIdleWork, fn)
8473- i.mu.Unlock()
8474- return
8475- }
8476- i.mu.Unlock()
8477- fn()
8478-}
8479-
8480-func (i *Interactive) takePendingIdleWorkLocked() []func() {
8481- work := i.pendingIdleWork
8482- i.pendingIdleWork = nil
8483- return work
8484-}
8485-
8486-func runPendingIdleWork(work []func()) {
8487- for _, fn := range work {
8488- if fn != nil {
8489- fn()
8490- }
8491- }
8492-}
84931798
84941799 // Agent returns the current agent, if any. Used by cli.go to flush the
84951800 // final transcript to the session file.
8496-func (i *Interactive) Agent() *core.Agent {
8497- i.mu.Lock()
8498- defer i.mu.Unlock()
8499- return i.agent
8500-}
85011801
85021802 // silence unused import in some build configs
85031803 var _ = fmt.Sprintf
@@ -8506,28 +1806,6 @@ var _ = fmt.Sprintf
85061806 // + explicit). Runs on a goroutine so the TUI stays responsive; the
85071807 // Manager.Reload takes a couple of hundred ms to shut down subprocs
85081808 // and respawn them. Shows a temporary status line throughout.
8509-func (i *Interactive) runReloadExt(ctx context.Context) {
8510- if i.cfg.Extensions == nil {
8511- i.mu.Lock()
8512- i.statusErr = "no extension manager in this build"
8513- i.mu.Unlock()
8514- i.invalidate()
8515- return
8516- }
8517- i.mu.Lock()
8518- i.statusOK = "Reloading extensions..."
8519- i.statusErr = ""
8520- i.mu.Unlock()
8521- i.invalidate()
8522-
8523- go func() {
8524- stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
8525- msg, failed := formatReloadStatus(stats)
8526- seq := i.setReloadStatus(msg, failed)
8527- i.invalidate()
8528- go i.dismissReloadStatus(ctx, seq, msg, failed)
8529- }()
8530-}
85311809
85321810 // Confirm implements core.Confirmer. The agent goroutine calls
85331811 // this synchronously before every tool invocation when --no-yolo is
@@ -8537,239 +1815,29 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
85371815 // If the session is cancelled or the TUI exits mid-prompt, any
85381816 // pending request is refused via CancelAll so the agent doesn't
85391817 // deadlock.
8540-func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
8541- return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
8542-}
85431818
85441819 // ConfirmToolCall attaches a side-effect-free preview to the matching live
85451820 // tool panel, then blocks until the user approves or refuses the call.
8546-func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
8547- resp := make(chan core.ConfirmDecision, 1)
8548- if isBtwOrigin(call.Origin) {
8549- req := &confirmRequest{
8550- toolName: call.Name,
8551- preview: call.Summary,
8552- resp: resp,
8553- returnToChild: true,
8554- }
8555- if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
8556- i.confirmDialog.Enqueue(req)
8557- }) {
8558- return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
8559- }
8560- i.invalidate()
8561- return <-resp
8562- }
8563- if call.ID != "" {
8564- i.mu.Lock()
8565- if tc, ok := i.toolCalls[call.ID]; ok {
8566- tc.Args = call.Summary
8567- tc.Preview = call.Content
8568- i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
8569- }
8570- i.mu.Unlock()
8571- }
8572- i.confirmDialog.Enqueue(&confirmRequest{
8573- toolName: call.Name,
8574- preview: call.Summary,
8575- resp: resp,
8576- })
8577- i.invalidate()
8578- return <-resp
8579-}
85801821
85811822 // openTelegramDialog shows the picker for `/telegram` with no arg.
85821823 // Items depend on current state: disconnect + status when running,
85831824 // connect + status when stopped.
8584-func (i *Interactive) openTelegramDialog() {
8585- items := i.telegramMenuItems()
8586- if len(items) == 0 {
8587- i.mu.Lock()
8588- i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
8589- i.mu.Unlock()
8590- i.invalidate()
8591- return
8592- }
8593- i.telegramDialog.Open(items)
8594- i.invalidate()
8595-}
85961825
85971826 // telegramMenuItems builds the dialog entries for the current
85981827 // bridge state. Returns empty when no bot.json exists so the
85991828 // caller can show a helpful status line instead of an empty menu.
8600-func (i *Interactive) telegramMenuItems() []telegramItem {
8601- cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
8602- if err != nil || cfg.BotToken == "" {
8603- return nil
8604- }
8605- var items []telegramItem
8606- if i.telegramBridge != nil && i.telegramBridge.Active() {
8607- items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
8608- st := i.telegramBridge.State()
8609- hint := "active"
8610- if st.Username != "" {
8611- hint += " as @" + st.Username
8612- }
8613- items = append(items, telegramItem{label: "status", action: "status", hint: hint})
8614- } else {
8615- label := "connect"
8616- hint := "start mirroring dms into this session"
8617- if cfg.AllowedUserID == 0 {
8618- hint = "awaiting pairing (send /start to the bot once connected)"
8619- }
8620- items = append(items, telegramItem{label: label, action: "connect", hint: hint})
8621- items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
8622- }
8623- return items
8624-}
86251829
86261830 // doTelegram dispatches one of the three explicit actions. Called
86271831 // from /telegram <action> or after the picker selects a row.
8628-func (i *Interactive) doTelegram(action string) {
8629- switch action {
8630- case "connect":
8631- i.telegramConnect()
8632- case "disconnect":
8633- i.telegramDisconnect()
8634- case "status":
8635- i.telegramStatus()
8636- default:
8637- i.mu.Lock()
8638- i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
8639- i.mu.Unlock()
8640- i.invalidate()
8641- }
8642-}
86431832
86441833 // telegramConnect starts the bridge. Refuses if it's already
86451834 // running or if the on-disk bot.json is missing a token.
8646-func (i *Interactive) telegramConnect() {
8647- if i.telegramBridge != nil && i.telegramBridge.Active() {
8648- i.mu.Lock()
8649- i.statusOK = "telegram already connected"
8650- i.statusErr = ""
8651- i.mu.Unlock()
8652- i.invalidate()
8653- return
8654- }
8655- cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
8656- if err != nil {
8657- i.mu.Lock()
8658- i.statusErr = "telegram: " + err.Error()
8659- i.mu.Unlock()
8660- i.invalidate()
8661- return
8662- }
8663- if cfg.BotToken == "" {
8664- i.mu.Lock()
8665- i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
8666- i.mu.Unlock()
8667- i.invalidate()
8668- return
8669- }
8670- // Refuse to start when a background daemon is already polling
8671- // the same bot. Two concurrent long-poll consumers race each
8672- // update and one always loses, so DMs get half-delivered. The
8673- // user can `zut telegram-bot stop` first, then /telegram connect.
8674- if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
8675- i.mu.Lock()
8676- i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
8677- i.mu.Unlock()
8678- i.invalidate()
8679- return
8680- }
8681- bridge := &telegram.Bridge{
8682- Client: telegram.NewClient(cfg.BotToken),
8683- Config: cfg,
8684- Save: func(next telegram.Config) error {
8685- return telegram.SaveConfig(i.cfg.ZutHome, next)
8686- },
8687- Host: &telegramHost{iv: i},
8688- }
8689- i.mu.Lock()
8690- i.telegramBridge = bridge
8691- i.mu.Unlock()
8692- // Strip web_search before the bridge's startup handshake can accept a
8693- // Telegram update. ApplyAgentPromptConfig also keeps it stripped from
8694- // concurrent refreshes while this bridge pointer is attached.
8695- i.applyTelegramTools(true)
8696- if err := bridge.Start(i.runCtx); err != nil {
8697- i.mu.Lock()
8698- i.telegramBridge = nil
8699- i.mu.Unlock()
8700- refreshErr := i.refreshToolsAfterTelegram()
8701- i.mu.Lock()
8702- i.statusErr = "telegram connect failed: " + err.Error()
8703- if refreshErr != nil {
8704- i.statusErr += "; tool refresh: " + refreshErr.Error()
8705- }
8706- i.mu.Unlock()
8707- i.invalidate()
8708- return
8709- }
8710- state := bridge.State()
8711- label := "telegram connected"
8712- if state.Username != "" {
8713- label += " as @" + state.Username
8714- }
8715- if state.PairedID == 0 {
8716- label += " — send /start to the bot from your phone to claim it"
8717- }
8718- i.mu.Lock()
8719- i.statusOK = label
8720- i.statusErr = ""
8721- i.mu.Unlock()
8722- i.invalidate()
8723-}
87241835
87251836 // telegramDisconnect stops the bridge. No-op when already stopped.
8726-func (i *Interactive) telegramDisconnect() {
8727- if i.telegramBridge == nil || !i.telegramBridge.Active() {
8728- i.mu.Lock()
8729- i.statusOK = "telegram already disconnected"
8730- i.statusErr = ""
8731- i.mu.Unlock()
8732- i.invalidate()
8733- return
8734- }
8735- bridge := i.telegramBridge
8736- bridge.Stop()
8737- i.mu.Lock()
8738- // Clear the pointer before rebuilding the normal registry. This both
8739- // allows web_search back into the refresh and marks the bridge inactive
8740- // for any concurrent prompt-config commit.
8741- i.telegramBridge = nil
8742- i.mu.Unlock()
8743- refreshErr := i.refreshToolsAfterTelegram()
8744- i.mu.Lock()
8745- if refreshErr != nil {
8746- i.statusOK = ""
8747- i.statusErr = "telegram disconnect tool refresh: " + refreshErr.Error()
8748- } else {
8749- i.statusOK = "telegram disconnected"
8750- i.statusErr = ""
8751- }
8752- i.mu.Unlock()
8753- i.invalidate()
8754-}
87551837
87561838 // refreshToolsAfterTelegram restores normal tools only through a successful
87571839 // resolver refresh. Failure removes Telegram-only tools but leaves web_search
87581840 // revoked in the live registry, stale snapshots, and generic-child policy.
8759-func (i *Interactive) refreshToolsAfterTelegram() error {
8760- if i.cfg.RefreshTools == nil {
8761- i.stripWebSearchTool()
8762- i.applyTelegramTools(false)
8763- return errors.New("live tool refresh is unavailable")
8764- }
8765- if err := i.cfg.RefreshTools(); err != nil {
8766- i.stripWebSearchTool()
8767- i.applyTelegramTools(false)
8768- return err
8769- }
8770- i.applyTelegramTools(false)
8771- return nil
8772-}
87731841
87741842 // telegramSenderAdapter wraps the bridge so the tools package can
87751843 // drive it without importing telegram directly. The Active() check
@@ -8779,546 +1847,44 @@ type telegramSenderAdapter struct {
87791847 bridge *telegram.Bridge
87801848 }
87811849
8782-func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
8783- if a.bridge == nil {
8784- return fmt.Errorf("telegram bridge is not connected")
8785- }
8786- return a.bridge.SendImage(ctx, path, caption)
8787-}
8788-
8789-func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
8790- if a.bridge == nil {
8791- return fmt.Errorf("telegram bridge is not connected")
8792- }
8793- return a.bridge.SendDocument(ctx, path, caption)
8794-}
8795-
8796-func (a telegramSenderAdapter) Active() bool {
8797- return a.bridge != nil && a.bridge.Active()
8798-}
8799-
88001850 // TrackResidentSubagent reserves completion delivery after the manager has
88011851 // durably accepted a resident child prompt and before it is scheduled.
8802-func (i *Interactive) TrackResidentSubagent(childID string) {
8803- if i == nil {
8804- return
8805- }
8806- i.ensureCompletionTracker().TrackResident()
8807- i.registerCoordinatorWorker(childID)
8808- i.invalidate()
8809- i.requestCompletionDelivery()
8810-}
88111852
88121853 // ReportResidentSubagent delivers a typed resident terminal outcome through
88131854 // the same coordinator path used by legacy worker completions.
8814-func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) {
8815- if i == nil {
8816- return
8817- }
8818- status, errText := "completed", ""
8819- if completion.Err != nil {
8820- status, errText = "failed", completion.Err.Error()
8821- }
8822- i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary})
8823- i.reloadOpenResidentChildSession(completion.ChildID)
8824- i.invalidate()
8825- i.requestCompletionDelivery()
8826-}
8827-
8828-func (i *Interactive) reloadOpenResidentChildSession(childID string) {
8829- if i == nil {
8830- return
8831- }
8832- i.mu.Lock()
8833- session := i.residentChildSession
8834- i.mu.Unlock()
8835- if session == nil || session.childID != childID || !session.RequestRecentReload() {
8836- return
8837- }
8838- i.reloadResidentChildSession(session)
8839-}
8840-
8841-func (i *Interactive) reloadResidentChildSession(session *residentChildSession) {
8842- if i == nil || session == nil {
8843- return
8844- }
8845- go func() {
8846- err := session.ReloadRecent(200)
8847- if session.FinishLoad(err) {
8848- if session.RequestRecentReload() {
8849- i.reloadResidentChildSession(session)
8850- }
8851- }
8852- i.invalidate()
8853- }()
8854-}
8855-
8856-func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
8857- // Tests and lightweight embedders may construct Interactive with a struct
8858- // literal instead of NewInteractive, so retain this lazy fallback.
8859- i.completionDeliveryMu.Lock()
8860- defer i.completionDeliveryMu.Unlock()
8861- if i.completionTracker == nil {
8862- i.completionTracker = subagents.NewCompletionTracker()
8863- }
8864- return i.completionTracker
8865-}
8866-
8867-func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator {
8868- if i.turnCoordinator == nil {
8869- i.turnCoordinator = orchestration.New()
8870- }
8871- return i.turnCoordinator
8872-}
8873-
8874-func (i *Interactive) coordinatorAcceptsUserInput() bool {
8875- if i == nil {
8876- return false
8877- }
8878- i.completionDeliveryMu.Lock()
8879- accepts := i.ensureCoordinatorLocked().AcceptsUserInput()
8880- i.completionDeliveryMu.Unlock()
8881- return accepts
8882-}
8883-
8884-func (i *Interactive) coordinatorHasPendingWorkers() bool {
8885- if i == nil {
8886- return false
8887- }
8888- i.completionDeliveryMu.Lock()
8889- pending := i.ensureCoordinatorLocked().HasPendingWorkers()
8890- i.completionDeliveryMu.Unlock()
8891- return pending
8892-}
8893-
8894-func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action {
8895- if i == nil {
8896- return nil
8897- }
8898- i.completionDeliveryMu.Lock()
8899- result := i.ensureCoordinatorLocked().Apply(event)
8900- i.completionDeliveryMu.Unlock()
8901- return result.Actions
8902-}
89031855
89041856 // registerCoordinatorWorker associates a tracker registration with the open
89051857 // manager wave. Direct embedders that register a worker outside startTurn get
8906-// a one-worker sealed wave, retaining the exported tracker API's old behavior.
8907-func (i *Interactive) registerCoordinatorWorker(workerID string) {
8908- if i == nil || workerID == "" {
8909- return
8910- }
8911- i.completionDeliveryMu.Lock()
8912- coordinator := i.ensureCoordinatorLocked()
8913- implicitWave := i.completionDeliveryHolds == 0
8914- if implicitWave {
8915- coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
8916- }
8917- i.coordinatorWorkerSeq++
8918- registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq)
8919- if i.coordinatorWorkerIDs == nil {
8920- i.coordinatorWorkerIDs = make(map[string][]string)
8921- }
8922- i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID)
8923- coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID})
8924- var actions []orchestration.Action
8925- if implicitWave {
8926- actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
8927- }
8928- i.completionDeliveryMu.Unlock()
8929- i.executeCoordinatorActions(actions)
8930-}
8931-
8932-func (i *Interactive) cancelCoordinator() {
8933- if i == nil {
8934- return
8935- }
8936- i.completionDeliveryMu.Lock()
8937- coordinator := i.ensureCoordinatorLocked()
8938- actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions
8939- // A later user turn starts a fresh wave. Drop resident registrations as
8940- // well as coordinator identities so late outcomes cannot match a new child.
8941- if i.completionTracker != nil {
8942- i.completionTracker.Reset()
8943- }
8944- i.turnCoordinator = orchestration.New()
8945- i.coordinatorWorkerIDs = nil
8946- i.completionDeliveryMu.Unlock()
8947- i.executeCoordinatorActions(actions)
8948-}
8949-
8950-func (i *Interactive) takeCoordinatorWorkerID(agentID string) string {
8951- if i == nil || agentID == "" {
8952- return ""
8953- }
8954- i.completionDeliveryMu.Lock()
8955- defer i.completionDeliveryMu.Unlock()
8956- ids := i.coordinatorWorkerIDs[agentID]
8957- if len(ids) == 0 {
8958- return ""
8959- }
8960- workerID := ids[0]
8961- if len(ids) == 1 {
8962- delete(i.coordinatorWorkerIDs, agentID)
8963- } else {
8964- i.coordinatorWorkerIDs[agentID] = ids[1:]
8965- }
8966- return workerID
8967-}
8968-
8969-func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) {
8970- for _, action := range actions {
8971- if action.Kind != orchestration.ActionRunManager {
8972- continue
8973- }
8974- prompt := action.Text
8975- if len(action.Completions) != 0 {
8976- instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
8977- update := subagents.FormatCompletionUpdate(action.Completions, instruction)
8978- if prompt != "" {
8979- prompt = update + "\n\nQueued user request:\n" + prompt
8980- } else {
8981- prompt = update
8982- }
8983- }
8984- if prompt != "" {
8985- i.submitOrQueue(prompt, nil, false)
8986- } else if action.Reason == orchestration.WakeGoal {
8987- parent := i.runCtx
8988- if parent == nil {
8989- parent = context.Background()
8990- }
8991- i.requestGoalContinuationIfIdle(parent)
8992- }
8993- }
8994-}
8995-
8996-// requestCompletionDelivery starts at most one waiter for the current active
8997-// set. A request arriving while that waiter is formatting or submitting is
8998-// picked up before the waiter exits, so a later worker cannot be lost in the
8999-// handoff between batches.
9000-func (i *Interactive) requestCompletionDelivery() {
9001- if i == nil {
9002- return
9003- }
9004- i.ensureCompletionTracker()
9005- i.completionDeliveryMu.Lock()
9006- i.completionDeliveryRequest = true
9007- if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
9008- i.completionDeliveryMu.Unlock()
9009- return
9010- }
9011- i.completionDeliveryRunning = true
9012- i.completionDeliveryMu.Unlock()
9013-
9014- go i.deliverCompletionUpdates()
9015-}
9016-
9017-// beginCompletionDeliveryHold keeps all completions observed during one
9018-// parent model turn together. Tool calls are executed sequentially inside the
9019-// core agent, so the release point is the owning registration boundary for
9020-// same-parent-turn batching rather than a timing-based debounce.
9021-func (i *Interactive) beginCompletionDeliveryHold() func() {
9022- if i == nil {
9023- return func() {}
9024- }
9025- i.completionDeliveryMu.Lock()
9026- i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
9027- i.completionDeliveryHolds++
9028- i.completionDeliveryMu.Unlock()
9029- return i.releaseCompletionDeliveryHold
9030-}
9031-
9032-func (i *Interactive) releaseCompletionDeliveryHold() {
9033- if i == nil {
9034- return
9035- }
9036- start := false
9037- var actions []orchestration.Action
9038- i.completionDeliveryMu.Lock()
9039- if i.completionDeliveryHolds > 0 {
9040- i.completionDeliveryHolds--
9041- }
9042- if i.completionDeliveryHolds == 0 {
9043- // The coordinator must observe the goal before sealing the wave, so a
9044- // pending worker wins the wake decision instead of a direct continuation.
9045- _, goalActive := i.goalContinuationMessage()
9046- coordinator := i.ensureCoordinatorLocked()
9047- coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
9048- actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
9049- }
9050- if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
9051- i.completionDeliveryRunning = true
9052- start = true
9053- }
9054- i.completionDeliveryMu.Unlock()
9055- i.executeCoordinatorActions(actions)
9056- if start {
9057- go i.deliverCompletionUpdates()
9058- }
9059-}
9060-
9061-func (i *Interactive) completionWaitContext() context.Context {
9062- i.mu.Lock()
9063- ctx := i.runCtx
9064- i.mu.Unlock()
9065- if ctx == nil {
9066- return context.Background()
9067- }
9068- return ctx
9069-}
9070-
9071-func (i *Interactive) deliverCompletionUpdates() {
9072- for {
9073- i.completionDeliveryMu.Lock()
9074- i.completionDeliveryRequest = false
9075- tracker := i.completionTracker
9076- i.completionDeliveryMu.Unlock()
9077-
9078- batch, err := tracker.WaitIdle(i.completionWaitContext())
9079- if err == nil && len(batch) != 0 {
9080- var actions []orchestration.Action
9081- for _, completion := range batch {
9082- workerID := i.takeCoordinatorWorkerID(completion.AgentID)
9083- if workerID == "" {
9084- continue
9085- }
9086- actions = append(actions, i.applyCoordinator(orchestration.Event{
9087- Kind: orchestration.EventWorkerFinished,
9088- WorkerID: workerID,
9089- Completion: completion,
9090- })...)
9091- }
9092- i.executeCoordinatorActions(actions)
9093- }
1858+// a one-worker sealed wave, retaining the exported tracker API's old behavior.
90941859
9095- i.completionDeliveryMu.Lock()
9096- if i.completionDeliveryRequest {
9097- i.completionDeliveryMu.Unlock()
9098- continue
9099- }
9100- i.completionDeliveryRunning = false
9101- i.completionDeliveryMu.Unlock()
9102- return
9103- }
9104-}
1860+// requestCompletionDelivery starts at most one waiter for the current active
1861+// set. A request arriving while that waiter is formatting or submitting is
1862+// picked up before the waiter exits, so a later worker cannot be lost in the
1863+// handoff between batches.
1864+
1865+// beginCompletionDeliveryHold keeps all completions observed during one
1866+// parent model turn together. Tool calls are executed sequentially inside the
1867+// core agent, so the release point is the owning registration boundary for
1868+// same-parent-turn batching rather than a timing-based debounce.
91051869
91061870 // autoSubagentsAddenda returns the prompt blocks owned by subagent prompting,
91071871 // in the same order Resolve appends them to a new agent. The profile manifest
91081872 // is independent of strict orchestration and remains available in on-demand
91091873 // mode whenever the primary agent can spawn a worker.
9110-func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
9111- addenda := make([]string, 0, 2)
9112- if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) {
9113- if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
9114- addenda = append(addenda, addendum)
9115- }
9116- }
9117- if !orchestrating {
9118- if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
9119- if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
9120- addenda = append(addenda, addendum)
9121- }
9122- }
9123- return addenda
9124- }
9125-
9126- if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
9127- addenda = append(addenda, addendum)
9128- }
9129- return addenda
9130-}
91311874
91321875 // removeLastAutoSubagentsAddendum removes one occurrence of a block known to
91331876 // have been appended by auto-subagents. Resolve appends owned blocks at the
91341877 // end, so removing the last occurrence preserves an identical base block.
9135-func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
9136- idx := strings.LastIndex(system, addendum)
9137- if idx < 0 {
9138- return system, false
9139- }
9140- return system[:idx] + system[idx+len(addendum):], true
9141-}
91421878
91431879 // applyAutoSubagentsSystemPrompt swaps the prompt blocks owned by subagent
91441880 // prompting on the running agent. Toggling strict orchestration changes the
91451881 // delegation guidance while retaining the profile manifest when usable.
9146-func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
9147- // i.mu is sufficient here: this path updates the existing agent's system
9148- // prompt and managed addenda in place; it does not replace the agent or
9149- // its tool registry, so agentMu is not required.
9150- i.mu.Lock()
9151- defer i.mu.Unlock()
9152- if i.agent == nil {
9153- return
9154- }
9155-
9156- sys, _ := i.agent.PromptConfig()
9157- changed := false
9158- if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
9159- var removed bool
9160- sys, removed = removeLastAutoSubagentsAddendum(sys, guidance)
9161- changed = changed || removed
9162- }
9163- for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
9164- var removed bool
9165- sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
9166- changed = changed || removed
9167- }
9168- i.managedAutoSubagentsAddenda = nil
9169-
9170- addenda := autoSubagentsAddenda(i.cfg, orchestrating)
9171- if changed {
9172- sys = strings.TrimRight(sys, "\n")
9173- }
9174- for _, addendum := range addenda {
9175- if sys != "" {
9176- sys += "\n\n"
9177- }
9178- sys += addendum
9179- i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
9180- changed = true
9181- }
9182- if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
9183- if sys != "" {
9184- sys += "\n\n"
9185- }
9186- sys += guidance
9187- changed = true
9188- }
9189- if !changed {
9190- return
9191- }
9192- i.agent.SetSystemPrompt(sys)
9193-}
91941882
91951883 // applyAutoSubagentsTool registers the canonical subagent tools whenever
91961884 // launch-time policy permits them. Auto-subagents controls prompt behavior,
91971885 // while the tools remain available for explicit user-requested delegation.
91981886 // Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and
91991887 // /reload-ext additions survive a settings change.
9200-func (i *Interactive) autoSubagentsAvailable() bool {
9201- return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
9202-}
9203-
9204-func (i *Interactive) autoSubagentsUnavailableHint() string {
9205- var hints []string
9206- if i.cfg.ResidentManager == nil {
9207- hints = append(hints, "resident subagent manager is unavailable in this mode")
9208- }
9209- if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
9210- hints = append(hints, "launch-time tool policy excludes subagent manager tools")
9211- }
9212- return strings.Join(hints, "; ")
9213-}
9214-
9215-func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
9216- return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
9217-}
9218-
9219-func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
9220- if cfg.AutoSubagentsStatusToolAllowed != nil {
9221- return *cfg.AutoSubagentsStatusToolAllowed
9222- }
9223- // Preserve the old single-boolean embedding contract: when callers have
9224- // not supplied the new field, status follows the existing delegation gate.
9225- return autoSubagentsToolAllowedConfig(cfg)
9226-}
9227-
9228-func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
9229- if cfg.AutoSubagentsStopToolAllowed != nil {
9230- return *cfg.AutoSubagentsStopToolAllowed
9231- }
9232- return autoSubagentsToolAllowedConfig(cfg)
9233-}
9234-
9235-func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
9236- if cfg.AutoSubagentsResumeToolAllowed != nil {
9237- return *cfg.AutoSubagentsResumeToolAllowed
9238- }
9239- return autoSubagentsToolAllowedConfig(cfg)
9240-}
9241-
9242-func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
9243- return autoSubagentsToolAllowedConfig(cfg) ||
9244- autoSubagentsStatusToolAllowedConfig(cfg) ||
9245- autoSubagentsStopToolAllowedConfig(cfg) ||
9246- autoSubagentsResumeToolAllowedConfig(cfg)
9247-}
9248-
9249-func (i *Interactive) autoSubagentsToolAllowed() bool {
9250- return autoSubagentsToolAllowedConfig(i.cfg)
9251-}
9252-
9253-func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
9254- return autoSubagentsStatusToolAllowedConfig(i.cfg)
9255-}
9256-
9257-func (i *Interactive) autoSubagentsStopToolAllowed() bool {
9258- return autoSubagentsStopToolAllowedConfig(i.cfg)
9259-}
9260-
9261-func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
9262- return autoSubagentsResumeToolAllowedConfig(i.cfg)
9263-}
9264-
9265-func (i *Interactive) autoSubagentsEnabledLocked() bool {
9266- return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
9267-}
9268-
9269-func (i *Interactive) applyAutoSubagentsTool() {
9270- i.agentMu.Lock()
9271- defer i.agentMu.Unlock()
9272- i.mu.Lock()
9273- defer i.mu.Unlock()
9274- if i.agent == nil {
9275- return
9276- }
9277- current := i.agent.ToolsSnapshot()
9278- next := core.Registry{}
9279- for name, t := range current {
9280- if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
9281- continue
9282- }
9283- next[name] = t
9284- }
9285- if i.autoSubagentsAvailable() {
9286- if i.autoSubagentsToolAllowed() {
9287- canonical := &tools.SubagentSpawnTool{
9288- ResidentManager: i.cfg.ResidentManager,
9289- BuildResidentSpec: i.cfg.BuildResidentSpec,
9290- Enabled: func() bool { return true },
9291- DefaultModel: func() string { return i.cfg.Model },
9292- DefaultProvider: func() string { return i.cfg.Provider },
9293- DefaultReasoning: func() string { return i.cfg.Reasoning },
9294- ResolveSubagent: i.cfg.ResolveSubagent,
9295- }
9296- next[canonical.Name()] = canonical
9297- }
9298- if i.autoSubagentsStatusToolAllowed() {
9299- statusTool := &tools.SubagentStatusTool{
9300- ResidentManager: i.cfg.ResidentManager,
9301- Enabled: func() bool { return true },
9302- }
9303- next[statusTool.Name()] = statusTool
9304- }
9305- if i.autoSubagentsStopToolAllowed() {
9306- stopTool := &tools.SubagentStopTool{
9307- ResidentManager: i.cfg.ResidentManager,
9308- Enabled: func() bool { return true },
9309- }
9310- next[stopTool.Name()] = stopTool
9311- }
9312- if i.autoSubagentsResumeToolAllowed() {
9313- resumeTool := &tools.SubagentResumeTool{
9314- ResidentManager: i.cfg.ResidentManager,
9315- Enabled: func() bool { return true },
9316- }
9317- next[resumeTool.Name()] = resumeTool
9318- }
9319- }
9320- i.agent.SetTools(next)
9321-}
93221888
93231889 // applyTelegramTools registers (active=true) or removes (active=false)
93241890 // the telegram_send_image and telegram_send_file tools on the running
@@ -9327,644 +1893,90 @@ func (i *Interactive) applyAutoSubagentsTool() {
93271893 // /reload-ext additions made while Telegram is connected survive a
93281894 // later /telegram disconnect. The two Telegram entries are always replaced;
93291895 // web_search is additionally stripped only while the external bridge is active.
9330-func (i *Interactive) applyTelegramTools(active bool) {
9331- if active {
9332- // The child ceiling must be in place even when no live main agent exists,
9333- // and before Bridge.Start can accept an external prompt.
9334- i.setWebSearchAvailable(false)
9335- }
9336- i.agentMu.Lock()
9337- defer i.agentMu.Unlock()
9338- i.mu.Lock()
9339- defer i.mu.Unlock()
9340- if i.agent == nil {
9341- return
9342- }
9343- current := i.agent.ToolsSnapshot()
9344- next := core.Registry{}
9345- for name, t := range current {
9346- if name == "telegram_send_image" || name == "telegram_send_file" {
9347- continue
9348- }
9349- if active && name == "web_search" {
9350- // External Telegram prompts have no per-request confirmation
9351- // surface, so V1 does not expose native web search while paired.
9352- continue
9353- }
9354- next[name] = t
9355- }
9356- if active {
9357- sender := telegramSenderAdapter{bridge: i.telegramBridge}
9358- next["telegram_send_image"] = &tools.TelegramSendImageTool{
9359- CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
9360- }
9361- next["telegram_send_file"] = &tools.TelegramSendFileTool{
9362- CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
9363- }
9364- }
9365- i.agent.SetTools(next)
9366- _, webSearchAvailable := next["web_search"]
9367- i.setWebSearchAvailable(webSearchAvailable && !active)
9368-}
93691896
93701897 // telegramStatus writes a one-liner describing the bridge state.
93711898 // Reports on both the in-tui bridge and the background daemon so
93721899 // the user isn't confused when the daemon owns the poll loop.
9373-func (i *Interactive) telegramStatus() {
9374- var msg string
9375- if i.telegramBridge != nil && i.telegramBridge.Active() {
9376- s := i.telegramBridge.State()
9377- msg = "telegram: connected (tui bridge)"
9378- if s.Username != "" {
9379- msg += " as @" + s.Username
9380- }
9381- if s.PairedID != 0 {
9382- msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
9383- } else {
9384- msg += " - awaiting pairing"
9385- }
9386- } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
9387- msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
9388- } else {
9389- cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
9390- if cfg.BotToken == "" {
9391- msg = "telegram: not configured. run `zut telegram-bot setup` first."
9392- } else {
9393- msg = "telegram: disconnected"
9394- if cfg.BotUsername != "" {
9395- msg += " (@" + cfg.BotUsername + " ready to connect)"
9396- }
9397- }
9398- }
9399- i.mu.Lock()
9400- i.statusOK = msg
9401- i.statusErr = ""
9402- i.mu.Unlock()
9403- i.invalidate()
9404-}
94051900
94061901 // telegramHost adapts *Interactive to telegram.Host so the bridge
94071902 // can call back into the TUI without importing modes directly.
94081903 type telegramHost struct{ iv *Interactive }
94091904
9410-func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
9411- h.iv.SubmitOrQueue(prompt, images)
9412-}
9413-
9414-func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
9415-
9416-func (h *telegramHost) Status() string {
9417- h.iv.mu.Lock()
9418- providerName := h.iv.cfg.Provider
9419- model := h.iv.cfg.Model
9420- cwd := h.iv.cfg.CWD
9421- usage := h.iv.cumUsage
9422- subscription := h.iv.cfg.AuthMethod == "oauth"
9423- ctxUsed := h.iv.lastCtxInput
9424- busy := h.iv.busy
9425- queued := len(h.iv.queued)
9426- h.iv.mu.Unlock()
9427-
9428- ctxMax := 0
9429- if m, err := provider.FindModel(providerName, model); err == nil {
9430- ctxMax = m.ContextWindow
9431- }
9432- return telegram.FormatStatus(telegram.StatusSnapshot{
9433- Provider: providerName,
9434- Model: model,
9435- CWD: cwd,
9436- Usage: usage,
9437- Subscription: subscription,
9438- ContextUsed: ctxUsed,
9439- ContextMax: ctxMax,
9440- Busy: busy,
9441- Queued: queued,
9442- })
9443-}
9444-
9445-func (h *telegramHost) Notify(level, message string) {
9446- h.iv.mu.Lock()
9447- switch level {
9448- case "error", "warn":
9449- h.iv.statusErr = message
9450- h.iv.statusOK = ""
9451- default:
9452- h.iv.statusOK = message
9453- h.iv.statusErr = ""
9454- }
9455- h.iv.mu.Unlock()
9456- h.iv.invalidate()
9457-}
9458-
94591905 // openSessionOpsDialog shows the picker for `/session` with no arg.
94601906 // Always offers export, import, fork, tree; the handlers bail with
94611907 // a clear status message when the precondition isn't met (empty
94621908 // transcript on fork; no parent/siblings on tree).
9463-func (i *Interactive) openSessionOpsDialog() {
9464- items := []sessionOpsItem{
9465- {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
9466- {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
9467- {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
9468- {label: "tree", action: "tree", hint: "switch between branches in this directory"},
9469- }
9470- i.sessionOpsDialog.Open(items)
9471- i.invalidate()
9472-}
94731909
94741910 // doSessionOp dispatches export, import, fork, or tree. arg is the
94751911 // optional positional argument from e.g. /session export <path>
94761912 // or /session import <path>; fork and tree ignore it.
9477-func (i *Interactive) doSessionOp(action, arg string) {
9478- switch action {
9479- case "export":
9480- i.doSessionExport(arg)
9481- case "import":
9482- i.doSessionImport(arg)
9483- case "fork":
9484- i.doSessionFork()
9485- case "tree":
9486- i.doSessionTree()
9487- default:
9488- i.mu.Lock()
9489- i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
9490- i.mu.Unlock()
9491- i.invalidate()
9492- }
9493-}
94941913
94951914 // doSessionExport writes the live session file to destination path
94961915 // dst. When dst is empty we default to ~/Downloads (falling back to
94971916 // the user's home directory if it doesn't exist). The helper
94981917 // expands a leading `~` and creates any missing parent directories.
9499-func (i *Interactive) doSessionExport(dst string) {
9500- src := i.currentSessionPath()
9501- if src == "" {
9502- i.mu.Lock()
9503- i.statusErr = "export: no session is active (running with --no-session?)"
9504- i.mu.Unlock()
9505- i.invalidate()
9506- return
9507- }
9508- // Persist any in-memory agent messages to the session file so
9509- // the export carries the full conversation. Without this, the
9510- // default lazy-flush-at-exit strategy leaves most of a running
9511- // session unwritten and the export ends up with only the meta.
9512- if i.cfg.FlushSession != nil {
9513- i.cfg.FlushSession()
9514- }
9515- dst = unquotePath(dst)
9516- if dst == "" {
9517- dst = defaultExportDir()
9518- } else {
9519- dst = expandTilde(dst)
9520- }
9521- out, err := core.ExportSession(src, dst)
9522- if err != nil {
9523- i.mu.Lock()
9524- i.statusErr = "export: " + err.Error()
9525- i.mu.Unlock()
9526- i.invalidate()
9527- return
9528- }
9529- i.mu.Lock()
9530- i.statusOK = "exported session to " + friendlyPath(out)
9531- i.statusErr = ""
9532- i.mu.Unlock()
9533- i.invalidate()
9534-}
95351918
95361919 // doSessionImport copies the .zutsession file at src into the
95371920 // running cwd's sessions directory and loads it as the active
95381921 // session, same as `/sessions` -> pick. When src is empty we ask
95391922 // the user to pass a path (no usable default here).
9540-func (i *Interactive) doSessionImport(src string) {
9541- src = unquotePath(src)
9542- if src == "" {
9543- i.mu.Lock()
9544- i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
9545- i.mu.Unlock()
9546- i.invalidate()
9547- return
9548- }
9549- src = expandTilde(src)
9550- if _, err := os.Stat(src); err != nil {
9551- i.mu.Lock()
9552- i.statusErr = "import: " + err.Error()
9553- i.mu.Unlock()
9554- i.invalidate()
9555- return
9556- }
9557- newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
9558- if err != nil {
9559- i.mu.Lock()
9560- i.statusErr = "import: " + err.Error()
9561- i.mu.Unlock()
9562- i.invalidate()
9563- return
9564- }
9565- if i.cfg.LoadSession == nil {
9566- i.mu.Lock()
9567- i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
9568- i.statusErr = ""
9569- i.mu.Unlock()
9570- i.invalidate()
9571- return
9572- }
9573- i.markSessionTitleSwitching()
9574- if err := i.cfg.LoadSession(newPath); err != nil {
9575- i.restoreFailedSessionTitle()
9576- i.mu.Lock()
9577- i.statusErr = "import: load failed: " + err.Error()
9578- i.mu.Unlock()
9579- i.invalidate()
9580- return
9581- }
9582- i.restoreLoadedSessionTitle()
9583- state := i.restoreCurrentCompactHandoff()
9584- i.mu.Lock()
9585- i.statusOK = "imported and switched to session " + friendlyPath(newPath)
9586- i.statusErr = ""
9587- if i.agent != nil {
9588- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9589- i.cumUsage = i.agent.Cost()
9590- last := i.agent.LastTurnUsage()
9591- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9592- if len(i.view.Messages) > initialResumeTailLimit {
9593- i.view.TailLimit = initialResumeTailLimit
9594- } else {
9595- i.view.TailLimit = 0
9596- }
9597- i.view.InvalidateRenderCache()
9598- }
9599- i.mu.Unlock()
9600- i.invalidate()
9601- if state.reason != compactContinuationNone {
9602- i.startRestoredCompactHandoff(i.runCtx)
9603- }
9604-}
96051923
96061924 // defaultExportDir returns ~/Downloads when it exists, or ~ as a
96071925 // fallback, or /tmp on exotic machines with no home dir.
9608-func defaultExportDir() string {
9609- home, err := os.UserHomeDir()
9610- if err != nil || home == "" {
9611- return os.TempDir()
9612- }
9613- downloads := filepath.Join(home, "Downloads")
9614- if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
9615- return downloads
9616- }
9617- return home
9618-}
96191926
96201927 // expandTilde turns a leading ~ into the user's home directory.
96211928 // Returns the input unchanged when there's no tilde or no home.
9622-func expandTilde(p string) string {
9623- if p == "" || p[0] != '~' {
9624- return p
9625- }
9626- home, err := os.UserHomeDir()
9627- if err != nil || home == "" {
9628- return p
9629- }
9630- if len(p) == 1 {
9631- return home
9632- }
9633- if p[1] == '/' || p[1] == filepath.Separator {
9634- return filepath.Join(home, p[2:])
9635- }
9636- return p
9637-}
96381929
96391930 // unquotePath strips a matching pair of surrounding single or
96401931 // double quotes. Drag-drop paste in the tui auto-quotes dropped
96411932 // file paths so the shell-like `/session import 'foo bar.zs'`
96421933 // stays well-formed; when the TUI's own slash handler consumes
96431934 // the arg, we want the raw path back.
9644-func unquotePath(p string) string {
9645- p = strings.TrimSpace(p)
9646- if len(p) >= 2 {
9647- first := p[0]
9648- last := p[len(p)-1]
9649- if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
9650- p = p[1 : len(p)-1]
9651- }
9652- }
9653- return p
9654-}
96551935
96561936 // friendlyPath collapses the user's home directory to a leading ~
96571937 // so status messages read cleanly. Falls back to the raw path when
96581938 // the home dir is unknown.
9659-func friendlyPath(p string) string {
9660- home, err := os.UserHomeDir()
9661- if err != nil || home == "" {
9662- return p
9663- }
9664- if strings.HasPrefix(p, home+string(filepath.Separator)) {
9665- return "~" + p[len(home):]
9666- }
9667- return p
9668-}
96691939
96701940 // doSessionFork opens the /jump turn picker in "fork mode". The
96711941 // next selection branches the current session at that user turn
96721942 // instead of scrolling the viewport to it.
9673-func (i *Interactive) doSessionFork() {
9674- path := i.currentSessionPath()
9675- if path == "" {
9676- i.mu.Lock()
9677- i.statusErr = "fork: no session is active (running with --no-session?)"
9678- i.mu.Unlock()
9679- i.invalidate()
9680- return
9681- }
9682- i.mu.Lock()
9683- busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
9684- queued := len(i.queued) != 0
9685- ag := i.agent
9686- i.mu.Unlock()
9687- if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
9688- i.mu.Lock()
9689- i.statusErr = "fork: wait until the current turn and queued messages finish"
9690- i.mu.Unlock()
9691- i.invalidate()
9692- return
9693- }
9694- if i.cfg.FlushSession != nil {
9695- i.cfg.FlushSession()
9696- }
9697- snapshot, err := core.ReadSessionSnapshot(path)
9698- if err != nil {
9699- i.mu.Lock()
9700- i.statusErr = "fork: " + err.Error()
9701- i.mu.Unlock()
9702- i.invalidate()
9703- return
9704- }
9705- msgs := snapshot.Messages
9706- if len(msgs) == 0 {
9707- i.mu.Lock()
9708- i.statusErr = "fork: transcript is empty; nothing to fork from"
9709- i.mu.Unlock()
9710- i.invalidate()
9711- return
9712- }
9713- i.pendingFork = true
9714- i.jumpDialog.Open(msgs, "")
9715- i.invalidate()
9716-}
97171943
97181944 // doSessionTree opens the current session family after the shared
97191945 // fail-closed gate succeeds. It intentionally does not fall back to the
97201946 // in-memory transcript: a tree that cannot be persisted and reloaded cannot
97211947 // safely create a navigation branch.
9722-func (i *Interactive) doSessionTree() {
9723- i.openSessionTree()
9724-}
97251948
97261949 // applySessionTreeMessageSelection keeps the old scalar integration
97271950 // source-compatible for package consumers. New dialog selections use the
97281951 // structured sessionTreeTarget path below.
9729-func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
9730- target := sessionTreeTarget{
9731- SourcePath: src,
9732- EffectiveIndex: msgIdx,
9733- SelectionBoundary: msgIdx + 1,
9734- Role: role,
9735- UserDraft: prompt,
9736- Boundary: sessionTreeMessageBoundary,
9737- }
9738- if role == provider.RoleUser {
9739- target.SelectionBoundary = msgIdx
9740- }
9741- i.applySessionTreeTarget(target, turnNo)
9742-}
97431952
97441953 // applySessionTreeTarget checks out a structured dialog target. The target's
97451954 // source and effective indices come from the same preflight snapshot the
97461955 // dialog rendered, while the live read below preserves compatibility with
97471956 // the existing LoadSession(path) error callback.
9748-func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
9749- // Selection is a state-changing operation too. Use the selection gate so
9750- // a queued/busy turn cannot race the hidden branch or accidentally submit
9751- // a provider turn while the session is being swapped. The open gate cannot
9752- // be reused here because the tree dialog itself owns the keyboard.
9753- if !i.canCommitSessionTreeSelection() {
9754- i.setSessionTreeError("tree: session branching is not available in this build")
9755- return
9756- }
9757- src := target.SourcePath
9758- if src == "" {
9759- src = i.currentSessionPath()
9760- }
9761- if src == "" {
9762- i.setSessionTreeError("tree: no session is active")
9763- return
9764- }
9765-
9766- // Read and validate the selected row before flushing or creating anything.
9767- // This gives tool-call/result pairs an atomic boundary and prevents a
9768- // malformed selection from changing the active session. Historical rows
9769- // come from a pre-compaction segment; current rows use the effective
9770- // snapshot that the running agent resumes from.
9771- var msgs []provider.Message
9772- var historicalSegment core.SessionHistorySegment
9773- if target.Historical {
9774- history, err := core.ReadSessionHistory(src)
9775- if err != nil {
9776- i.setSessionTreeError("tree: read selection: " + err.Error())
9777- return
9778- }
9779- if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
9780- i.setSessionTreeError("tree: selected history segment is unavailable")
9781- return
9782- }
9783- historicalSegment = history.Segments[target.HistorySegment]
9784- msgs = historicalSegment.Messages
9785- } else {
9786- sess, current, err := core.OpenSession(src)
9787- if err != nil {
9788- i.setSessionTreeError("tree: read selection: " + err.Error())
9789- return
9790- }
9791- if err := sess.Close(); err != nil {
9792- i.setSessionTreeError("tree: close selection: " + err.Error())
9793- return
9794- }
9795- msgs = current
9796- }
9797- selection, err := sessionTreeSelection(msgs, target)
9798- if err != nil {
9799- i.setSessionTreeError("tree: " + err.Error())
9800- return
9801- }
9802- if i.cfg.FlushSession != nil {
9803- i.cfg.FlushSession()
9804- }
9805- var newPath string
9806- if target.Historical {
9807- newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
9808- } else {
9809- newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
9810- }
9811- if err != nil {
9812- i.setSessionTreeError("tree: " + err.Error())
9813- return
9814- }
9815- i.markSessionTitleSwitching()
9816- if err := i.cfg.LoadSession(newPath); err != nil {
9817- i.restoreFailedSessionTitle()
9818- i.setSessionTreeError("tree: checkout failed: " + err.Error())
9819- return
9820- }
9821- state := i.restoreCurrentCompactHandoff()
9822-
9823- // LoadSession is intentionally the old func(string) error callback: the
9824- // CLI and embedders already own agent/session swapping. Refresh the view
9825- // from the swapped agent here instead of requiring a new callback result.
9826- i.resetSessionTitleForFreshBranch()
9827- i.scrollToBottom()
9828- i.mu.Lock()
9829- i.lastCtxInput = 0
9830- if i.agent != nil {
9831- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9832- i.cumUsage = i.agent.Cost()
9833- last := i.agent.LastTurnUsage()
9834- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9835- i.view.InvalidateRenderCache()
9836- }
9837- i.toolCalls = map[string]*tui.ToolCallView{}
9838- i.toolOrder = nil
9839- i.toolGate = map[string]int{}
9840- i.extNotes = nil
9841- if selection.restoreDraft {
9842- i.ed.SetValue(selection.draftText)
9843- i.clipboardImages = selection.images
9844- i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
9845- } else {
9846- i.ed.Clear()
9847- i.clipboardImages = nil
9848- i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
9849- }
9850- i.statusErr = ""
9851- i.mu.Unlock()
9852- i.scrollToBottom()
9853- // Selection does not submit, start, or queue the restored user draft; only
9854- // a later explicit Enter does that. An inherited compact handoff may resume
9855- // its hidden continuation below when this branch has the complete effective
9856- // transcript, without submitting the restored draft.
9857- i.invalidate()
9858- if state.reason != compactContinuationNone {
9859- i.startRestoredCompactHandoff(i.runCtx)
9860- }
9861-}
98621957
98631958 // applyForkSelection branches the current session at msgIdx+1 (so
98641959 // the selected user message and everything before it is included
98651960 // in the new branch), then switches the running agent to the new
98661961 // file. Called from the jump-dialog handler when pendingFork=true.
9867-func (i *Interactive) applyForkSelection(msgIdx int) {
9868- i.pendingFork = false
9869- src := i.currentSessionPath()
9870- if src == "" {
9871- i.mu.Lock()
9872- i.statusErr = "fork: no session is active"
9873- i.mu.Unlock()
9874- i.invalidate()
9875- return
9876- }
9877- if i.cfg.FlushSession != nil {
9878- i.cfg.FlushSession()
9879- }
9880- // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
9881- // the selected user message is included.
9882- upTo := msgIdx + 1
9883- newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
9884- if err != nil {
9885- i.mu.Lock()
9886- i.statusErr = "fork: " + err.Error()
9887- i.mu.Unlock()
9888- i.invalidate()
9889- return
9890- }
9891- if i.cfg.LoadSession == nil {
9892- i.mu.Lock()
9893- i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
9894- i.statusErr = ""
9895- i.mu.Unlock()
9896- i.invalidate()
9897- return
9898- }
9899- i.markSessionTitleSwitching()
9900- if err := i.cfg.LoadSession(newPath); err != nil {
9901- i.restoreFailedSessionTitle()
9902- i.mu.Lock()
9903- i.statusErr = "fork: switch failed: " + err.Error()
9904- i.mu.Unlock()
9905- i.invalidate()
9906- return
9907- }
9908- state := i.restoreCurrentCompactHandoff()
9909- i.resetSessionTitleForFreshBranch()
9910- i.mu.Lock()
9911- i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
9912- i.statusErr = ""
9913- i.mu.Unlock()
9914- i.invalidate()
9915- if state.reason != compactContinuationNone {
9916- i.startRestoredCompactHandoff(i.runCtx)
9917- }
9918-}
99191962
99201963 // formatInt is a tiny strconv.Itoa shim; keeps the handler above
99211964 // from needing a strconv import just for one call.
9922-func formatInt(n int) string {
9923- return fmt.Sprintf("%d", n)
9924-}
99251965
99261966 // assistantText returns the concatenated text of every TextBlock in
99271967 // m. Used by the streaming-view dedupe guard to tell when a live
99281968 // streamed reply has already been promoted into the transcript.
9929-func assistantText(m provider.Message) string {
9930- var sb strings.Builder
9931- for _, c := range m.Content {
9932- if tb, ok := c.(provider.TextBlock); ok {
9933- if sb.Len() > 0 {
9934- sb.WriteByte('\n')
9935- }
9936- sb.WriteString(tb.Text)
9937- }
9938- }
9939- return sb.String()
9940-}
99411969
99421970 // resetTranscriptRenderLocked invalidates every render cache that assumes
99431971 // the previous transcript remains structurally intact. Compaction is a
99441972 // replacement, not an append: the flow renderer must repaint from the new
99451973 // transcript rather than diff it against scrollback rows from the old one.
99461974 // Must be called with i.mu held.
9947-func (i *Interactive) resetTranscriptRenderLocked() {
9948- i.view.InvalidateRenderCache()
9949- i.chatCacheValid = false
9950- i.prevChatLen = 0
9951- i.prevChatCols = 0
9952- i.prevChatRows = 0
9953- i.prevScrollOffset = 0
9954- i.requestRendererInvalidate()
9955-}
99561975
99571976 // resetStreamingStateLocked clears every piece of streaming state
99581977 // in one shot. Used by abort paths (turn cancel, compact hand-off,
99591978 // queue drain) so the pacer doesn't keep draining stale runes from
99601979 // a prior turn. Must be called with i.mu held.
9961-func (i *Interactive) resetStreamingStateLocked() {
9962- i.streaming.Reset()
9963- i.streamPending = i.streamPending[:0]
9964- i.streamFlushPending = false
9965- i.streamOn = false
9966- i.openAllToolGatesLocked()
9967-}
99681980
99691981 // openAllToolGatesLocked drops every pending tool gate so that any
99701982 // tool registered during this turn renders unconditionally from now
@@ -9973,11 +1985,6 @@ func (i *Interactive) resetStreamingStateLocked() {
99731985 // this, the gate comparison against a freshly-reset streaming buffer
99741986 // would wrongly re-hide tools that had already cleared their gate.
99751987 // Must be called with i.mu held.
9976-func (i *Interactive) openAllToolGatesLocked() {
9977- for id := range i.toolGate {
9978- i.toolGate[id] = 0
9979- }
9980-}
99811988
99821989 // gateToolLocked records the stream position at which a tool call may
99831990 // become visible. The gate is the total length the streaming buffer
@@ -9992,27 +1999,10 @@ func (i *Interactive) openAllToolGatesLocked() {
99921999 // behaviour for tool-only turns and replayed sessions. First
99932000 // registration wins so a later EvToolCall can't move an existing
99942001 // gate. Must be called with i.mu held.
9995-func (i *Interactive) gateToolLocked(id string) {
9996- if _, ok := i.toolGate[id]; ok {
9997- return
9998- }
9999- if !i.streamOn {
10000- i.toolGate[id] = 0
10001- return
10002- }
10003- i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
10004-}
100052002
100062003 // toolGateOpenLocked reports whether the gated tool block may render
100072004 // yet, i.e. the pacer has drained enough text to reach the position
100082005 // recorded when the tool call arrived. Must be called with i.mu held.
10009-func (i *Interactive) toolGateOpenLocked(id string) bool {
10010- gate, ok := i.toolGate[id]
10011- if !ok || gate == 0 {
10012- return true
10013- }
10014- return i.streaming.Len() >= gate
10015-}
100162006
100172007 // assistantMessageSideEffects runs the non-visual hooks attached to
100182008 // EvAssistantMessage: the host-provided OnAssistant callback and the
@@ -10023,25 +2013,6 @@ func (i *Interactive) toolGateOpenLocked(id string) bool {
100232013 // the callbacks themselves must fire on message arrival so
100242014 // downstream observers (session persistence, telegram, cost panels)
100252015 // don't wait on a UI animation to catch up.
10026-func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
10027- if i.cfg.OnAssistant != nil {
10028- i.cfg.OnAssistant(m)
10029- }
10030- if i.telegramBridge != nil && i.telegramBridge.Active() {
10031- var sb strings.Builder
10032- for _, c := range m.Content {
10033- if tb, ok := c.(provider.TextBlock); ok {
10034- if sb.Len() > 0 {
10035- sb.WriteString("\n")
10036- }
10037- sb.WriteString(tb.Text)
10038- }
10039- }
10040- if text := sb.String(); strings.TrimSpace(text) != "" {
10041- go i.telegramBridge.OnAssistantText(text)
10042- }
10043- }
10044-}
100452016
100462017 // paintPaceRate is how many runes the streaming pacer releases per
100472018 // tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
@@ -10065,41 +2036,3 @@ const paintPaceInterval = 16 * time.Millisecond
100652036 // summary into 3 fat chunks, visually indistinguishable from "the
100662037 // whole reply just appeared". The pacer normalizes that so every
100672038 // path looks the same on screen.
10068-func (i *Interactive) runStreamPacer(ctx context.Context) {
10069- t := time.NewTicker(paintPaceInterval)
10070- defer t.Stop()
10071- for {
10072- select {
10073- case <-ctx.Done():
10074- return
10075- case <-t.C:
10076- i.mu.Lock()
10077- if len(i.streamPending) == 0 {
10078- // EvAssistantMessage already fired but the pacer
10079- // was still draining a tick ago. Everything is now
10080- // painted; clear the streaming flags so the next
10081- // redraw shows the finalised transcript message
10082- // and hides the streaming overlay.
10083- if i.streamFlushPending {
10084- i.streamFlushPending = false
10085- i.streaming.Reset()
10086- i.streamOn = false
10087- i.openAllToolGatesLocked()
10088- i.mu.Unlock()
10089- i.invalidate()
10090- continue
10091- }
10092- i.mu.Unlock()
10093- continue
10094- }
10095- n := paintPaceRate
10096- if n > len(i.streamPending) {
10097- n = len(i.streamPending)
10098- }
10099- i.streaming.WriteString(string(i.streamPending[:n]))
10100- i.streamPending = i.streamPending[n:]
10101- i.mu.Unlock()
10102- i.invalidate()
10103- }
10104- }
10105-}
A packages/agent/modes/interactive_alerts.go +79 -0
@@ -0,0 +1,79 @@
1+package modes
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/zut/packages/agent/extproto"
7+ "github.com/bnema/zut/packages/tui"
8+)
9+
10+// Notify is the manager's NotifyFromExt entry point.
11+func (i *Interactive) Notify(extName, level, message string) {
12+ i.appendExtensionNote(extName, message, level)
13+ i.invalidate()
14+}
15+
16+// Alert is the manager's AlertFromExt entry point. Alerts are emitted
17+// through the same terminal writer as redraws while holding the TUI mutex,
18+// so a BEL cannot be interleaved with a frame update.
19+func (i *Interactive) Alert(_ string, alert extproto.AlertRequest) {
20+ i.mu.Lock()
21+ i.emitAlertLocked(alert)
22+ i.mu.Unlock()
23+}
24+
25+func terminalAlertsEnabled(enabled *bool) bool {
26+ return enabled == nil || *enabled
27+}
28+
29+// emitAlertLocked applies the shared terminal-alert policy. The caller must
30+// hold i.mu because terminal writes share the renderer's output boundary.
31+func (i *Interactive) emitAlertLocked(alert extproto.AlertRequest) {
32+ if alert.Kind != extproto.AlertKindBell || !terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled) || i.cfg.Terminal == nil {
33+ return
34+ }
35+ _ = tui.WriteBell(i.cfg.Terminal)
36+}
37+
38+// scheduleMainAlert defers a main-session alert until the next redraw
39+// commits the final frame. Keeping this deferred even when no paced text is
40+// pending avoids racing the pacer's final state transition.
41+func (i *Interactive) scheduleMainAlert(reason string) {
42+ if reason == "" {
43+ return
44+ }
45+ i.mu.Lock()
46+ i.pendingAlert = &extproto.AlertRequest{Kind: extproto.AlertKindBell, Reason: reason}
47+ i.mu.Unlock()
48+ i.invalidate()
49+}
50+
51+// ClearNotes removes every note line owned by extName from the
52+// bottom-sticky ext-notes block. Extensions use this to retract a
53+// transient status line (e.g. an approval prompt) once it no longer
54+// applies, instead of leaving it stacked forever. Notes from other
55+// extensions and internal notes (auto-compact) are left untouched.
56+func (i *Interactive) ClearNotes(extName string) {
57+ marker := "[" + extName + "] "
58+ i.mu.Lock()
59+ if len(i.extNotes) == 0 {
60+ i.mu.Unlock()
61+ return
62+ }
63+ kept := i.extNotes[:0:0]
64+ changed := false
65+ for _, line := range i.extNotes {
66+ if strings.Contains(line, marker) {
67+ changed = true
68+ continue
69+ }
70+ kept = append(kept, line)
71+ }
72+ if changed {
73+ i.extNotes = kept
74+ }
75+ i.mu.Unlock()
76+ if changed {
77+ i.invalidate()
78+ }
79+}
A packages/agent/modes/interactive_commands.go +1226 -0
@@ -0,0 +1,1226 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "time"
11+
12+ "github.com/bnema/zut/packages/agent/skills"
13+ "github.com/bnema/zut/packages/agent/tools"
14+ "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
16+ "github.com/bnema/zut/packages/provider/auth"
17+ "github.com/bnema/zut/packages/tui"
18+)
19+
20+func buildStudyPrompt(arg, cwd string) string {
21+ arg = strings.TrimSpace(arg)
22+ if arg == "" {
23+ return "Read and understand everything in the current directory."
24+ }
25+ abs := arg
26+ if !filepath.IsAbs(abs) {
27+ abs = filepath.Join(cwd, abs)
28+ }
29+ display := arg
30+ if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
31+ display = rel
32+ }
33+ if info, err := os.Stat(abs); err == nil && !info.IsDir() {
34+ return "Read and understand the file " + display + "."
35+ }
36+ return "Read and understand everything in the directory " + display + "."
37+}
38+func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
39+ if ed == nil {
40+ return false
41+ }
42+ val := ed.Value()
43+ // Find the trailing run of non-whitespace.
44+ start := len(val)
45+ for start > 0 {
46+ r := val[start-1]
47+ if r == ' ' || r == '\t' || r == '\n' {
48+ break
49+ }
50+ start--
51+ }
52+ token := val[start:]
53+ if token == "" {
54+ return false
55+ }
56+ if !looksLikePathToken(token) {
57+ return false
58+ }
59+
60+ // Resolve the absolute parent directory + base prefix to match.
61+ parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
62+ if !ok {
63+ return true
64+ }
65+ entries, err := os.ReadDir(parentAbs)
66+ if err != nil {
67+ return true
68+ }
69+ var names []string
70+ var isDir []bool
71+ for _, e := range entries {
72+ name := e.Name()
73+ if !strings.HasPrefix(name, basePrefix) {
74+ continue
75+ }
76+ // Hide dotfiles unless the user explicitly typed a leading dot,
77+ // mirroring bash's default behaviour.
78+ if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
79+ continue
80+ }
81+ names = append(names, name)
82+ isDir = append(isDir, e.IsDir())
83+ }
84+ if len(names) == 0 {
85+ return true
86+ }
87+
88+ var completed string
89+ var completedIsDir bool
90+ if len(names) == 1 {
91+ completed = names[0]
92+ completedIsDir = isDir[0]
93+ } else {
94+ completed = longestCommonPrefix(names)
95+ if completed == basePrefix {
96+ // Already at the deepest unambiguous prefix; nothing to add.
97+ return true
98+ }
99+ }
100+
101+ // Build the replacement token in the same display form the user
102+ // typed (preserve ~ vs absolute vs relative).
103+ newToken := displayParent + completed
104+ if len(names) == 1 && completedIsDir {
105+ newToken += "/"
106+ }
107+
108+ ed.SetValue(val[:start] + newToken)
109+ return true
110+}
111+func (i *Interactive) tryPathTabComplete() bool {
112+ if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
113+ i.invalidate()
114+ return true
115+ }
116+ return false
117+}
118+func looksLikePathToken(tok string) bool {
119+ if tok == "" {
120+ return false
121+ }
122+ if tok[0] == '~' || tok[0] == '/' {
123+ return true
124+ }
125+ if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
126+ return true
127+ }
128+ return strings.Contains(tok, "/")
129+}
130+func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
131+ // Detect ~ expansion.
132+ expanded := tok
133+ homePrefix := ""
134+ if tok == "~" {
135+ home, err := os.UserHomeDir()
136+ if err != nil || home == "" {
137+ return "", "", "", false
138+ }
139+ // "~" alone: complete in $HOME. parent = home, base = "".
140+ return home, "", "~/", true
141+ }
142+ if strings.HasPrefix(tok, "~/") {
143+ home, err := os.UserHomeDir()
144+ if err != nil || home == "" {
145+ return "", "", "", false
146+ }
147+ expanded = home + tok[1:]
148+ homePrefix = "~"
149+ }
150+
151+ dir, base := splitDirBase(expanded)
152+ if !filepath.IsAbs(dir) {
153+ dir = filepath.Join(cwd, dir)
154+ }
155+
156+ // Reconstruct the display form the user typed for the parent,
157+ // keeping ~ when they used it. The base is dropped — the caller
158+ // substitutes the completed name.
159+ display := tok[:len(tok)-len(base)]
160+ if homePrefix != "" && !strings.HasPrefix(display, "~") {
161+ display = homePrefix + display[len(homePrefix):]
162+ }
163+ return dir, base, display, true
164+}
165+func splitDirBase(p string) (dir, base string) {
166+ if p == "" {
167+ return ".", ""
168+ }
169+ i := strings.LastIndex(p, "/")
170+ if i < 0 {
171+ return ".", p
172+ }
173+ return p[:i+1], p[i+1:]
174+}
175+func longestCommonPrefix(ss []string) string {
176+ if len(ss) == 0 {
177+ return ""
178+ }
179+ prefix := ss[0]
180+ for _, s := range ss[1:] {
181+ n := 0
182+ for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
183+ n++
184+ }
185+ prefix = prefix[:n]
186+ if prefix == "" {
187+ return ""
188+ }
189+ }
190+ return prefix
191+}
192+func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
193+ parts := strings.Fields(cmd)
194+ if len(parts) == 0 {
195+ return false
196+ }
197+
198+ if isSkillCommand(cmd) {
199+ var available []*skills.Skill
200+ if i.cfg.SkillSnapshot != nil {
201+ available = i.cfg.SkillSnapshot()
202+ }
203+ prompt, _, err := expandSkillCommand(cmd, available)
204+ if err != nil {
205+ i.mu.Lock()
206+ i.statusErr = err.Error()
207+ i.statusOK = ""
208+ i.mu.Unlock()
209+ return false
210+ }
211+ i.submitOrQueuePrompt(ctx, prompt)
212+ return false
213+ }
214+
215+ switch strings.ToLower(parts[0]) {
216+ case "/goal":
217+ i.runGoalCommand(ctx, cmd, parts)
218+ case "/exit":
219+ return true
220+ case "/clear":
221+ if i.agent != nil {
222+ i.agent.SetMessages(nil)
223+ }
224+ i.mu.Lock()
225+ i.toolCalls = map[string]*tui.ToolCallView{}
226+ i.toolOrder = nil
227+ i.toolGate = map[string]int{}
228+ i.statusErr = ""
229+ i.statusOK = ""
230+ i.helpBlock = nil
231+ i.parkedTurn = 0
232+ i.parkedTotal = 0
233+ i.scrollOffset = 0
234+ i.extNotes = nil
235+ i.reloadErrors = nil
236+ handoff, persistHandoff := i.resetCompactContinuationLocked()
237+ i.view.InvalidateRenderCache()
238+ i.mu.Unlock()
239+ if persistHandoff {
240+ i.persistCompactHandoff(handoff)
241+ }
242+ case "/help":
243+ i.mu.Lock()
244+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
245+ i.statusErr = ""
246+ i.statusOK = ""
247+ // Pin the viewport to the newest content so the help block,
248+ // which we just appended to the end of the transcript, is
249+ // what the user actually sees.
250+ i.scrollOffset = 0
251+ i.mu.Unlock()
252+ case "/login":
253+ i.dialog.Open(i.cfg.ZutHome)
254+ case "/logout":
255+ if len(parts) >= 2 {
256+ // Explicit target: /logout anthropic | openai | all
257+ i.doLogout(parts[1])
258+ break
259+ }
260+ // No arg: open the picker over whichever providers are
261+ // currently logged in. If nothing's logged in, bail with a
262+ // status line.
263+ i.openLogoutDialog()
264+ case "/model":
265+ if len(parts) >= 2 {
266+ i.applyModelSelection("", parts[1])
267+ } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
268+ i.mu.Lock()
269+ i.modelRefreshing = true
270+ i.statusOK = "Refreshing models"
271+ i.statusErr = ""
272+ i.mu.Unlock()
273+ go func() {
274+ refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
275+ defer cancel()
276+ i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
277+ }()
278+ } else {
279+ i.openModelPickerAfterRefresh(nil)
280+ }
281+ case "/llama":
282+ if i.cfg.LlamaCPPConfig == nil {
283+ i.mu.Lock()
284+ i.statusErr = "llama.cpp configuration is unavailable"
285+ i.statusOK = ""
286+ i.mu.Unlock()
287+ break
288+ }
289+ baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
290+ if configErr == nil {
291+ configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
292+ }
293+ if configErr != nil {
294+ i.mu.Lock()
295+ i.statusErr = configErr.Error()
296+ i.statusOK = ""
297+ i.mu.Unlock()
298+ }
299+ case "/reasoning":
300+ i.openReasoningDialog()
301+ case "/fast":
302+ enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
303+ i.applySettingToggle("fast_mode", enabled)
304+ case "/orchestrator":
305+ enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled
306+ i.applySettingToggle("auto_subagents_enabled", enabled)
307+ case "/settings":
308+ i.openSettingsDialog()
309+ case "/sessions":
310+ i.mu.Lock()
311+ i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
312+ i.mu.Unlock()
313+ case "/fork":
314+ i.doSessionFork()
315+ case "/jump":
316+ i.openJumpDialog(parts[1:])
317+ case "/btw":
318+ i.openBtwDialog(parts[1:])
319+ case "/skills":
320+ i.openSkillsDialog()
321+ case "/compact":
322+ i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
323+ case "/study":
324+ // Canned prompt that tells the agent to read every file
325+ // in some target so its later turns have the whole thing
326+ // in context. With no argument, the target is the current
327+ // directory. With an argument, the target is whatever the
328+ // user passed — typed by hand, drag-dropped, or selected
329+ // via the @ file picker (which is why we accept both files
330+ // and directories; the @-picker chips for both have already
331+ // been expanded to absolute paths by expandFileChips above).
332+ // Dispatched through the normal queue-or-start path so it
333+ // behaves identically to typing the prompt by hand.
334+ studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
335+ i.mu.Lock()
336+ busy := i.busy
337+ compacting := i.compacting
338+ ag := i.agent
339+ i.mu.Unlock()
340+ if busy {
341+ var handoff json.RawMessage
342+ var persistHandoff bool
343+ if ag != nil && !compacting {
344+ i.mu.Lock()
345+ handoff, persistHandoff = i.resetCompactContinuationLocked()
346+ ag.QueueMessage(studyPrompt)
347+ i.mu.Unlock()
348+ } else {
349+ i.mu.Lock()
350+ handoff, persistHandoff = i.resetCompactContinuationLocked()
351+ i.queued = append(i.queued, studyPrompt)
352+ i.mu.Unlock()
353+ }
354+ if persistHandoff {
355+ i.persistCompactHandoff(handoff)
356+ }
357+ i.invalidate()
358+ break
359+ }
360+ i.startTurn(ctx, studyPrompt)
361+ case "/cd":
362+ // Hidden command: switch the running session's cwd. Not in
363+ // slash_suggest, not in /help. Used by the workspaces
364+ // extension's panel-key Enter handler so picking a row
365+ // jumps zut into that directory without relaunching.
366+ //
367+ // Recovers the raw argument (path) from the original cmd
368+ // string rather than parts, so paths with spaces survive.
369+ // The host's ChangeCWD hook handles validation, session
370+ // close + reopen, agent rebuild, sandbox re-rooting, and
371+ // re-jail-if-jailed semantics.
372+ if i.cfg.ChangeCWD == nil {
373+ i.mu.Lock()
374+ i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
375+ i.mu.Unlock()
376+ break
377+ }
378+ path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
379+ if path == "" {
380+ i.mu.Lock()
381+ i.statusErr = "/cd: missing path"
382+ i.mu.Unlock()
383+ break
384+ }
385+ if err := i.cfg.ChangeCWD(path); err != nil {
386+ i.mu.Lock()
387+ i.statusErr = "/cd: " + err.Error()
388+ i.statusOK = ""
389+ i.mu.Unlock()
390+ break
391+ }
392+ // ChangeCWD has already updated i.cfg.CWD and swapped the
393+ // agent + session. Reset transient TUI state so the new
394+ // session opens clean.
395+ i.mu.Lock()
396+ i.toolCalls = map[string]*tui.ToolCallView{}
397+ i.toolOrder = nil
398+ i.toolGate = map[string]int{}
399+ i.helpBlock = nil
400+ i.parkedTurn = 0
401+ i.statusOK = "cwd " + i.cfg.CWD
402+ i.statusErr = ""
403+ i.mu.Unlock()
404+ i.fileSuggest.Reset()
405+ i.fileSuggest.SetCWD(i.cfg.CWD)
406+ i.invalidate()
407+ case "/jail":
408+ if i.cfg.Sandbox == nil {
409+ i.mu.Lock()
410+ i.statusErr = "sandbox not available in this build"
411+ i.mu.Unlock()
412+ break
413+ }
414+ i.cfg.Sandbox.Lock()
415+ i.mu.Lock()
416+ i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
417+ i.statusErr = ""
418+ i.mu.Unlock()
419+ case "/unjail":
420+ if i.cfg.Sandbox == nil {
421+ i.mu.Lock()
422+ i.statusErr = "sandbox not available in this build"
423+ i.mu.Unlock()
424+ break
425+ }
426+ i.cfg.Sandbox.Unlock()
427+ i.mu.Lock()
428+ i.statusOK = "unjailed"
429+ i.statusErr = ""
430+ i.mu.Unlock()
431+ case "/reload-ext":
432+ i.runReloadExt(ctx)
433+ case "/telegram", "/tg":
434+ if len(parts) >= 2 {
435+ i.doTelegram(parts[1])
436+ break
437+ }
438+ i.openTelegramDialog()
439+ case "/session":
440+ if len(parts) >= 2 {
441+ action := parts[1]
442+ arg := ""
443+ if len(parts) >= 3 {
444+ arg = strings.Join(parts[2:], " ")
445+ }
446+ i.doSessionOp(action, arg)
447+ break
448+ }
449+ i.openSessionOpsDialog()
450+ case "/subagents":
451+ i.runSubagents(ctx, parts[1:])
452+ default:
453+ // Last-resort fallback: try the extension manager. Built-in
454+ // cases above always win; this branch only fires for slash
455+ // commands the extension manager registered. Same routing as
456+ // the editor's submit-handler dispatch path so the autocomplete
457+ // "enter on highlighted suggestion" flow also works.
458+ extName := strings.TrimPrefix(parts[0], "/")
459+ if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
460+ rest := ""
461+ if len(parts) > 1 {
462+ rest = strings.Join(parts[1:], " ")
463+ }
464+ go i.invokeExtensionCommand(ctx, extName, rest)
465+ return false
466+ }
467+ i.mu.Lock()
468+ i.statusErr = "unknown command: " + parts[0]
469+ i.mu.Unlock()
470+ }
471+ return false
472+}
473+func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
474+ var loggedIn []string
475+ if i.cfg.LoggedInProviders != nil {
476+ loggedIn = i.cfg.LoggedInProviders()
477+ }
478+ i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
479+ i.mu.Lock()
480+ if refreshErr != nil {
481+ i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
482+ i.statusOK = ""
483+ } else if i.statusOK == "Refreshing models" {
484+ i.statusOK = ""
485+ }
486+ i.mu.Unlock()
487+}
488+func (i *Interactive) openLogoutDialog() {
489+ if i.cfg.AuthManager == nil {
490+ i.mu.Lock()
491+ i.statusErr = "no auth manager configured"
492+ i.mu.Unlock()
493+ i.invalidate()
494+ return
495+ }
496+ store := i.cfg.AuthManager.Store()
497+ if store == nil {
498+ i.mu.Lock()
499+ i.statusErr = "auth store is not available"
500+ i.mu.Unlock()
501+ i.invalidate()
502+ return
503+ }
504+ creds, err := store.Load()
505+ if err != nil {
506+ i.mu.Lock()
507+ i.statusErr = "read auth store: " + err.Error()
508+ i.mu.Unlock()
509+ i.invalidate()
510+ return
511+ }
512+
513+ var items []logoutItem
514+ for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
515+ if creds.Has(p) {
516+ method := creds.Method(p)
517+ if method == "oauth" {
518+ method = "subscription"
519+ }
520+ items = append(items, logoutItem{
521+ label: providerLabel(p),
522+ target: p,
523+ method: method,
524+ })
525+ }
526+ }
527+ if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
528+ items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
529+ }
530+ if creds.OpenAI.OAuth != nil {
531+ items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
532+ }
533+ for p, c := range creds.AdditionalAPIKeyCreds {
534+ if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
535+ items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
536+ }
537+ }
538+ if len(items) == 0 {
539+ i.mu.Lock()
540+ i.statusOK = "no credentials stored; already logged out"
541+ i.statusErr = ""
542+ i.mu.Unlock()
543+ i.invalidate()
544+ return
545+ }
546+ if len(items) > 1 {
547+ items = append(items, logoutItem{label: "all", target: "all"})
548+ }
549+
550+ i.logoutDialog.Open(items)
551+ i.invalidate()
552+}
553+func (i *Interactive) doLogout(target string) {
554+ if i.cfg.AuthManager == nil {
555+ i.mu.Lock()
556+ i.statusErr = "no auth manager configured"
557+ i.mu.Unlock()
558+ return
559+ }
560+ store := i.cfg.AuthManager.Store()
561+ if store == nil {
562+ i.mu.Lock()
563+ i.statusErr = "auth store is not available"
564+ i.mu.Unlock()
565+ return
566+ }
567+
568+ var providers []string
569+ switch target {
570+ case "", "all":
571+ providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
572+ case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
573+ providers = []string{target}
574+ default:
575+ known := false
576+ for _, p := range auth.APIKeyProviders() {
577+ if target == p {
578+ known = true
579+ break
580+ }
581+ }
582+ if !known {
583+ i.mu.Lock()
584+ i.statusErr = "unknown provider: " + target
585+ i.mu.Unlock()
586+ return
587+ }
588+ providers = []string{target}
589+ }
590+
591+ var errs []string
592+ clearedCurrent := false
593+ for _, p := range providers {
594+ var err error
595+ switch p {
596+ case "openai":
597+ err = store.ClearAPIKey("openai")
598+ case "openai-codex":
599+ err = store.ClearOAuth("openai")
600+ default:
601+ err = store.Clear(p)
602+ }
603+ if err != nil {
604+ errs = append(errs, p+": "+err.Error())
605+ continue
606+ }
607+ if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
608+ if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
609+ errs = append(errs, p+": "+err.Error())
610+ continue
611+ }
612+ }
613+ if p == i.cfg.Provider {
614+ clearedCurrent = true
615+ }
616+ }
617+
618+ llamaConfigured := i.llamaConfigured
619+ if i.cfg.LlamaCPPConfig != nil {
620+ baseURL, _, err := i.cfg.LlamaCPPConfig()
621+ llamaConfigured = err == nil && baseURL != ""
622+ }
623+
624+ i.mu.Lock()
625+ i.llamaConfigured = llamaConfigured
626+ if len(errs) > 0 {
627+ i.statusErr = "logout errors: " + strings.Join(errs, "; ")
628+ i.mu.Unlock()
629+ return
630+ }
631+ i.statusErr = ""
632+ var handoff json.RawMessage
633+ var persistHandoff bool
634+ if clearedCurrent {
635+ // The running agent was using a credential we just wiped. Drop
636+ // it so prompts can't go out with the stale client, and hint at
637+ // /login. Close any LSP processes owned by its tools first.
638+ if i.agent != nil {
639+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
640+ }
641+ i.agent = nil
642+ handoff, persistHandoff = i.resetCompactContinuationLocked()
643+ i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
644+ } else {
645+ i.statusOK = "logged out of " + strings.Join(providers, ", ")
646+ }
647+ i.mu.Unlock()
648+ if persistHandoff {
649+ i.persistCompactHandoff(handoff)
650+ }
651+}
652+func providerSetupInfo(provider string) (string, []string, bool) {
653+ const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
654+ switch provider {
655+ case "amazon-bedrock":
656+ return "Amazon Bedrock setup", []string{
657+ "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
658+ "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
659+ "",
660+ "For Bedrock API keys, set:",
661+ " AWS_BEARER_TOKEN_BEDROCK=...",
662+ " AWS_REGION=us-east-1",
663+ "",
664+ "Docs:",
665+ " " + docsURL,
666+ }, true
667+ case "google-vertex":
668+ return "Google Vertex AI setup", []string{
669+ "Google Vertex AI usually uses Google Cloud credentials and project settings.",
670+ "Set a Google API key, application-default credentials, or a service account.",
671+ "",
672+ "Common environment:",
673+ " GOOGLE_CLOUD_API_KEY=...",
674+ " GOOGLE_CLOUD_PROJECT=...",
675+ " GOOGLE_CLOUD_LOCATION=us-central1",
676+ "",
677+ "Docs:",
678+ " " + docsURL,
679+ }, true
680+ case "cloudflare-workers-ai":
681+ return "Cloudflare Workers AI setup", []string{
682+ "Cloudflare Workers AI needs both an API token and an account ID.",
683+ "",
684+ "Set:",
685+ " CLOUDFLARE_API_KEY=...",
686+ " CLOUDFLARE_ACCOUNT_ID=...",
687+ "",
688+ "Docs:",
689+ " " + docsURL,
690+ }, true
691+ case "cloudflare-ai-gateway":
692+ return "Cloudflare AI Gateway setup", []string{
693+ "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
694+ "",
695+ "Set:",
696+ " CLOUDFLARE_API_KEY=...",
697+ " CLOUDFLARE_ACCOUNT_ID=...",
698+ " CLOUDFLARE_GATEWAY_ID=...",
699+ "",
700+ "Docs:",
701+ " " + docsURL,
702+ }, true
703+ case "azure-openai-responses":
704+ return "Azure OpenAI Responses setup", []string{
705+ "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
706+ "",
707+ "Set:",
708+ " AZURE_OPENAI_API_KEY=...",
709+ " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
710+ " AZURE_OPENAI_API_VERSION=2024-02-01",
711+ "",
712+ "Docs:",
713+ " " + docsURL,
714+ }, true
715+ default:
716+ return "", nil, false
717+ }
718+}
719+func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
720+ if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
721+ i.dialog.ShowResult(false, "auth store is unavailable")
722+ return
723+ }
724+ if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
725+ i.dialog.ShowResult(false, err.Error())
726+ return
727+ }
728+ i.mu.Lock()
729+ i.llamaConfigured = true
730+ i.statusErr = ""
731+ i.statusOK = "configured llama.cpp router"
732+ i.mu.Unlock()
733+ i.dialog.ShowResult(true, "")
734+}
735+func (i *Interactive) startAPIKeyFlow(provider string) {
736+ if title, lines, ok := providerSetupInfo(provider); ok {
737+ i.dialog.ShowInfo(title, lines)
738+ return
739+ }
740+ if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
741+ _ = i.cfg.SetKimiCLIFallbackDisabled(false)
742+ }
743+ url, err := i.cfg.AuthManager.StartAPIKey(provider)
744+ if err != nil {
745+ i.dialog.ShowResult(false, err.Error())
746+ return
747+ }
748+ i.dialog.ShowWaiting(url)
749+}
750+func (i *Interactive) startOAuthFlow(provider string) {
751+ if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
752+ _ = i.cfg.SetKimiCLIFallbackDisabled(false)
753+ }
754+ // Device-code providers already support headless login and must only
755+ // start one polling flow.
756+ if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
757+ loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
758+ if err != nil {
759+ i.dialog.ShowResult(false, err.Error())
760+ return
761+ }
762+ i.dialog.ShowWaiting(loginURL)
763+ return
764+ }
765+ // Always run the manual/copy-code flow in parallel with the local
766+ // callback server so headless environments (docker, SSH) can paste
767+ // the authorization code directly without first pressing 'p'.
768+ _, err := i.cfg.AuthManager.StartOAuth(provider)
769+ if err != nil {
770+ i.dialog.ShowResult(false, err.Error())
771+ return
772+ }
773+ manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
774+ if mErr == nil {
775+ i.dialog.ShowWaiting(manualURL)
776+ } else {
777+ i.dialog.ShowResult(false, mErr.Error())
778+ }
779+}
780+func (i *Interactive) startManualOAuthFlow(provider string) {
781+ if i.cfg.AuthManager == nil {
782+ return
783+ }
784+ i.cfg.AuthManager.CancelOAuth()
785+ url, err := i.cfg.AuthManager.StartManualOAuth(provider)
786+ if err != nil {
787+ i.dialog.ShowResult(false, err.Error())
788+ return
789+ }
790+ i.dialog.url = url
791+ i.invalidate()
792+}
793+func (i *Interactive) submitManualOAuthCode(code string) {
794+ if i.cfg.AuthManager == nil {
795+ return
796+ }
797+ go func() {
798+ if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
799+ i.dialog.ShowResult(false, err.Error())
800+ i.invalidate()
801+ }
802+ }()
803+}
804+func (i *Interactive) cancelAndWaitForIdle() {
805+ i.mu.Lock()
806+ busy := i.busy
807+ cancel := i.cancelTurn
808+ i.mu.Unlock()
809+ if !busy {
810+ return
811+ }
812+ if cancel != nil {
813+ i.mu.Lock()
814+ handoff, persistHandoff := i.resetCompactContinuationLocked()
815+ i.mu.Unlock()
816+ cancel()
817+ if persistHandoff {
818+ i.persistCompactHandoff(handoff)
819+ }
820+ }
821+ // ConfirmToolCall waits on its response channel rather than the turn
822+ // context, so cancellation must also resolve every pending prompt.
823+ i.confirmDialog.CancelAll("turn cancelled")
824+ deadline := time.Now().Add(2 * time.Second)
825+ for time.Now().Before(deadline) {
826+ i.mu.Lock()
827+ done := !i.busy
828+ i.mu.Unlock()
829+ if done {
830+ return
831+ }
832+ time.Sleep(10 * time.Millisecond)
833+ }
834+}
835+func (i *Interactive) openBtwDialog(args []string) {
836+ i.agentMu.Lock()
837+ defer i.agentMu.Unlock()
838+ i.mu.Lock()
839+ ag := i.agent
840+ system := ""
841+ if ag != nil {
842+ system, _ = ag.PromptConfig()
843+ }
844+ theme := i.cfg.Theme
845+ model := i.cfg.Model
846+ cwd := i.cfg.CWD
847+ compact := i.compactModeEnabled()
848+ flatTools := i.cfg.FlatTools
849+ lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
850+ i.mu.Unlock()
851+ if ag == nil {
852+ i.mu.Lock()
853+ i.statusErr = "not logged in. type /login first."
854+ i.mu.Unlock()
855+ return
856+ }
857+ seed := strings.TrimSpace(strings.Join(args, " "))
858+ i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
859+ i.invalidate()
860+}
861+func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
862+ i.mu.Lock()
863+ if i.agent == nil {
864+ i.statusErr = "not logged in. type /login first."
865+ i.statusOK = ""
866+ i.mu.Unlock()
867+ i.invalidate()
868+ return
869+ }
870+ if i.busy {
871+ // Keep the mutex held while enqueueing so the turn-completion
872+ // goroutine cannot publish idle and inspect an empty queue between
873+ // this check and the append.
874+ var handoff json.RawMessage
875+ var persistHandoff bool
876+ if i.compacting {
877+ handoff, persistHandoff = i.resetCompactContinuationLocked()
878+ i.queued = append(i.queued, prompt)
879+ } else {
880+ handoff, persistHandoff = i.resetCompactContinuationLocked()
881+ i.agent.QueueMessage(prompt)
882+ }
883+ i.mu.Unlock()
884+ if persistHandoff {
885+ i.persistCompactHandoff(handoff)
886+ }
887+ i.invalidate()
888+ return
889+ }
890+ i.mu.Unlock()
891+ i.startTurn(ctx, prompt)
892+}
893+func (i *Interactive) openSkillsDialog() {
894+ var list []*skills.Skill
895+ if i.cfg.SkillSnapshot != nil {
896+ list = i.cfg.SkillSnapshot()
897+ }
898+ i.skillsDialog.Open(list)
899+ i.invalidate()
900+}
901+func (i *Interactive) openJumpDialog(args []string) {
902+ if i.view == nil || len(i.view.Messages) == 0 {
903+ i.mu.Lock()
904+ i.statusErr = "nothing to jump to \u2014 the session is empty"
905+ i.mu.Unlock()
906+ return
907+ }
908+ filter := strings.TrimSpace(strings.Join(args, " "))
909+ i.jumpDialog.Open(i.view.Messages, filter)
910+ // Shortcut: with a filter argument that matches exactly one turn,
911+ // jump immediately and skip the picker.
912+ if filter != "" {
913+ if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
914+ t := tgts[0]
915+ i.jumpDialog.Close()
916+ i.applyJumpSelection(t.MessageIdx, t.TurnNo)
917+ }
918+ }
919+}
920+func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
921+ cols := i.lastCols()
922+ chat, anchors := i.view.BuildWithAnchors(cols)
923+ var row int
924+ found := false
925+ for _, a := range anchors {
926+ if a.MessageIdx == msgIdx {
927+ row = a.Row
928+ found = true
929+ break
930+ }
931+ }
932+ if !found {
933+ i.mu.Lock()
934+ i.statusErr = "could not resolve jump target"
935+ i.mu.Unlock()
936+ return
937+ }
938+
939+ chatLen := len(chat)
940+ page := i.chatPage()
941+ if page < 1 {
942+ page = 1
943+ }
944+ // scrollOffset is measured from the bottom of the chat slice, so
945+ // to place `row` at the top of the viewport we want:
946+ // chatLen - scrollOffset - page == row
947+ // Solve for scrollOffset and clamp to [0, chatLen-page].
948+ offset := chatLen - (row + page)
949+ if offset < 0 {
950+ offset = 0
951+ }
952+ maxOffset := chatLen - page
953+ if maxOffset < 0 {
954+ maxOffset = 0
955+ }
956+ if offset > maxOffset {
957+ offset = maxOffset
958+ }
959+
960+ i.mu.Lock()
961+ i.scrollOffset = offset
962+ i.parkedTurn = turnNo
963+ i.parkedTotal = totalTurnsLocked(i.view.Messages)
964+ i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
965+ i.statusErr = ""
966+ i.mu.Unlock()
967+}
968+func totalTurnsLocked(msgs []provider.Message) int {
969+ n := 0
970+ for _, m := range msgs {
971+ if m.Role == provider.RoleUser {
972+ n++
973+ }
974+ }
975+ return n
976+}
977+func (i *Interactive) applySessionSelection(path string) {
978+ if i.cfg.LoadSession == nil {
979+ i.mu.Lock()
980+ i.statusErr = "session loading is not wired in this build"
981+ i.mu.Unlock()
982+ return
983+ }
984+ i.mu.Lock()
985+ if i.sessionLoading {
986+ i.statusErr = "already resuming a session"
987+ i.mu.Unlock()
988+ i.invalidate()
989+ return
990+ }
991+ i.sessionLoading = true
992+ i.statusOK = "resuming session: " + path
993+ i.statusErr = ""
994+ i.mu.Unlock()
995+ i.invalidate()
996+ i.markSessionTitleSwitching()
997+
998+ go func() {
999+ err := i.cfg.LoadSession(path)
1000+ if err != nil {
1001+ i.restoreFailedSessionTitle()
1002+ i.mu.Lock()
1003+ i.sessionLoading = false
1004+ i.statusErr = err.Error()
1005+ i.statusOK = ""
1006+ i.mu.Unlock()
1007+ i.invalidate()
1008+ return
1009+ }
1010+ i.restoreLoadedSessionTitle()
1011+ state := decodeCompactHandoff(i.currentCompactHandoff())
1012+ i.mu.Lock()
1013+ i.compactContinuation = state
1014+ i.sessionLoading = false
1015+ i.statusOK = "resumed session: " + path
1016+ i.statusErr = ""
1017+ i.parkedTurn = 0
1018+ i.parkedTotal = 0
1019+ i.scrollOffset = 0
1020+ // Fresh transcript swapped in: drop the auto-follow baseline so
1021+ // the next render's follow guard doesn't see the wholesale
1022+ // length change as a delta and jump the viewport.
1023+ i.prevChatLen = 0
1024+ i.prevChatCols = 0
1025+ i.extNotes = nil
1026+ i.view.InvalidateRenderCache()
1027+ if i.agent != nil {
1028+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1029+ i.cumUsage = i.agent.Cost()
1030+ if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
1031+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
1032+ } else {
1033+ i.lastCtxInput = 0
1034+ }
1035+ // Snap to the tail again — the swap brought in a fresh
1036+ // transcript whose markdown / chroma cost we don't want
1037+ // blocking the redraw.
1038+ if len(i.view.Messages) > initialResumeTailLimit {
1039+ i.view.TailLimit = initialResumeTailLimit
1040+ } else {
1041+ i.view.TailLimit = 0
1042+ }
1043+ }
1044+ i.mu.Unlock()
1045+ i.invalidate()
1046+ if state.reason != compactContinuationNone {
1047+ i.startRestoredCompactHandoff(i.runCtx)
1048+ }
1049+ }()
1050+}
1051+func (i *Interactive) applyModelSelection(prov, model string) {
1052+ i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
1053+}
1054+func (i *Interactive) applyRescueModelSelection(prov, model string) {
1055+ builder := i.cfg.BuildAgentForRescue
1056+ if builder == nil {
1057+ builder = i.cfg.BuildAgentFor
1058+ }
1059+ i.swapModel(prov, model, builder, true)
1060+}
1061+func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
1062+ var replaced bool
1063+ swap := func() {
1064+ replaced = i.swapModelUnserialized(prov, model, builder, rescue)
1065+ }
1066+ if i.cfg.SessionTransition != nil {
1067+ i.cfg.SessionTransition(swap)
1068+ } else {
1069+ swap()
1070+ }
1071+ if replaced {
1072+ i.resetCompactHandoff()
1073+ }
1074+}
1075+func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool {
1076+ if model == "" {
1077+ return false
1078+ }
1079+ m, err := provider.FindModel(prov, model)
1080+ if err != nil {
1081+ i.mu.Lock()
1082+ i.statusErr = err.Error()
1083+ i.mu.Unlock()
1084+ return false
1085+ }
1086+ // Same provider AND not a rescue retry: just swap the model on
1087+ // the existing agent. Mixed-API providers dispatch from model metadata,
1088+ // so the client remains reusable. Rescue retries always rebuild so a stale
1089+ // auth header / base URL can't carry over.
1090+ if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
1091+ i.mu.Lock()
1092+ i.cfg.Model = m.ID
1093+ i.agent.Model = m.ID
1094+ i.statusOK = "model: " + m.ID
1095+ i.statusErr = ""
1096+ i.mu.Unlock()
1097+ if i.cfg.PersistModel != nil {
1098+ i.cfg.PersistModel(i.cfg.Provider, m.ID)
1099+ }
1100+ return false
1101+ }
1102+ if builder == nil {
1103+ i.mu.Lock()
1104+ i.statusErr = "cannot switch provider: no builder configured"
1105+ i.mu.Unlock()
1106+ return false
1107+ }
1108+ // Snapshot the current transcript and cumulative usage BEFORE we
1109+ // build the replacement agent so we can hand them off. Without
1110+ // this the user perceives the entire session as wiped on a
1111+ // cross-provider /model swap.
1112+ var carryMsgs []provider.Message
1113+ var carryCost provider.Usage
1114+ if i.agent != nil {
1115+ carryMsgs = i.agent.Messages()
1116+ carryCost = i.agent.Cost()
1117+ }
1118+
1119+ ag, p, md, err := builder(m.Provider, m.ID)
1120+ if err != nil {
1121+ i.mu.Lock()
1122+ i.statusErr = err.Error()
1123+ i.mu.Unlock()
1124+ return false
1125+ }
1126+
1127+ // Replay the transcript and seed the cost on the freshly-built
1128+ // agent. Messages travel cleanly between providers because they
1129+ // use the same provider.Message shape; tool-call ids are local
1130+ // to a turn so cross-provider continuation never confuses the
1131+ // new model (it just sees the assistant's reply, no orphan
1132+ // tool_use blocks because /model swaps are gated to idle state).
1133+ if len(carryMsgs) > 0 {
1134+ ag.SetMessages(carryMsgs)
1135+ }
1136+ ag.SeedCost(carryCost)
1137+
1138+ if i.agent != nil && i.agent != ag {
1139+ _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
1140+ }
1141+ i.agentMu.Lock()
1142+ i.mu.Lock()
1143+ i.prepareReplacementAgentLocked(ag)
1144+ i.agent = ag
1145+ i.cfg.Provider = p
1146+ i.cfg.Model = md
1147+ if rescue {
1148+ i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
1149+ } else {
1150+ i.statusOK = "switched to " + p + " / " + md
1151+ }
1152+ i.statusErr = ""
1153+ // Render cache keys are width+content based, so the new agent's
1154+ // identical messages will reuse the existing entries. Nothing
1155+ // to invalidate.
1156+ i.mu.Unlock()
1157+ i.agentMu.Unlock()
1158+ // The new agent was built off the base tool registry, so any
1159+ // dynamically-registered tools need to be reattached. The apply
1160+ // helpers are no-ops when their feature is inactive, so the
1161+ // cross-provider path still works on a vanilla setup.
1162+ i.applyAutoSubagentsTool()
1163+ i.applyTelegramTools(i.telegramBridge != nil)
1164+ if i.cfg.PersistModel != nil {
1165+ i.cfg.PersistModel(p, md)
1166+ }
1167+ return true
1168+}
1169+func (i *Interactive) handleAuthEvent(ev auth.Event) {
1170+ switch ev.Kind {
1171+ case "started":
1172+ i.dialog.ShowWaiting(ev.URL)
1173+ case "browser_open":
1174+ // no-op
1175+ case "error":
1176+ i.dialog.ShowResult(false, ev.Message)
1177+ case "success":
1178+ // Keep the credential-driven resolve and its final replacement in the
1179+ // same transition as settings/session changes. Otherwise a build that
1180+ // started under the old web-search policy could commit afterward.
1181+ var buildErr error
1182+ var clearHandoff bool
1183+ buildAndCommitLogin := func() {
1184+ ag, prov, model, err := i.cfg.BuildAgent()
1185+ if err != nil {
1186+ buildErr = err
1187+ return
1188+ }
1189+ i.agentMu.Lock()
1190+ i.mu.Lock()
1191+ oldAgent := i.agent
1192+ i.prepareReplacementAgentLocked(ag)
1193+ _, clearHandoff = i.resetCompactContinuationLocked()
1194+ i.agent = ag
1195+ i.cfg.Provider = prov
1196+ i.cfg.Model = model
1197+ i.statusErr = ""
1198+ i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
1199+ i.mu.Unlock()
1200+ i.agentMu.Unlock()
1201+ if oldAgent != nil {
1202+ _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
1203+ }
1204+ i.applyAutoSubagentsTool()
1205+ i.applyTelegramTools(i.telegramBridge != nil)
1206+ // Authentication can change the provider used by the live
1207+ // agent. Persist it on the active session just like /model.
1208+ if i.cfg.PersistModel != nil {
1209+ i.cfg.PersistModel(prov, model)
1210+ }
1211+ }
1212+ if i.cfg.SessionTransition != nil {
1213+ i.cfg.SessionTransition(buildAndCommitLogin)
1214+ } else {
1215+ buildAndCommitLogin()
1216+ }
1217+ if buildErr != nil {
1218+ i.dialog.ShowResult(false, buildErr.Error())
1219+ return
1220+ }
1221+ if clearHandoff {
1222+ i.persistCompactHandoff(nil)
1223+ }
1224+ i.dialog.ShowResult(true, "")
1225+ }
1226+}
A packages/agent/modes/interactive_compaction.go +400 -0
@@ -0,0 +1,400 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/bnema/zut/packages/provider"
9+ "github.com/bnema/zut/packages/tui"
10+)
11+
12+func (i *Interactive) clearPendingCompactTurnLocked() {
13+ i.pendingCompactPrompt = ""
14+ i.pendingCompactImages = nil
15+ i.hasPendingCompactPrompt = false
16+ i.continueAfterCompact = false
17+ i.pendingPostCompactNote = ""
18+}
19+func (i *Interactive) compactHandoffLocked() json.RawMessage {
20+ return encodeCompactHandoff(i.compactContinuation)
21+}
22+func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
23+ if i.compactContinuation == state {
24+ return i.compactHandoffLocked(), false
25+ }
26+ i.compactContinuation = state
27+ return i.compactHandoffLocked(), true
28+}
29+func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
30+ return i.setCompactContinuationLocked(compactContinuationState{})
31+}
32+func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
33+ if persist := i.cfg.PersistCompactHandoff; persist != nil {
34+ if err := persist(state); err != nil {
35+ i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
36+ }
37+ }
38+}
39+func (i *Interactive) resetCompactHandoff() {
40+ i.mu.Lock()
41+ state, persist := i.resetCompactContinuationLocked()
42+ i.mu.Unlock()
43+ if persist {
44+ i.persistCompactHandoff(state)
45+ }
46+}
47+func (i *Interactive) currentCompactHandoff() json.RawMessage {
48+ if current := i.cfg.CurrentCompactHandoff; current != nil {
49+ return current()
50+ }
51+ return nil
52+}
53+func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
54+ i.mu.Lock()
55+ i.compactContinuation = state
56+ i.mu.Unlock()
57+}
58+func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
59+ state := decodeCompactHandoff(i.currentCompactHandoff())
60+ i.restoreCompactHandoff(state)
61+ return state
62+}
63+func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
64+ lastHandoff := -1
65+ for idx := len(messages) - 1; idx >= 0; idx-- {
66+ message := messages[idx]
67+ if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
68+ lastHandoff = idx
69+ break
70+ }
71+ }
72+ if lastHandoff < 0 {
73+ return compactHandoffAppendPrompt
74+ }
75+ for _, message := range messages[lastHandoff+1:] {
76+ switch message.Role {
77+ case provider.RoleTool:
78+ continue
79+ case provider.RoleAssistant:
80+ hasToolCall := false
81+ for _, content := range message.Content {
82+ if _, ok := content.(provider.ToolCallBlock); ok {
83+ hasToolCall = true
84+ break
85+ }
86+ }
87+ if !hasToolCall {
88+ return compactHandoffDiscard
89+ }
90+ default:
91+ return compactHandoffDiscard
92+ }
93+ }
94+ return compactHandoffContinueExisting
95+}
96+func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
97+ if parent == nil {
98+ parent = i.runCtx
99+ }
100+ if parent == nil {
101+ parent = context.Background()
102+ }
103+ i.mu.Lock()
104+ state := i.compactContinuation
105+ ag := i.agent
106+ busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
107+ if state.reason == compactContinuationNone || ag == nil || busy {
108+ i.mu.Unlock()
109+ return
110+ }
111+
112+ var next string
113+ continueQueued := false
114+ var resume compactHandoffResume
115+ var handoff json.RawMessage
116+ var persistHandoff bool
117+ switch {
118+ case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
119+ next, i.queued = i.queued[0], i.queued[1:]
120+ handoff, persistHandoff = i.resetCompactContinuationLocked()
121+ case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
122+ continueQueued = true
123+ handoff, persistHandoff = i.resetCompactContinuationLocked()
124+ default:
125+ resume = classifyCompactHandoffResume(ag.Messages())
126+ if resume == compactHandoffDiscard {
127+ handoff, persistHandoff = i.resetCompactContinuationLocked()
128+ }
129+ }
130+ // Reserve the turn slot before persisting or dispatching so concurrent input
131+ // can only queue behind the restored handoff, never start a competing turn.
132+ starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
133+ i.busy = starting
134+ i.mu.Unlock()
135+ if persistHandoff {
136+ i.persistCompactHandoff(handoff)
137+ }
138+ if !starting {
139+ i.invalidate()
140+ return
141+ }
142+ switch {
143+ case next != "":
144+ i.startTurn(parent, next)
145+ case continueQueued:
146+ i.startTurnRequest(parent, "", nil, true, false)
147+ case resume == compactHandoffContinueExisting:
148+ i.startTurnRequest(parent, "", nil, true, false)
149+ case resume == compactHandoffAppendPrompt:
150+ i.startAutoCompactContinuation(parent)
151+ }
152+}
153+func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
154+ i.mu.Lock()
155+ ag := i.agent
156+ if ag == nil {
157+ i.busy = false
158+ pendingIdleWork := i.takePendingIdleWorkLocked()
159+ i.mu.Unlock()
160+ runPendingIdleWork(pendingIdleWork)
161+ i.invalidate()
162+ return
163+ }
164+ // The caller selected the continuation while holding i.mu. Keep that
165+ // ordering through the hidden append so an explicitly queued user prompt
166+ // cannot be overtaken by an old-task handoff. Forced truncation is the
167+ // existing exception: it keeps priority and the queued prompt runs after it.
168+ if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
169+ next := i.queued[0]
170+ i.queued = i.queued[1:]
171+ handoff, persistHandoff := i.resetCompactContinuationLocked()
172+ i.mu.Unlock()
173+ if persistHandoff {
174+ i.persistCompactHandoff(handoff)
175+ }
176+ i.startTurn(parent, next)
177+ return
178+ }
179+ if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
180+ handoff, persistHandoff := i.resetCompactContinuationLocked()
181+ i.mu.Unlock()
182+ if persistHandoff {
183+ i.persistCompactHandoff(handoff)
184+ }
185+ i.startTurnRequest(parent, "", nil, true, false)
186+ return
187+ }
188+ ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
189+ autoCompactContinueMetaKey: "true",
190+ })
191+ i.mu.Unlock()
192+ i.startTurnRequest(parent, "", nil, true, false)
193+}
194+func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
195+ auto := request.origin != compactOriginManual
196+ if i.agent == nil {
197+ i.mu.Lock()
198+ i.statusErr = "not logged in. type /login first."
199+ i.mu.Unlock()
200+ return
201+ }
202+ ctx, cancel := context.WithCancel(parent)
203+ i.mu.Lock()
204+ i.busy = true
205+ i.compacting = true
206+ i.spin.Start()
207+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
208+ if auto {
209+ i.activity.kind = activityCondensingHistory
210+ i.autoCompacting = true
211+ } else {
212+ i.activity.kind = activityCompactingHistory
213+ }
214+ i.cancelTurn = cancel
215+ var initialHandoff json.RawMessage
216+ persistInitialHandoff := false
217+ if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
218+ initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
219+ }
220+ i.statusErr = ""
221+ i.statusOK = ""
222+ // Do NOT set streamOn: the summary text should not be visible
223+ // in the chat while compacting. The user just sees the spinner
224+ // and can keep typing / queue prompts.
225+ i.scrollOffset = 0
226+ i.helpBlock = nil
227+ i.mu.Unlock()
228+ if persistInitialHandoff {
229+ i.persistCompactHandoff(initialHandoff)
230+ }
231+ i.invalidate()
232+
233+ go func() {
234+ // Sink discards deltas — we don't stream the summary to the UI.
235+ sink := func(delta string) {}
236+ msgsBefore := i.agent.Messages()
237+ i.mu.Lock()
238+ statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
239+ i.mu.Unlock()
240+ continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
241+ if request.force {
242+ continuationReason = compactContinuationForcedLength
243+ }
244+ // Keep the usual recent tail when possible, but never let it cover
245+ // the whole transcript. A short session can still be at 70–90% of
246+ // a model's window because one prompt or tool result is large; the
247+ // automatic path must summarize that session instead of failing
248+ // before it reaches the compaction provider.
249+ keepTail := 4
250+ if n := len(msgsBefore); n > 0 && keepTail >= n {
251+ keepTail = n - 1
252+ }
253+ summary, err := i.agent.Compact(ctx, keepTail, sink)
254+ _ = summary
255+ goalMessage, goalActive := i.goalContinuationMessage()
256+ i.mu.Lock()
257+ // Keep busy/compacting asserted while cleanup and queue selection run.
258+ // Completion updates can arrive in this window; clearing busy before
259+ // inspecting the queues lets them race a new turn or be stranded.
260+ i.resetStreamingStateLocked()
261+ i.cancelTurn = nil
262+ i.autoCompacting = false
263+ pendingIdleWork := i.takePendingIdleWorkLocked()
264+
265+ // Drain pending work after the transcript is clean. Overflow recovery
266+ // continues the user message already in the transcript. Pre-turn
267+ // compaction preserves its complete text-and-images request. Regular
268+ // prompts typed during compaction remain in the host queue.
269+ var next string
270+ var nextImages []provider.ImageBlock
271+ var hasNext bool
272+ var continueExisting bool
273+ var continueAutomatically bool
274+ var continueGoal bool
275+ var handoff json.RawMessage
276+ var persistHandoff bool
277+
278+ switch {
279+ case err != nil && ctx.Err() != nil:
280+ i.statusErr = ""
281+ if auto {
282+ i.statusOK = "auto-condense cancelled"
283+ } else {
284+ i.statusOK = "compaction cancelled"
285+ }
286+ i.queued = nil // drop queue on cancel
287+ i.clearPendingCompactTurnLocked()
288+ handoff, persistHandoff = i.resetCompactContinuationLocked()
289+ if i.agent != nil {
290+ i.agent.DrainQueuedMessages()
291+ }
292+ case err != nil:
293+ i.statusErr = "compaction failed: " + err.Error()
294+ i.statusOK = ""
295+ i.queued = nil // drop queue on error
296+ i.clearPendingCompactTurnLocked()
297+ handoff, persistHandoff = i.resetCompactContinuationLocked()
298+ if i.agent != nil {
299+ i.agent.DrainQueuedMessages()
300+ }
301+ default:
302+ i.statusErr = ""
303+ // Read token count from the compaction message meta.
304+ tokens := ""
305+ msgs := i.agent.Messages()
306+ if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
307+ tokens = msgs[0].Meta["tokens_before"]
308+ }
309+ switch {
310+ case i.pendingPostCompactNote != "":
311+ i.statusOK = i.pendingPostCompactNote
312+ case tokens != "":
313+ i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
314+ default:
315+ i.statusOK = "compacted (ctrl+o to expand)"
316+ }
317+ i.pendingPostCompactNote = ""
318+ i.extNotes = stripAutoCompactNotes(i.extNotes)
319+ i.lastCtxInput = 0
320+ i.toolCalls = map[string]*tui.ToolCallView{}
321+ i.toolOrder = nil
322+ i.toolGate = map[string]int{}
323+ i.resetTranscriptRenderLocked()
324+ switch {
325+ case i.continueAfterCompact:
326+ continueExisting = true
327+ i.continueAfterCompact = false
328+ case i.hasPendingCompactPrompt:
329+ next = i.pendingCompactPrompt
330+ nextImages = i.pendingCompactImages
331+ i.pendingCompactPrompt = ""
332+ i.pendingCompactImages = nil
333+ i.hasPendingCompactPrompt = false
334+ hasNext = true
335+ case continuationReason == compactContinuationForcedLength:
336+ // Forced truncated-output continuation keeps its existing
337+ // priority over an explicitly queued prompt.
338+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
339+ continueAutomatically = true
340+ case len(i.queued) > 0:
341+ next, i.queued = i.queued[0], i.queued[1:]
342+ hasNext = true
343+ handoff, persistHandoff = i.resetCompactContinuationLocked()
344+ case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
345+ if continuationReason == compactContinuationStatusRescue {
346+ attempts := 1
347+ if i.compactContinuation.reason == compactContinuationStatusRescue {
348+ attempts = i.compactContinuation.rescueAttempts + 1
349+ }
350+ if attempts > maxStatusRescueContinuations {
351+ handoff, persistHandoff = i.resetCompactContinuationLocked()
352+ break
353+ }
354+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
355+ reason: continuationReason,
356+ rescueAttempts: attempts,
357+ })
358+ } else {
359+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
360+ }
361+ continueAutomatically = true
362+ case auto && goalActive && !i.coordinatorHasPendingWorkers():
363+ continueGoal = true
364+ handoff, persistHandoff = i.resetCompactContinuationLocked()
365+ default:
366+ handoff, persistHandoff = i.resetCompactContinuationLocked()
367+ }
368+ }
369+ // Keep the host busy until the hand-off decision is committed under
370+ // the mutex. A completion update arriving after the compaction result
371+ // but before this assignment is then queued for the selected follow-up
372+ // instead of starting a competing turn.
373+ i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
374+ i.compacting = false
375+ i.autoCompacting = false
376+ i.mu.Unlock()
377+ if persistHandoff {
378+ i.persistCompactHandoff(handoff)
379+ }
380+ runPendingIdleWork(pendingIdleWork)
381+ i.invalidate()
382+
383+ if hasNext || continueExisting || continueAutomatically || continueGoal {
384+ p := i.runCtx
385+ if p == nil {
386+ p = context.Background()
387+ }
388+ switch {
389+ case continueExisting:
390+ i.startTurnRequest(p, "", nil, true, true)
391+ case hasNext:
392+ i.startTurnWithImages(p, next, nextImages)
393+ case continueAutomatically:
394+ i.startAutoCompactContinuation(p)
395+ case continueGoal:
396+ i.startGoalContinuation(p, goalMessage)
397+ }
398+ }
399+ }()
400+}
A packages/agent/modes/interactive_events.go +347 -0
@@ -0,0 +1,347 @@
1+package modes
2+
3+import (
4+ "context"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/tools"
9+ "github.com/bnema/zut/packages/core"
10+ "github.com/bnema/zut/packages/provider"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+func eventAffectsPresentation(ev core.AgentEvent) bool {
15+ switch ev.(type) {
16+ case core.EvToolProgress:
17+ return false
18+ default:
19+ return true
20+ }
21+}
22+func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
23+ i.toolRenderRevision++
24+ if i.toolRenderRevision == 0 {
25+ // Overflow is practically unreachable, but resetting the sequence must
26+ // also drop old revisions before they can alias new frames.
27+ if i.view != nil {
28+ i.view.InvalidateRenderCache()
29+ }
30+ i.toolRenderRevision = 1
31+ }
32+ tc.Revision = i.toolRenderRevision
33+}
34+func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
35+ i.handleEvent(ev)
36+ // Progress payloads are still delivered to the core sink and applied
37+ // in order, but they have no interactive-visible representation.
38+ if eventAffectsPresentation(ev) {
39+ i.invalidate()
40+ }
41+}
42+func (i *Interactive) handleEvent(ev core.AgentEvent) {
43+ i.mu.Lock()
44+ defer i.mu.Unlock()
45+ i.activity.apply(ev)
46+ switch e := ev.(type) {
47+ case core.EvAssistantStart:
48+ // Fires at the top of every oneTurn, including follow-up
49+ // turns after tool use. Without this, the streaming buffer
50+ // is still marked off from the previous assistant message
51+ // and the final summary text pops in all at once instead
52+ // of typewriter-streaming delta by delta.
53+ i.streaming.Reset()
54+ i.streamPending = i.streamPending[:0]
55+ i.streamFlushPending = false
56+ i.streamOn = true
57+ // Clear the live tool-call overlay. Any tools from the
58+ // previous round are now fully folded into the transcript
59+ // (assistant tool_use block + tool role message with the
60+ // result), so keeping them in the overlay would duplicate
61+ // them in the view — once inside the finalised transcript
62+ // and once below the streaming block, with the streaming
63+ // summary sandwiched in between. The next EvToolUseStart
64+ // will populate fresh entries for this turn's tools.
65+ i.toolCalls = map[string]*tui.ToolCallView{}
66+ i.toolOrder = nil
67+ i.toolGate = map[string]int{}
68+ case core.EvTextDelta:
69+ // Buffer into streamPending; the paintPace ticker drains
70+ // it into i.streaming a few runes at a time for a smooth
71+ // typewriter effect independent of upstream chunk size.
72+ i.streamPending = append(i.streamPending, []rune(e.Delta)...)
73+ i.streamOn = true
74+ case core.EvAssistantMessage:
75+ // OnAssistant + telegram mirroring always fire on message
76+ // arrival — they read the FINAL message content, which is
77+ // complete regardless of what's still in the pacer.
78+ i.assistantMessageSideEffects(e.Message)
79+ // If the pacer still has characters to drain, keep streamOn
80+ // true and mark flush pending; the paintPace ticker will
81+ // drain the remainder and reset streaming state when done.
82+ // Otherwise (rare: full-replay sessions, abort paths) clear
83+ // synchronously so a later render doesn't show stale text.
84+ if len(i.streamPending) > 0 {
85+ i.streamFlushPending = true
86+ return
87+ }
88+ i.resetStreamingStateLocked()
89+ case core.EvToolUseStart:
90+ // Live streaming: pre-create the view so the user sees the
91+ // tool call being composed in real time. Any subsequent
92+ // EvToolCall for the same ID updates the same struct (the
93+ // final parsed args + name are already known here).
94+ if _, exists := i.toolCalls[e.ID]; !exists {
95+ tc := &tui.ToolCallView{
96+ ID: e.ID,
97+ Name: e.Name,
98+ Streaming: true,
99+ }
100+ i.bumpToolRevisionLocked(tc)
101+ i.toolCalls[e.ID] = tc
102+ i.toolOrder = append(i.toolOrder, e.ID)
103+ i.gateToolLocked(e.ID)
104+ }
105+ case core.EvToolUseArgs:
106+ if tc, ok := i.toolCalls[e.ID]; ok {
107+ tc.RawJSONBuf += e.Delta
108+ i.bumpToolRevisionLocked(tc)
109+ // Refresh the live path as soon as it parses; used in
110+ // the header (write /Users/example/Desktop/demo.ts)
111+ // while the content is still streaming.
112+ if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
113+ tc.LivePath = p
114+ } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
115+ tc.LivePath = p
116+ }
117+ }
118+ case core.EvToolUseEnd:
119+ if tc, ok := i.toolCalls[e.ID]; ok {
120+ tc.Streaming = false
121+ i.bumpToolRevisionLocked(tc)
122+ }
123+ case core.EvToolCall:
124+ // If we already pre-created the view during streaming, just
125+ // refresh the final Args summary. Otherwise create a new one
126+ // (non-streaming providers or legacy paths).
127+ if tc, ok := i.toolCalls[e.ID]; ok {
128+ tc.Args = tui.ShortArgs(e.Name, e.Args)
129+ i.bumpToolRevisionLocked(tc)
130+ if tc.RawJSONBuf == "" {
131+ tc.RawJSONBuf = string(e.Args)
132+ }
133+ tc.Streaming = false
134+ } else {
135+ tc := &tui.ToolCallView{
136+ ID: e.ID,
137+ Name: e.Name,
138+ Args: tui.ShortArgs(e.Name, e.Args),
139+ RawJSONBuf: string(e.Args),
140+ }
141+ i.bumpToolRevisionLocked(tc)
142+ i.toolCalls[e.ID] = tc
143+ i.toolOrder = append(i.toolOrder, e.ID)
144+ i.gateToolLocked(e.ID)
145+ }
146+ case core.EvToolResult:
147+ if tc, ok := i.toolCalls[e.ID]; ok {
148+ tc.Done = true
149+ tc.Error = e.Result.IsError
150+ tc.Preview = ""
151+ var text strings.Builder
152+ for _, c := range e.Result.Content {
153+ if tb, ok := c.(provider.TextBlock); ok {
154+ if text.Len() > 0 {
155+ text.WriteString("\n")
156+ }
157+ text.WriteString(tb.Text)
158+ }
159+ }
160+ tc.Result = text.String()
161+ i.bumpToolRevisionLocked(tc)
162+ }
163+ if update, ok := tools.GoalUpdateFromResult(e.Result); ok {
164+ if update.Status == core.GoalActive {
165+ // A manager may advance a terminal goal to the next persisted
166+ // goal in the same mission. Do not let a late tool result resume
167+ // a goal explicitly paused by the user.
168+ if i.goalStatus != core.GoalPaused {
169+ i.goalStatus = core.GoalActive
170+ }
171+ } else if i.goalStatus == core.GoalActive {
172+ i.goalStatus = update.Status
173+ }
174+ }
175+ case core.EvUsage:
176+ i.cumUsage = e.Cumulative
177+ if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
178+ i.lastCtxInput = contextUsed
179+ }
180+ case core.EvTurnEnd:
181+ if e.Stop == provider.StopAborted {
182+ i.resetStreamingStateLocked()
183+ i.statusErr = ""
184+ i.statusOK = "cancelled"
185+ return
186+ }
187+ if e.Stop == provider.StopLength {
188+ // The model hit its output-token cap mid-response, so the
189+ // reply (often a long write/edit) is truncated. Surface it
190+ // explicitly, otherwise the turn just ends and reads like
191+ // the UI gave up. The agent already requests the model's
192+ // full MaxOutput budget, so this means the response genuinely
193+ // exceeded that ceiling; ask the user to continue.
194+ i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
195+ i.statusOK = ""
196+ return
197+ }
198+ // Don't surface mid-loop stream errors as a red banner here.
199+ // EvTurnEnd fires after every step in a multi-step tool loop,
200+ // so a transient 503 / network blip would briefly paint a red
201+ // banner over the still-streaming chat before the agent loop
202+ // either retries or exits. The final error (if any) is set by
203+ // startTurnWithImages once Prompt() returns, and recoverable
204+ // failures are routed to the rescue picker instead — which
205+ // keeps the chat clean while the agent is working.
206+ _ = e.Err
207+ }
208+}
209+func (i *Interactive) WebSearchPolicyGeneration() uint64 {
210+ return i.webSearchPolicyGeneration.Load()
211+}
212+func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
213+ return i.applyAgentPromptConfig(ag, system, tools, 0, false)
214+}
215+func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
216+ return i.applyAgentPromptConfig(ag, system, tools, generation, true)
217+}
218+func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
219+ if ag == nil {
220+ return nil, false
221+ }
222+ // Match the lock order used by dynamic tool mutations so a refresh
223+ // cannot race a snapshot-and-replace operation that would reintroduce
224+ // the old registry after the atomic prompt swap.
225+ i.agentMu.Lock()
226+ defer i.agentMu.Unlock()
227+ i.mu.Lock()
228+ defer i.mu.Unlock()
229+ if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
230+ return nil, false
231+ }
232+ if i.telegramBridge != nil {
233+ // Telegram prompts arrive over an external messaging channel without a
234+ // per-request confirmation surface. Keep the normal interactive
235+ // registry from reintroducing web_search from the moment the bridge is
236+ // attached, including while its startup handshake is in flight.
237+ delete(tools, "web_search")
238+ }
239+ oldTools := ag.SetPromptConfig(system, tools)
240+ _, webSearchAvailable := tools["web_search"]
241+ i.setWebSearchAvailable(webSearchAvailable)
242+ return oldTools, true
243+}
244+func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
245+ if ag == nil {
246+ i.setWebSearchAvailable(false)
247+ return
248+ }
249+ registry := ag.ToolsSnapshot()
250+ if i.telegramBridge != nil {
251+ // External Telegram prompts may arrive as soon as the replacement is
252+ // published, so post-swap cleanup is too late.
253+ delete(registry, "web_search")
254+ ag.SetTools(registry)
255+ }
256+ _, webSearchAvailable := registry["web_search"]
257+ i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
258+}
259+func (i *Interactive) DeferUntilIdle(fn func()) {
260+ if fn == nil {
261+ return
262+ }
263+ i.mu.Lock()
264+ if i.busy {
265+ i.pendingIdleWork = append(i.pendingIdleWork, fn)
266+ i.mu.Unlock()
267+ return
268+ }
269+ i.mu.Unlock()
270+ fn()
271+}
272+func (i *Interactive) takePendingIdleWorkLocked() []func() {
273+ work := i.pendingIdleWork
274+ i.pendingIdleWork = nil
275+ return work
276+}
277+func runPendingIdleWork(work []func()) {
278+ for _, fn := range work {
279+ if fn != nil {
280+ fn()
281+ }
282+ }
283+}
284+func (i *Interactive) Agent() *core.Agent {
285+ i.mu.Lock()
286+ defer i.mu.Unlock()
287+ return i.agent
288+}
289+func (i *Interactive) runReloadExt(ctx context.Context) {
290+ if i.cfg.Extensions == nil {
291+ i.mu.Lock()
292+ i.statusErr = "no extension manager in this build"
293+ i.mu.Unlock()
294+ i.invalidate()
295+ return
296+ }
297+ i.mu.Lock()
298+ i.statusOK = "Reloading extensions..."
299+ i.statusErr = ""
300+ i.mu.Unlock()
301+ i.invalidate()
302+
303+ go func() {
304+ stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
305+ msg, failed := formatReloadStatus(stats)
306+ seq := i.setReloadStatus(msg, failed)
307+ i.invalidate()
308+ go i.dismissReloadStatus(ctx, seq, msg, failed)
309+ }()
310+}
311+func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
312+ return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
313+}
314+func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
315+ resp := make(chan core.ConfirmDecision, 1)
316+ if isBtwOrigin(call.Origin) {
317+ req := &confirmRequest{
318+ toolName: call.Name,
319+ preview: call.Summary,
320+ resp: resp,
321+ returnToChild: true,
322+ }
323+ if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
324+ i.confirmDialog.Enqueue(req)
325+ }) {
326+ return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
327+ }
328+ i.invalidate()
329+ return <-resp
330+ }
331+ if call.ID != "" {
332+ i.mu.Lock()
333+ if tc, ok := i.toolCalls[call.ID]; ok {
334+ tc.Args = call.Summary
335+ tc.Preview = call.Content
336+ i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
337+ }
338+ i.mu.Unlock()
339+ }
340+ i.confirmDialog.Enqueue(&confirmRequest{
341+ toolName: call.Name,
342+ preview: call.Summary,
343+ resp: resp,
344+ })
345+ i.invalidate()
346+ return <-resp
347+}
A packages/agent/modes/interactive_host.go +417 -0
@@ -0,0 +1,417 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/zut/packages/agent/extproto"
12+ "github.com/bnema/zut/packages/agent/internal/orchestration"
13+ "github.com/bnema/zut/packages/agent/subagents"
14+ "github.com/bnema/zut/packages/core"
15+ "github.com/bnema/zut/packages/provider"
16+ "github.com/bnema/zut/packages/tui"
17+)
18+
19+func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
20+ resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
21+ if err != nil {
22+ i.mu.Lock()
23+ i.statusErr = "extension /" + name + ": " + err.Error()
24+ i.statusOK = ""
25+ i.mu.Unlock()
26+ i.invalidate()
27+ return
28+ }
29+ if resp.Error != "" {
30+ i.mu.Lock()
31+ i.statusErr = "extension /" + name + ": " + resp.Error
32+ i.statusOK = ""
33+ i.mu.Unlock()
34+ i.invalidate()
35+ return
36+ }
37+ switch resp.Action {
38+ case "open_panel":
39+ if resp.OpenPanel != nil {
40+ extName := name
41+ if i.cfg.Extensions != nil {
42+ if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
43+ extName = owner
44+ }
45+ }
46+ i.OpenPanel(extName, *resp.OpenPanel)
47+ }
48+ case "prompt":
49+ if strings.TrimSpace(resp.Prompt) == "" {
50+ return
51+ }
52+ i.startTurn(i.runCtx, resp.Prompt)
53+ case "insert":
54+ i.ed.Insert(resp.Insert)
55+ i.invalidate()
56+ case "display":
57+ i.appendExtensionNote(name, resp.Display, "info")
58+ case "noop", "":
59+ // nothing
60+ default:
61+ i.mu.Lock()
62+ i.statusErr = "extension /" + name + ": unknown action " + resp.Action
63+ i.mu.Unlock()
64+ i.invalidate()
65+ }
66+}
67+func (i *Interactive) appendExtensionNote(extName, msg, level string) {
68+ if msg == "" {
69+ return
70+ }
71+ i.mu.Lock()
72+ defer i.mu.Unlock()
73+ color := i.cfg.Theme.Muted
74+ switch level {
75+ case "warn":
76+ color = i.cfg.Theme.Warning
77+ case "error":
78+ color = i.cfg.Theme.Error
79+ case "success":
80+ color = i.cfg.Theme.Tool
81+ }
82+ prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
83+ for _, line := range strings.Split(msg, "\n") {
84+ i.statusOK = "" // clear any stale ok
85+ i.statusErr = ""
86+ i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
87+ }
88+ // Extension slash commands complete asynchronously. If their result is
89+ // displayed while confirmation is waiting, keep Esc assigned to the note
90+ // until it is dismissed rather than letting it answer the tool call.
91+ i.confirmDialog.Blur()
92+}
93+func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
94+ i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil)
95+}
96+func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) {
97+ if ag == nil {
98+ return
99+ }
100+ i.agentMu.Lock()
101+ defer i.agentMu.Unlock()
102+ i.mu.Lock()
103+ i.prepareReplacementAgentLocked(ag)
104+ i.compactContinuation = decodeCompactHandoff(compactHandoff)
105+ i.agent = ag
106+ i.cfg.Provider = providerName
107+ i.cfg.Model = model
108+ i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
109+ i.cumUsage = ag.Cost()
110+ last := ag.LastTurnUsage()
111+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
112+ if len(i.view.Messages) > initialResumeTailLimit {
113+ i.view.TailLimit = initialResumeTailLimit
114+ } else {
115+ i.view.TailLimit = 0
116+ }
117+ i.view.InvalidateRenderCache()
118+ i.toolCalls = map[string]*tui.ToolCallView{}
119+ i.toolOrder = nil
120+ i.toolGate = map[string]int{}
121+ i.helpBlock = nil
122+ i.extNotes = nil
123+ i.parkedTurn = 0
124+ i.parkedTotal = 0
125+ i.mu.Unlock()
126+ i.invalidate()
127+}
128+func (i *Interactive) SetSubagentSessionScope(_ string) {
129+ i.invalidate()
130+}
131+func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
132+ i.applyChangedCWD(ag, provider, model, cwd, nil)
133+}
134+func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
135+ i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
136+}
137+func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
138+ home, _ := os.UserHomeDir()
139+ profiles, _ := subagents.Discover(cwd, home)
140+ subagentsAddendum := subagents.SystemPromptAddendum(profiles)
141+
142+ i.agentMu.Lock()
143+ defer i.agentMu.Unlock()
144+ i.mu.Lock()
145+ i.prepareReplacementAgentLocked(ag)
146+ i.resetCompactContinuationLocked()
147+ i.agent = ag
148+ i.cfg.CWD = cwd
149+ i.cfg.SubagentsSystemAddendum = subagentsAddendum
150+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
151+ i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
152+ i.view.StartupContextPaths = nil
153+ i.view.InvalidateRenderCache()
154+ if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
155+ i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
156+ }
157+ // Re-report the working directory to the terminal so "new tab here"
158+ // tracks the /cd change (OSC 7, see issue #38).
159+ if i.cfg.Terminal != nil {
160+ if seq := tui.ReportCWD(cwd); seq != "" {
161+ _, _ = i.cfg.Terminal.Write([]byte(seq))
162+ }
163+ }
164+ i.cfg.Provider = provider
165+ i.cfg.Model = model
166+ titleCancel := i.titleCancel
167+ i.titleCancel = nil
168+ i.titleVersion++
169+ i.sessionTitle = ""
170+ i.titleRealPromptSeen = false
171+ i.titleGenerationStarted = false
172+ i.writeTerminalTitleLocked("")
173+ i.toolCalls = map[string]*tui.ToolCallView{}
174+ i.toolOrder = nil
175+ i.toolGate = map[string]int{}
176+ i.helpBlock = nil
177+ i.parkedTurn = 0
178+ i.statusErr = ""
179+ i.mu.Unlock()
180+ if titleCancel != nil {
181+ titleCancel()
182+ }
183+ i.fileSuggest.Reset()
184+ i.fileSuggest.SetCWD(cwd)
185+ i.invalidate()
186+}
187+func (i *Interactive) SubmitSlash(text string) {
188+ i.mu.Lock()
189+ i.doubleEscape.Reset()
190+ i.mu.Unlock()
191+ text = strings.TrimSpace(text)
192+ if !strings.HasPrefix(text, "/") {
193+ return
194+ }
195+ if slashCommandCancelsTurn(text) {
196+ i.cancelAndWaitForIdle()
197+ }
198+ i.runSlash(i.runCtx, text)
199+ i.invalidate()
200+}
201+func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
202+ i.submitOrQueue(text, images, true)
203+}
204+func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
205+ if cmd, ok := shellEscapeCommand(text); ok {
206+ i.startShellEscape(i.runCtx, cmd)
207+ return
208+ }
209+ i.mu.Lock()
210+ if i.agent == nil {
211+ i.statusErr = "not logged in. type /login first."
212+ i.mu.Unlock()
213+ i.invalidate()
214+ return
215+ }
216+ i.mu.Unlock()
217+ if userInput && i.cfg.EnsureMission != nil {
218+ if err := i.cfg.EnsureMission(text); err != nil {
219+ i.ReportError(fmt.Errorf("persist mission: %w", err))
220+ return
221+ }
222+ }
223+ // User input is an orchestration event, not merely a queue append. When
224+ // the manager is dormant behind a sealed worker wave, the coordinator makes
225+ // this the next manager turn and carries any completed worker summary with it.
226+ if userInput && i.coordinatorAcceptsUserInput() {
227+ if actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}); len(actions) != 0 {
228+ i.executeCoordinatorActions(actions)
229+ return
230+ }
231+ }
232+ i.maybeStartSessionTitle(i.runCtx, text)
233+ i.mu.Lock()
234+ if i.busy {
235+ // Queue text only; images are dropped for queued prompts. Keep the
236+ // interactive mutex held through Agent.QueueMessage so turn teardown
237+ // cannot inspect the agent queue between the busy check and enqueue.
238+ // Compaction uses the host queue because it has no active agent loop
239+ // to drain Agent.QueueMessage entries.
240+ var handoff json.RawMessage
241+ var persistHandoff bool
242+ if i.agent != nil && !i.compacting {
243+ handoff, persistHandoff = i.resetCompactContinuationLocked()
244+ i.agent.QueueMessage(text)
245+ } else {
246+ handoff, persistHandoff = i.resetCompactContinuationLocked()
247+ i.queued = append(i.queued, text)
248+ }
249+ i.mu.Unlock()
250+ if persistHandoff {
251+ i.persistCompactHandoff(handoff)
252+ }
253+ i.invalidate()
254+ return
255+ }
256+ i.mu.Unlock()
257+ i.startTurnWithImages(i.runCtx, text, images)
258+}
259+func (i *Interactive) ChangelogVersion() string {
260+ if i.changelogDialog != nil {
261+ return i.changelogDialog.version
262+ }
263+ return ""
264+}
265+func (i *Interactive) CancelTurn() {
266+ i.mu.Lock()
267+ cancel := i.cancelTurn
268+ i.mu.Unlock()
269+ if cancel != nil {
270+ i.cancelCoordinator()
271+
272+ i.mu.Lock()
273+ handoff, persistHandoff := i.resetCompactContinuationLocked()
274+ i.mu.Unlock()
275+ cancel()
276+ i.confirmDialog.CancelAll("turn cancelled")
277+ if persistHandoff {
278+ i.persistCompactHandoff(handoff)
279+ }
280+ }
281+}
282+func (i *Interactive) Insert(text string) {
283+ i.ed.Insert(text)
284+ i.invalidate()
285+}
286+func (i *Interactive) Display(extName, text string) {
287+ i.appendExtensionNote(extName, text, "info")
288+ i.invalidate()
289+}
290+func (i *Interactive) ReportError(err error) {
291+ if err == nil {
292+ return
293+ }
294+ i.mu.Lock()
295+ i.statusOK = ""
296+ i.statusErr = err.Error()
297+ i.mu.Unlock()
298+ i.invalidate()
299+}
300+func (i *Interactive) SetStatus(extName, key, level, text string) {
301+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
302+ return
303+ }
304+ i.mu.Lock()
305+ if i.extStatuses == nil {
306+ i.extStatuses = map[string]map[string]extensionStatus{}
307+ }
308+ if strings.TrimSpace(text) == "" {
309+ if items := i.extStatuses[extName]; items != nil {
310+ delete(items, key)
311+ if len(items) == 0 {
312+ delete(i.extStatuses, extName)
313+ }
314+ }
315+ } else {
316+ if i.extStatuses[extName] == nil {
317+ i.extStatuses[extName] = map[string]extensionStatus{}
318+ }
319+ i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
320+ }
321+ i.mu.Unlock()
322+ i.invalidate()
323+}
324+func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
325+ if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
326+ return
327+ }
328+ position = extproto.NormalizeWidgetPosition(position)
329+ i.mu.Lock()
330+ if i.extWidgets == nil {
331+ i.extWidgets = map[string]map[string]extensionWidget{}
332+ }
333+ if i.extWidgets[extName] == nil {
334+ i.extWidgets[extName] = map[string]extensionWidget{}
335+ }
336+ i.extWidgets[extName][id] = extensionWidget{
337+ Position: position,
338+ Title: title,
339+ Lines: append([]string(nil), lines...),
340+ }
341+ i.mu.Unlock()
342+ i.invalidate()
343+}
344+func (i *Interactive) ClearWidget(extName, id string) {
345+ i.mu.Lock()
346+ if items := i.extWidgets[extName]; items != nil {
347+ delete(items, id)
348+ if len(items) == 0 {
349+ delete(i.extWidgets, extName)
350+ }
351+ }
352+ i.mu.Unlock()
353+ i.invalidate()
354+}
355+func (i *Interactive) ClearExtensionChrome(extName string) {
356+ if strings.TrimSpace(extName) == "" {
357+ return
358+ }
359+ marker := "[" + extName + "] "
360+ i.mu.Lock()
361+ changed := false
362+ if _, ok := i.extStatuses[extName]; ok {
363+ delete(i.extStatuses, extName)
364+ changed = true
365+ }
366+ if _, ok := i.extWidgets[extName]; ok {
367+ delete(i.extWidgets, extName)
368+ changed = true
369+ }
370+ if len(i.extNotes) > 0 {
371+ kept := i.extNotes[:0:0]
372+ for _, line := range i.extNotes {
373+ if strings.Contains(line, marker) {
374+ changed = true
375+ continue
376+ }
377+ kept = append(kept, line)
378+ }
379+ i.extNotes = kept
380+ }
381+ if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
382+ i.extPanel.Close()
383+ changed = true
384+ }
385+ i.mu.Unlock()
386+ if changed {
387+ i.invalidate()
388+ }
389+}
390+func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
391+ i.mu.Lock()
392+ defer i.mu.Unlock()
393+ i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
394+ i.confirmDialog.Blur()
395+ if i.cfg.Extensions != nil {
396+ cols, rows := i.cfg.Terminal.Size()
397+ _ = cols
398+ _ = rows
399+ }
400+ i.invalidate()
401+}
402+func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
403+ i.mu.Lock()
404+ defer i.mu.Unlock()
405+ if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
406+ i.extPanel.Update(title, lines, footer)
407+ i.invalidate()
408+ }
409+}
410+func (i *Interactive) ClosePanel(extName, panelID string) {
411+ i.mu.Lock()
412+ defer i.mu.Unlock()
413+ if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
414+ i.extPanel.Close()
415+ i.invalidate()
416+ }
417+}
A packages/agent/modes/interactive_input.go +59 -0
@@ -0,0 +1,59 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/tui"
8+)
9+
10+// prepareInputKey applies main-editor key state that must happen before
11+// routing the key to a dialog, suggestion, or editor. It returns false when
12+// clipboard resolution failed and the key must not be delivered further.
13+func (i *Interactive) prepareInputKey(k tui.Key) (tui.Key, bool) {
14+ // A bare Escape is eligible for the session-tree gesture only while the
15+ // main input owns the key. Any other key, modified Escape, or visible
16+ // child interaction resets the pending first tap before normal routing.
17+ if !isUnmodifiedEscape(k) || i.sessionTreeEscapeBlocked() {
18+ i.mu.Lock()
19+ i.doubleEscape.Reset()
20+ i.mu.Unlock()
21+ }
22+
23+ // Dialogs route keys before the main clipboard handler below. Resolve text
24+ // here when a child interaction owns input so every editor and filter sees
25+ // the same KeyPaste event as terminal-native bracketed paste. Main chat is
26+ // left image-first to preserve macOS clipboard image attachments.
27+ if k.Kind == tui.KeyPasteClipboard && i.confirmChildActive() {
28+ resolved, pastedText, err := resolveClipboardText(k, tui.ReadClipboardText)
29+ if err != nil {
30+ i.mu.Lock()
31+ i.statusErr = "clipboard paste failed: " + err.Error()
32+ i.statusOK = ""
33+ i.mu.Unlock()
34+ return tui.Key{}, false
35+ }
36+ if pastedText {
37+ k = resolved
38+ i.mu.Lock()
39+ i.statusErr = ""
40+ i.statusOK = ""
41+ i.mu.Unlock()
42+ }
43+ }
44+
45+ // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so pressing
46+ // ctrl+c then typing then ctrl+c much later doesn't quit unexpectedly. The
47+ // hint message also goes stale; clear it.
48+ if k.Kind != tui.KeyCtrlC {
49+ i.mu.Lock()
50+ if !i.lastCtrlC.IsZero() {
51+ i.lastCtrlC = time.Time{}
52+ if strings.HasPrefix(i.statusOK, "input cleared") || strings.HasPrefix(i.statusOK, "press ctrl+c") {
53+ i.statusOK = ""
54+ }
55+ }
56+ i.mu.Unlock()
57+ }
58+ return k, true
59+}
A packages/agent/modes/interactive_input_routing.go +1054 -0
@@ -0,0 +1,1054 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+ "github.com/bnema/zut/packages/tui"
13+)
14+
15+func (i *Interactive) armCtrlCExit() {
16+ i.mu.Lock()
17+ i.lastCtrlC = time.Now()
18+ i.mu.Unlock()
19+}
20+func (i *Interactive) ctrlCExitArmed() bool {
21+ i.mu.Lock()
22+ t := i.lastCtrlC
23+ i.mu.Unlock()
24+ return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
25+}
26+func (i *Interactive) clearFileSuggestQuery() {
27+ val := i.ed.Value()
28+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
29+ i.ed.SetValue(val[:idx+1])
30+ }
31+}
32+func (i *Interactive) setToolExpansion(expanded bool) {
33+ i.mu.Lock()
34+ i.view.ExpandAll = expanded
35+ i.requestRendererClear()
36+ i.mu.Unlock()
37+ i.invalidate()
38+}
39+func (i *Interactive) toggleToolExpansion() {
40+ i.mu.Lock()
41+ expanded := !i.view.ExpandAll
42+ i.mu.Unlock()
43+ i.setToolExpansion(expanded)
44+}
45+func (i *Interactive) toggleBtwToolExpansion() {
46+ i.btwDialog.ToggleToolExpansion()
47+ i.mu.Lock()
48+ i.requestRendererClear()
49+ i.mu.Unlock()
50+ i.invalidate()
51+}
52+func (i *Interactive) confirmChildActive() bool {
53+ i.mu.Lock()
54+ defer i.mu.Unlock()
55+ return i.confirmChildActiveLocked()
56+}
57+func (i *Interactive) confirmChildActiveLocked() bool {
58+ return len(i.helpBlock) > 0 ||
59+ len(i.extNotes) > 0 ||
60+ i.dialog.Active() ||
61+ i.modelDialog.Active() ||
62+ i.llamaDialog.Active() ||
63+ i.rescueDialog.Active() ||
64+ i.sessionDialog.Active() ||
65+ i.residentSubagentsDialog.Active() ||
66+ i.residentChildSession != nil ||
67+ i.jumpDialog.Active() ||
68+ i.btwDialog.Active() ||
69+ i.skillsDialog.Active() ||
70+ i.changelogDialog.Active() ||
71+ i.logoutDialog.Active() ||
72+ i.telegramDialog.Active() ||
73+ i.settingsDialog.Active() ||
74+ i.sessionOpsDialog.Active() ||
75+ i.sessionTreeDialog.Active() ||
76+ i.extPanel.Active()
77+}
78+func (i *Interactive) restoreConfirmFocus() {
79+ if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
80+ return
81+ }
82+ if !i.ed.IsEmpty() || i.confirmChildActive() {
83+ return
84+ }
85+ i.confirmDialog.Focus()
86+ i.invalidate()
87+}
88+func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
89+ defer i.restoreConfirmFocus()
90+
91+ var ok bool
92+ if k, ok = i.prepareInputKey(k); !ok {
93+ return false
94+ }
95+
96+ // Dialogs consume keys while open (except ctrl+c, which always closes them).
97+
98+ // Confirmation owns input by default. Pressing / deliberately moves
99+ // focus to the main editor without answering the pending request, so
100+ // slash commands and the dialogs they open can run first.
101+ if i.confirmDialog.Focused() {
102+ if k.Kind == tui.KeyRune && k.Rune == '/' {
103+ i.confirmDialog.Blur()
104+ i.ed.Insert("/")
105+ i.suggest.Reset()
106+ i.invalidate()
107+ return false
108+ }
109+ if k.Kind == tui.KeyCtrlO {
110+ if i.btwDialog != nil && i.btwDialog.Active() {
111+ i.toggleBtwToolExpansion()
112+ } else {
113+ i.toggleToolExpansion()
114+ }
115+ return false
116+ }
117+ i.confirmDialog.HandleKey(k)
118+ i.invalidate()
119+ return false
120+ }
121+ if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
122+ i.ed.Clear()
123+ i.suggest.Reset()
124+ i.fileSuggest.Reset()
125+ i.confirmDialog.Focus()
126+ i.invalidate()
127+ return false
128+ }
129+ if i.dialog.Active() {
130+ if k.Kind == tui.KeyCtrlC {
131+ i.dialog.Close()
132+ if i.cfg.AuthManager != nil {
133+ i.cfg.AuthManager.CancelOAuth()
134+ }
135+ return false
136+ }
137+ act := i.dialog.HandleKey(k)
138+ if act.StartAPIKey {
139+ i.startAPIKeyFlow(act.Provider)
140+ }
141+ if act.StartOAuth {
142+ i.startOAuthFlow(act.Provider)
143+ }
144+ if act.StartManual {
145+ i.startManualOAuthFlow(act.Provider)
146+ }
147+ if act.SubmitCode != "" {
148+ i.submitManualOAuthCode(act.SubmitCode)
149+ }
150+ if act.SaveLlama {
151+ i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
152+ }
153+ return false
154+ }
155+ if i.modelDialog.Active() {
156+ if k.Kind == tui.KeyCtrlC {
157+ i.modelDialog.Close()
158+ i.quickModelAssign = 0
159+ return false
160+ }
161+ act := i.modelDialog.HandleKey(k)
162+ if act.Close {
163+ i.quickModelAssign = 0
164+ }
165+ if act.ReasoningChanged {
166+ i.applyReasoningSetting(act.Reasoning)
167+ }
168+ if act.Select {
169+ if i.quickModelAssign > 0 {
170+ i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
171+ i.quickModelAssign = 0
172+ } else {
173+ i.applyModelSelection(act.Provider, act.Model)
174+ }
175+ }
176+ return false
177+ }
178+ if i.llamaDialog.Active() {
179+ i.llamaDialog.HandleKey(k)
180+ i.invalidate()
181+ return false
182+ }
183+ if i.rescueDialog.Active() {
184+ if k.Kind == tui.KeyCtrlC {
185+ i.rescueDialog.Close()
186+ i.invalidate()
187+ return false
188+ }
189+ act := i.rescueDialog.HandleKey(k)
190+ if act.Select {
191+ i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
192+ }
193+ i.invalidate()
194+ return false
195+ }
196+ i.mu.Lock()
197+ sessionDialogActive := i.sessionDialog.Active()
198+ i.mu.Unlock()
199+ if sessionDialogActive {
200+ if k.Kind == tui.KeyCtrlC {
201+ i.mu.Lock()
202+ i.sessionDialog.Close()
203+ i.sessionLoads = nil
204+ i.mu.Unlock()
205+ i.invalidate()
206+ return false
207+ }
208+ manualRenamePath := ""
209+ i.mu.Lock()
210+ if k.Kind == tui.KeyEnter && i.sessionDialog.renaming && core.NormalizeSessionTitle(i.sessionDialog.rename) != "" && i.sessionDialog.cursor >= 0 && i.sessionDialog.cursor < len(i.sessionDialog.sessions) && i.cfg.CurrentSessionPath != nil {
211+ manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
212+ }
213+ i.mu.Unlock()
214+ manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
215+ if manualRenameCurrent {
216+ i.markSessionTitleSwitching()
217+ }
218+ i.mu.Lock()
219+ act := i.sessionDialog.HandleKey(k)
220+ if act.Select || act.Close {
221+ i.sessionLoads = nil
222+ }
223+ i.mu.Unlock()
224+ if act.Select {
225+ i.applySessionSelection(act.Path)
226+ }
227+ if act.Err != nil {
228+ if manualRenameCurrent {
229+ i.restoreFailedSessionTitle()
230+ }
231+ i.mu.Lock()
232+ i.statusErr = "rename: " + act.Err.Error()
233+ i.statusOK = ""
234+ i.mu.Unlock()
235+ } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
236+ i.setManualSessionTitle(act.RenameTitle)
237+ }
238+ // Always request a redraw after handling a key here: when esc
239+ // closes the picker, the overlay-close detection in the render
240+ // pass needs to run so the tall dialog rows get repainted from
241+ // the chat (otherwise VS Code's retained scrollback leaves a
242+ // duplicate frame on screen).
243+ i.invalidate()
244+ return false
245+ }
246+ i.mu.Lock()
247+ residentDialog := i.residentSubagentsDialog
248+ residentDialogActive := residentDialog != nil && residentDialog.Active()
249+ residentSession := i.residentChildSession
250+ i.mu.Unlock()
251+ if residentDialogActive {
252+ if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
253+ i.mu.Lock()
254+ if i.residentSubagentsDialog == residentDialog {
255+ residentDialog.Close()
256+ }
257+ i.mu.Unlock()
258+ i.invalidate()
259+ return false
260+ }
261+ i.mu.Lock()
262+ childID := residentDialog.HandleKey(k)
263+ i.mu.Unlock()
264+ if childID != "" {
265+ i.openResidentChildSession(childID)
266+ }
267+ i.invalidate()
268+ return false
269+ }
270+ if residentSession != nil {
271+ switch k.Kind {
272+ case tui.KeyCtrlC, tui.KeyEsc:
273+ i.mu.Lock()
274+ if i.residentChildSession == residentSession {
275+ i.residentChildSession = nil
276+ }
277+ i.mu.Unlock()
278+ case tui.KeyPageUp:
279+ session := residentSession
280+ if session.BeginLoad() {
281+ go func() {
282+ err := session.LoadOlder(200)
283+ if session.FinishLoad(err) {
284+ i.reloadResidentChildSession(session)
285+ }
286+ i.invalidate()
287+ }()
288+ }
289+ case tui.KeyPageDown:
290+ residentSession.FollowTail()
291+ case tui.KeyUp:
292+ residentSession.Scroll(1)
293+ case tui.KeyDown:
294+ residentSession.Scroll(-1)
295+ default:
296+ session := residentSession
297+ if prompt, submit := session.HandleKey(k); submit {
298+ go func() {
299+ err := i.cfg.ResidentManager.Resume(ctx, session.childID, prompt)
300+ session.FinishSubmission(err)
301+ i.invalidate()
302+ }()
303+ }
304+ }
305+ i.invalidate()
306+ return false
307+ }
308+ if i.logoutDialog.Active() {
309+ if k.Kind == tui.KeyCtrlC {
310+ i.logoutDialog.Close()
311+ i.invalidate()
312+ return false
313+ }
314+ act := i.logoutDialog.HandleKey(k)
315+ if act.Select {
316+ i.doLogout(act.Target)
317+ }
318+ i.invalidate()
319+ return false
320+ }
321+ if i.telegramDialog.Active() {
322+ if k.Kind == tui.KeyCtrlC {
323+ i.telegramDialog.Close()
324+ i.invalidate()
325+ return false
326+ }
327+ act := i.telegramDialog.HandleKey(k)
328+ if act.Select {
329+ i.doTelegram(act.Action)
330+ }
331+ i.invalidate()
332+ return false
333+ }
334+ if i.settingsDialog.Active() {
335+ if k.Kind == tui.KeyCtrlC {
336+ i.settingsDialog.Close()
337+ i.invalidate()
338+ return false
339+ }
340+ act := i.settingsDialog.HandleKey(k)
341+ if act.ModelShortcutSlot > 0 {
342+ i.openQuickModelPicker(act.ModelShortcutSlot)
343+ }
344+ if act.Toggle {
345+ i.applySettingChange(act)
346+ }
347+ i.invalidate()
348+ return false
349+ }
350+ if i.sessionOpsDialog.Active() {
351+ if k.Kind == tui.KeyCtrlC {
352+ i.sessionOpsDialog.Close()
353+ i.invalidate()
354+ return false
355+ }
356+ act := i.sessionOpsDialog.HandleKey(k)
357+ if act.Select {
358+ i.doSessionOp(act.Action, "")
359+ }
360+ i.invalidate()
361+ return false
362+ }
363+ if i.sessionTreeDialog.Active() {
364+ if k.Kind == tui.KeyCtrlC {
365+ i.sessionTreeDialog.Close()
366+ i.invalidate()
367+ return false
368+ }
369+ act := i.sessionTreeDialog.HandleKey(k)
370+ if act.Select {
371+ if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
372+ i.applySessionTreeTarget(act.Target, act.TurnNo)
373+ } else {
374+ // Keep package embedders that still return the scalar action
375+ // source-compatible while the dialog migrates to Target.
376+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
377+ }
378+ }
379+ i.invalidate()
380+ return false
381+ }
382+ if i.extPanel.Active() {
383+ if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
384+ if i.cfg.Extensions != nil {
385+ _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
386+ }
387+ i.extPanel.Close()
388+ i.invalidate()
389+ return false
390+ }
391+ if i.cfg.Extensions != nil {
392+ _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
393+ }
394+ return false
395+ }
396+ if i.jumpDialog.Active() {
397+ if k.Kind == tui.KeyCtrlC {
398+ i.jumpDialog.Close()
399+ i.pendingFork = false
400+ return false
401+ }
402+ act := i.jumpDialog.HandleKey(k)
403+ if act.Select {
404+ if i.pendingFork {
405+ i.applyForkSelection(act.MessageIdx)
406+ } else {
407+ i.applyJumpSelection(act.MessageIdx, act.TurnNo)
408+ }
409+ }
410+ // If the user dismissed the dialog without selecting, also
411+ // clear the pending-fork flag so a later plain /jump isn't
412+ // hijacked.
413+ if act.Close {
414+ i.pendingFork = false
415+ }
416+ return false
417+ }
418+ if i.btwDialog.Active() {
419+ if k.Kind == tui.KeyCtrlC {
420+ i.btwDialog.Close()
421+ i.invalidate()
422+ return false
423+ }
424+ if k.Kind == tui.KeyCtrlO {
425+ i.toggleBtwToolExpansion()
426+ return false
427+ }
428+ i.btwDialog.HandleKey(k, i.invalidate)
429+ return false
430+ }
431+ if i.skillsDialog.Active() {
432+ if k.Kind == tui.KeyCtrlC {
433+ i.skillsDialog.Close()
434+ i.invalidate()
435+ return false
436+ }
437+ i.skillsDialog.HandleKey(k)
438+ i.invalidate()
439+ return false
440+ }
441+ if i.changelogDialog.Active() {
442+ if closed := i.changelogDialog.HandleKey(k); closed {
443+ // User dismissed; let the parent persist the
444+ // LastChangelogShown marker via the close callback.
445+ if i.cfg.OnChangelogDismiss != nil {
446+ i.cfg.OnChangelogDismiss()
447+ }
448+ }
449+ i.invalidate()
450+ return false
451+ }
452+
453+ if slot := quickModelShortcutSlot(k); slot > 0 {
454+ i.applyQuickModelShortcut(slot)
455+ return false
456+ }
457+
458+ // The first eligible bare Escape is deliberately left to the existing
459+ // Escape path (which clears idle input or cancels a turn). A second tap
460+ // within the gesture window only gets first refusal while the cheap,
461+ // local eligibility rules still hold. If a turn started, a message was
462+ // queued, or the input became ineligible between taps, reset the detector
463+ // and fall through so Escape retains its normal cancellation behavior.
464+ // Once those rules still hold, the full tree gate owns the second tap:
465+ // family/read failures are fail-closed but are nevertheless consumed so
466+ // they cannot fall through into ordinary editor handling.
467+ if isUnmodifiedEscape(k) {
468+ now := i.sessionTreeEscapeNow()
469+ i.mu.Lock()
470+ consumed := i.doubleEscape.Consume(now)
471+ i.mu.Unlock()
472+ if consumed {
473+ if i.canArmSessionTreeEscape() {
474+ i.openSessionTree()
475+ return false
476+ }
477+ i.mu.Lock()
478+ i.doubleEscape.Reset()
479+ i.mu.Unlock()
480+ } else if i.canArmSessionTreeEscape() {
481+ i.mu.Lock()
482+ i.doubleEscape.Arm(now)
483+ i.mu.Unlock()
484+ }
485+ }
486+
487+ // Global keys.
488+ switch k.Kind {
489+ case tui.KeyCtrlC:
490+ i.mu.Lock()
491+ loadingSession := i.sessionLoading
492+ i.mu.Unlock()
493+ if loadingSession {
494+ i.markCtrlCExit()
495+ return true
496+ }
497+ // While busy: do NOT cancel the turn. ctrl+c during a
498+ // running turn is almost always reflex muscle memory
499+ // ("be quiet" in a shell) rather than a deliberate
500+ // decision to kill a multi-minute model call that's
501+ // already cost tokens. Use esc to interrupt a turn; use
502+ // a deliberate double-ctrl+c to exit zut entirely. First
503+ // press arms the exit hint, second press within
504+ // ctrlCExitWindow quits.
505+ if i.busy {
506+ if i.ctrlCExitArmed() {
507+ i.markCtrlCExit()
508+ return true
509+ }
510+ i.mu.Lock()
511+ i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
512+ i.statusErr = ""
513+ i.mu.Unlock()
514+ i.armCtrlCExit()
515+ return false
516+ }
517+ // Idle: first press clears the editor (and any queued
518+ // follow-up messages); a second press within ctrlCExitWindow
519+ // exits. With both an empty editor and no queue the first
520+ // press still just arms — require a deliberate double-tap.
521+ ag := i.agent
522+ pending := 0
523+ if ag != nil {
524+ pending = ag.QueuedMessageCount()
525+ }
526+ hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
527+ if hadInput {
528+ i.ed.Clear()
529+ i.clipboardImages = nil
530+ i.suggest.Reset()
531+ if ag != nil {
532+ ag.DrainQueuedMessages()
533+ }
534+ i.mu.Lock()
535+ i.queued = nil
536+ i.statusOK = "input cleared"
537+ i.statusErr = ""
538+ i.mu.Unlock()
539+ i.armCtrlCExit()
540+ return false
541+ }
542+ if i.ctrlCExitArmed() {
543+ i.markCtrlCExit()
544+ return true
545+ }
546+ i.mu.Lock()
547+ i.statusOK = "press ctrl+c again to exit"
548+ i.statusErr = ""
549+ i.mu.Unlock()
550+ i.armCtrlCExit()
551+ return false
552+ case tui.KeyEsc:
553+ // Esc interrupts a running turn — but only when nothing
554+ // else on screen wants to consume the key first. The slash
555+ // popup has its own Esc behaviour (close + clear editor),
556+ // and transient overlays like the /help block and extension
557+ // notes should dismiss on Esc before we even consider the
558+ // turn. Without these guards, a casual Esc press after
559+ // running /help on a busy turn rips the turn away.
560+ if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
561+ break
562+ }
563+ i.mu.Lock()
564+ hadHelp := len(i.helpBlock) > 0
565+ hadNotes := len(i.extNotes) > 0
566+ if hadHelp {
567+ i.helpBlock = nil
568+ }
569+ if hadNotes {
570+ i.extNotes = nil
571+ }
572+ i.mu.Unlock()
573+ if hadHelp || hadNotes {
574+ i.invalidate()
575+ return false
576+ }
577+ i.mu.Lock()
578+ busyCancel := i.busy && i.cancelTurn != nil
579+ cancelTurn := i.cancelTurn
580+ var restoreQueued string
581+ var handoff json.RawMessage
582+ var persistHandoff bool
583+ if busyCancel {
584+ // Keep the most recently queued follow-up as an editable draft
585+ // instead of losing it with the cancelled turn's stale queue.
586+ if i.agent != nil {
587+ restoreQueued, _ = i.agent.PopQueuedMessage()
588+ }
589+ if restoreQueued == "" && len(i.queued) > 0 {
590+ n := len(i.queued) - 1
591+ restoreQueued = i.queued[n]
592+ i.queued = i.queued[:n]
593+ }
594+ handoff, persistHandoff = i.resetCompactContinuationLocked()
595+ }
596+ i.mu.Unlock()
597+ if busyCancel {
598+ if restoreQueued != "" {
599+ i.ed.SetValue(restoreQueued)
600+ i.inputHistoryIndex = -1
601+ }
602+ i.updateActiveGoal(core.GoalPaused, "interrupted by user")
603+ cancelTurn()
604+ // If a confirm dialog is pending, refuse it so the agent
605+ // goroutine unblocks and the context cancellation can
606+ // actually take effect.
607+ i.confirmDialog.CancelAll("turn cancelled")
608+ if persistHandoff {
609+ i.persistCompactHandoff(handoff)
610+ }
611+ return false
612+ }
613+ case tui.KeyCtrlD:
614+ if i.ed.IsEmpty() && !i.busy {
615+ return true
616+ }
617+ case tui.KeyCtrlB:
618+ i.mu.Lock()
619+ i.rightBarHidden = !i.rightBarHidden
620+ i.mu.Unlock()
621+ i.requestRendererInvalidate()
622+ i.invalidate()
623+ return false
624+ case tui.KeyCtrlL:
625+ i.requestRendererClear()
626+ i.invalidate()
627+ return false
628+ case tui.KeyPasteClipboard:
629+ i.pasteClipboard(ctx)
630+ return false
631+ case tui.KeyCtrlO:
632+ i.toggleToolExpansion()
633+ return false
634+ case tui.KeyPageUp:
635+ i.scrollBy(+i.chatPage())
636+ return false
637+ case tui.KeyPageDown:
638+ i.scrollBy(-i.chatPage())
639+ return false
640+ case tui.KeyUp:
641+ // Alt/Option+Up: pop the most recently queued ("sliding in")
642+ // message back into the editor so the user can edit and
643+ // resend it. Repeated presses keep peeling messages off the
644+ // tail of the queue; each press *replaces* the editor
645+ // contents (we don't append/push). When the queue is empty
646+ // the keypress falls through to the normal scroll behavior.
647+ if k.Alt {
648+ i.mu.Lock()
649+ var text string
650+ if i.agent != nil {
651+ text, _ = i.agent.PopQueuedMessage()
652+ }
653+ if text == "" {
654+ if n := len(i.queued); n > 0 {
655+ text = i.queued[n-1]
656+ i.queued = i.queued[:n-1]
657+ }
658+ }
659+ i.mu.Unlock()
660+ if text != "" {
661+ i.ed.SetValue(text)
662+ i.inputHistoryIndex = -1
663+ i.invalidate()
664+ return false
665+ }
666+ }
667+ // In multi-line / wrapped input, Up first moves inside the editor.
668+ // At the editor's top edge, plain Up can browse input history when
669+ // history browsing is safe/active; otherwise it falls back to chat
670+ // scrolling, preserving the old single-line scroll behavior.
671+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
672+ if i.ed.MoveVertical(-1) {
673+ i.invalidate()
674+ return false
675+ }
676+ if !k.Alt && i.handleInputHistoryKey(k) {
677+ return false
678+ }
679+ i.scrollBy(+3)
680+ return false
681+ }
682+ case tui.KeyDown:
683+ if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
684+ if i.ed.MoveVertical(+1) {
685+ i.invalidate()
686+ return false
687+ }
688+ if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil {
689+ if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 {
690+ i.runResidentSubagents(ctx, nil)
691+ return false
692+ }
693+ }
694+ if !k.Alt && i.handleInputHistoryKey(k) {
695+ return false
696+ }
697+ if i.scrollOffset > 0 {
698+ i.scrollBy(-3)
699+ }
700+ return false
701+ }
702+ }
703+
704+ // Note: we intentionally do NOT gate the editor on i.busy here.
705+ // Typing while the agent is working is supported — submitted
706+ // messages are queued and delivered as follow-up turns when the
707+ // current turn ends. See the submit handler below.
708+
709+ if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
710+ i.ed.HandleKey(k)
711+ return false
712+ }
713+
714+ // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
715+ if i.suggest.Active(i.ed.Value()) {
716+ switch k.Kind {
717+ case tui.KeyUp:
718+ i.suggest.Up()
719+ return false
720+ case tui.KeyDown:
721+ i.suggest.Down()
722+ return false
723+ case tui.KeyPageUp:
724+ i.suggest.PageUp()
725+ return false
726+ case tui.KeyPageDown:
727+ i.suggest.PageDown()
728+ return false
729+ case tui.KeyTab:
730+ if name := i.suggest.Selection(i.ed.Value()); name != "" {
731+ i.ed.SetValue(name)
732+ i.suggest.Reset()
733+ }
734+ return false
735+ case tui.KeyEnter:
736+ // Enter on an ambiguous or partial slash prefix: complete to the
737+ // currently highlighted command and run it. That way typing
738+ // "/lo" + enter picks whichever of /login or /logout is selected
739+ // in the popup instead of submitting "/lo" as unknown. Also
740+ // clear the editor so the command doesn't linger after the
741+ // dialog opens/closes.
742+ if name := i.suggest.Selection(i.ed.Value()); name != "" {
743+ i.ed.Clear()
744+ i.suggest.Reset()
745+ return i.runSlash(ctx, name)
746+ }
747+ case tui.KeyEsc:
748+ i.ed.Clear()
749+ i.suggest.Reset()
750+ return false
751+ }
752+ }
753+
754+ // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
755+ if i.fileSuggest.Active(i.ed.Value()) {
756+ switch k.Kind {
757+ case tui.KeyUp:
758+ i.fileSuggest.Up()
759+ return false
760+ case tui.KeyDown:
761+ i.fileSuggest.Down()
762+ return false
763+ case tui.KeyRight:
764+ // Open selected directory. The filter the user typed picked
765+ // that directory at the current level; once we descend it no
766+ // longer applies to the directory's contents, so clear it.
767+ // Otherwise typing "@eda" then right would re-filter inside
768+ // eda/ by "eda" and show nothing.
769+ if i.fileSuggest.Right() {
770+ i.clearFileSuggestQuery()
771+ }
772+ return false
773+ case tui.KeyLeft:
774+ // Go back to parent directory. Clear the filter for the same
775+ // reason as Right: it was scoped to the level we just left.
776+ if i.fileSuggest.Left() {
777+ i.clearFileSuggestQuery()
778+ }
779+ return false
780+ case tui.KeyEnter:
781+ if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
782+ var chip string
783+ if entry.isDir {
784+ chip = "[dir:" + entry.rel + "/]"
785+ } else {
786+ chip = "[file:" + entry.rel + "]"
787+ }
788+ val := i.ed.Value()
789+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
790+ val = val[:idx]
791+ }
792+ i.ed.SetValue(val + chip + " ")
793+ i.fileSuggest.Reset()
794+ }
795+ return false
796+ case tui.KeyEsc:
797+ val := i.ed.Value()
798+ if idx := strings.LastIndex(val, "@"); idx >= 0 {
799+ i.ed.SetValue(val[:idx])
800+ }
801+ i.fileSuggest.Reset()
802+ return false
803+ }
804+ }
805+
806+ // Tab-complete a path token in the editor when no popup is open.
807+ // Recognises tokens that look like paths (start with ~, /, ./, ../
808+ // or contain a slash); shell-style completion expands ~, lists the
809+ // parent dir, and completes the basename to the longest common
810+ // prefix. Single match: full replace and trailing / for dirs.
811+ // No match: no-op. Plain bare words (no slash, no tilde) fall
812+ // through so Tab keeps its current no-op behaviour outside paths.
813+ if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
814+ if i.tryPathTabComplete() {
815+ return false
816+ }
817+ }
818+
819+ if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
820+ i.inputHistoryIndex = -1
821+ }
822+
823+ if k.Kind == tui.KeyEsc {
824+ i.clipboardImages = nil
825+ }
826+ if submit := i.ed.HandleKey(k); submit {
827+ // SubmitValue() expands any [pasted text #N +L lines]
828+ // placeholders back into their bodies; the raw Value()
829+ // is only what the user sees on screen.
830+ text := strings.TrimRight(i.ed.SubmitValue(), "\n")
831+ // Expand [file:name] and [dir:name/] chips to full paths.
832+ text = expandFileChips(text, i.cfg.CWD)
833+ text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
834+ if text == "" && len(images) == 0 {
835+ return false
836+ }
837+ clearSubmittedInput := func() {
838+ i.clipboardImages = nil
839+ i.ed.Clear()
840+ i.inputHistoryIndex = -1
841+ i.suggest.Reset()
842+ i.fileSuggest.Reset()
843+ }
844+
845+ // Shell escapes and slash commands are text-only. An image-bearing
846+ // draft remains a normal provider prompt instead of silently dropping
847+ // its attachments.
848+ if len(images) == 0 {
849+ if cmd, ok := shellEscapeCommand(text); ok {
850+ clearSubmittedInput()
851+ i.startShellEscape(ctx, cmd)
852+ return false
853+ }
854+ }
855+
856+ if len(images) == 0 && looksLikeSlashCommand(text) {
857+ clearSubmittedInput()
858+ head := text
859+ rest := ""
860+ if idx := strings.IndexAny(text, " \t"); idx >= 0 {
861+ head = text[:idx]
862+ rest = strings.TrimSpace(text[idx:])
863+ }
864+ if !isKnownSlashCommand(text) {
865+ // Try extensions before giving up. Extensions register
866+ // commands by bare name (no leading slash); strip it here.
867+ extName := strings.TrimPrefix(head, "/")
868+ if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
869+ go i.invokeExtensionCommand(ctx, extName, rest)
870+ return false
871+ }
872+ i.mu.Lock()
873+ i.statusErr = "unknown command " + head + " — type /help to see the list"
874+ i.statusOK = ""
875+ i.mu.Unlock()
876+ return false
877+ }
878+ // Slash commands run regardless of busy state. Commands that
879+ // would mutate the transcript or replace the agent (/clear,
880+ // /compact, /logout, /login, /model, /fork, /session fork)
881+ // cancel the active turn
882+ // first and wait for the goroutine to wind down so they don't
883+ // race with a streaming response. Safe commands (/help,
884+ // /jump, /sessions, /jail, /unjail, /exit) run immediately
885+ // without disturbing the active turn.
886+ if slashCommandCancelsTurn(text) {
887+ i.cancelAndWaitForIdle()
888+ }
889+ return i.runSlash(ctx, text)
890+ }
891+
892+ if i.agent == nil {
893+ i.mu.Lock()
894+ i.statusErr = "not logged in. type /login first."
895+ i.mu.Unlock()
896+ return false
897+ }
898+ // Mirror the user's typed prompt into the paired Telegram
899+ // chat (when the bridge is active) so the Telegram thread
900+ // stays a complete record of the session, not just the half
901+ // that originated on the phone. On a goroutine so the
902+ // network write doesn't delay the local turn.
903+ if i.telegramBridge != nil && i.telegramBridge.Active() {
904+ go i.telegramBridge.OnUserTyped(text)
905+ }
906+ i.maybeStartSessionTitle(ctx, text)
907+ // If a turn is already in flight, queue this prompt inside the
908+ // agent loop so it is delivered at the next safe model-call
909+ // boundary instead of waiting for the whole run to finish.
910+ i.mu.Lock()
911+ busy := i.busy
912+ compacting := i.compacting
913+ ag := i.agent
914+ i.mu.Unlock()
915+ if busy {
916+ if len(images) > 0 {
917+ i.mu.Lock()
918+ i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
919+ i.mu.Unlock()
920+ i.invalidate()
921+ return false
922+ }
923+ clearSubmittedInput()
924+ var handoff json.RawMessage
925+ var persistHandoff bool
926+ if ag != nil && !compacting {
927+ i.mu.Lock()
928+ handoff, persistHandoff = i.resetCompactContinuationLocked()
929+ ag.QueueMessage(text)
930+ i.mu.Unlock()
931+ } else {
932+ i.mu.Lock()
933+ handoff, persistHandoff = i.resetCompactContinuationLocked()
934+ i.queued = append(i.queued, text)
935+ i.mu.Unlock()
936+ }
937+ if persistHandoff {
938+ i.persistCompactHandoff(handoff)
939+ }
940+ i.invalidate()
941+ return false
942+ }
943+ clearSubmittedInput()
944+ i.startTurnWithImages(ctx, text, images)
945+ }
946+ return false
947+}
948+func (i *Interactive) pasteClipboard(ctx context.Context) {
949+ image, ok, _ := tui.ReadClipboardImage(ctx)
950+ if ok {
951+ i.mu.Lock()
952+ marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
953+ i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
954+ Marker: marker,
955+ Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
956+ })
957+ i.ed.Insert(marker + " ")
958+ i.statusOK = ""
959+ i.statusErr = ""
960+ i.mu.Unlock()
961+ return
962+ }
963+ key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
964+ i.mu.Lock()
965+ defer i.mu.Unlock()
966+ if err != nil {
967+ i.statusErr = "clipboard paste failed: " + err.Error()
968+ i.statusOK = ""
969+ return
970+ }
971+ if pastedText {
972+ i.ed.HandleKey(key)
973+ i.statusErr = ""
974+ i.statusOK = ""
975+ return
976+ }
977+ i.statusErr = "clipboard does not contain text or an image"
978+ i.statusOK = ""
979+}
980+func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
981+ if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
982+ return false
983+ }
984+ // Do not steal normal vertical cursor movement. History browsing can only
985+ // start from an empty editor; once active, Up/Down keep walking
986+ // the ring so repeated presses work even though the editor now
987+ // contains the selected historical prompt.
988+ if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
989+ return false
990+ }
991+ hist := i.inputHistory()
992+ if len(hist) == 0 {
993+ return false
994+ }
995+
996+ if i.inputHistoryIndex < 0 {
997+ // Start just after the newest item so Up lands on the most
998+ // recent user prompt. A lone Down from an empty editor is not
999+ // history navigation; let the caller fall through to normal UI
1000+ // behavior instead.
1001+ if k.Kind != tui.KeyUp {
1002+ return false
1003+ }
1004+ i.inputHistoryIndex = len(hist)
1005+ }
1006+
1007+ switch k.Kind {
1008+ case tui.KeyUp:
1009+ if i.inputHistoryIndex > 0 {
1010+ i.inputHistoryIndex--
1011+ }
1012+ case tui.KeyDown:
1013+ if i.inputHistoryIndex < len(hist) {
1014+ i.inputHistoryIndex++
1015+ }
1016+ }
1017+
1018+ if i.inputHistoryIndex >= len(hist) {
1019+ i.ed.Clear()
1020+ } else {
1021+ i.ed.SetValue(hist[i.inputHistoryIndex])
1022+ }
1023+ return true
1024+}
1025+func (i *Interactive) inputHistory() []string {
1026+ if i.agent == nil {
1027+ return nil
1028+ }
1029+ msgs := i.agent.Messages()
1030+ hist := make([]string, 0, len(msgs))
1031+ for _, m := range msgs {
1032+ if !isForkableUserMessage(m) {
1033+ continue
1034+ }
1035+ text := userMessageText(m)
1036+ if strings.TrimSpace(text) == "" {
1037+ continue
1038+ }
1039+ hist = append(hist, text)
1040+ }
1041+ return hist
1042+}
1043+func userMessageText(m provider.Message) string {
1044+ var sb strings.Builder
1045+ for _, c := range m.Content {
1046+ if tb, ok := c.(provider.TextBlock); ok {
1047+ if sb.Len() > 0 {
1048+ sb.WriteByte('\n')
1049+ }
1050+ sb.WriteString(tb.Text)
1051+ }
1052+ }
1053+ return sb.String()
1054+}
M packages/agent/modes/interactive_render.go +41 -0
@@ -99,3 +99,44 @@ func (s *latestFrameScheduler) stop() {
9999 s.mu.Unlock()
100100 <-s.done
101101 }
102+
103+func (i *Interactive) requestRendererClear() {
104+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
105+ scheduler.request(true, false)
106+ return
107+ }
108+ if i.rend != nil {
109+ i.rend.Clear()
110+ }
111+}
112+
113+func (i *Interactive) requestRendererInvalidate() {
114+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
115+ scheduler.request(false, true)
116+ return
117+ }
118+ if i.rend != nil {
119+ i.rend.Invalidate()
120+ }
121+}
122+
123+func (i *Interactive) requestRendererTheme(theme tui.Theme) {
124+ if scheduler := i.renderScheduler.Load(); scheduler != nil {
125+ scheduler.requestTheme(theme)
126+ return
127+ }
128+ if i.rend != nil {
129+ i.rend.SetTheme(theme)
130+ }
131+}
132+
133+func (i *Interactive) invalidate() {
134+ i.renderRevision.Add(1)
135+ // Keep ordinary state changes on the main-loop throttle. The scheduler
136+ // owns terminal output, but scheduling it here would bypass
137+ // redrawMinInterval and paint every intermediate tool-event frame.
138+ select {
139+ case i.dirty <- struct{}{}:
140+ default:
141+ }
142+}
A packages/agent/modes/interactive_rendering.go +1234 -0
@@ -0,0 +1,1234 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "sort"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/zut/packages/agent/extproto"
10+ "github.com/bnema/zut/packages/agent/skills"
11+ "github.com/bnema/zut/packages/provider"
12+ "github.com/bnema/zut/packages/tui"
13+)
14+
15+func (i *Interactive) cachedChatLocked(cols int) []string {
16+ // A busy frame contains mutable streaming/tool state. Keep the stable
17+ // transcript cache, but never return a full-frame cache entry that would
18+ // hide a newly arrived delta or completed result.
19+ if i.busy || i.streamOn || i.streamFlushPending {
20+ return i.buildChatLocked(cols)
21+ }
22+ key := i.chatCacheKeyLocked(cols)
23+ if i.chatCacheValid && i.chatCacheKey == key {
24+ return append([]string(nil), i.chatCache...)
25+ }
26+ chat := i.buildChatLocked(cols)
27+ key = i.chatCacheKeyLocked(cols)
28+ i.chatCache = append(i.chatCache[:0], chat...)
29+ i.chatCacheKey = key
30+ i.chatCacheValid = true
31+ return chat
32+}
33+func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
34+ var rev uint64
35+ if i.agent != nil {
36+ rev = i.agent.Revision()
37+ }
38+ showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
39+ return chatCacheKey{
40+ cols: cols,
41+ agentRev: rev,
42+ statusOK: i.statusOK,
43+ statusErr: i.statusErr,
44+ help: strings.Join(i.helpBlock, "\n"),
45+ extNotes: strings.Join(i.extNotes, "\n"),
46+ extStatuses: i.extensionStatusesKeyLocked(),
47+ extWidgets: i.extensionWidgetsKeyLocked(),
48+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
49+ updateAvailable: i.updateInfo.Available,
50+ updateCurrent: i.updateInfo.Current,
51+ updateLatest: i.updateInfo.Latest,
52+ updateURL: i.updateInfo.URL,
53+ welcomeShowVer: showVer,
54+ expandAll: i.view.ExpandAll,
55+ tailLimit: i.view.TailLimit,
56+ renderedMessageCount: len(i.view.Messages),
57+ viewCacheRev: i.view.RenderCacheRevision,
58+ }
59+}
60+func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
61+ keys := make([]string, 0, len(groups))
62+ for key := range groups {
63+ keys = append(keys, key)
64+ }
65+ sort.Strings(keys)
66+ return keys
67+}
68+func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
69+ items := groups[outer]
70+ keys := make([]string, 0, len(items))
71+ for key := range items {
72+ keys = append(keys, key)
73+ }
74+ sort.Strings(keys)
75+ return keys
76+}
77+func (i *Interactive) extensionStatusesKeyLocked() string {
78+ var sb strings.Builder
79+ extNames := sortedNestedOuterKeys(i.extStatuses)
80+ for _, extName := range extNames {
81+ keys := sortedNestedInnerKeys(i.extStatuses, extName)
82+ for _, key := range keys {
83+ status := i.extStatuses[extName][key]
84+ fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
85+ }
86+ }
87+ return sb.String()
88+}
89+func (i *Interactive) extensionWidgetsKeyLocked() string {
90+ var sb strings.Builder
91+ extNames := sortedNestedOuterKeys(i.extWidgets)
92+ for _, extName := range extNames {
93+ ids := sortedNestedInnerKeys(i.extWidgets, extName)
94+ for _, id := range ids {
95+ widget := i.extWidgets[extName][id]
96+ fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
97+ for _, line := range widget.Lines {
98+ sb.WriteString(line)
99+ sb.WriteByte('\n')
100+ }
101+ }
102+ }
103+ return sb.String()
104+}
105+func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
106+ var out []tui.RightBarWidget
107+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
108+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
109+ widget := i.extWidgets[extName][id]
110+ if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
111+ continue
112+ }
113+ out = append(out, tui.RightBarWidget{
114+ Extension: extName,
115+ ID: id,
116+ Title: widget.Title,
117+ Lines: append([]string(nil), widget.Lines...),
118+ })
119+ }
120+ }
121+ return out
122+}
123+func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
124+ return i.extensionChromeLinesAtLocked(cols, false)
125+}
126+func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
127+ lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
128+ if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
129+ return lines
130+ }
131+ limit := maxNarrowExtensionChromeRows
132+ trimmed := append([]string(nil), lines[:limit-1]...)
133+ trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
134+ return trimmed
135+}
136+func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
137+ var out []string
138+ bodyWidth := cols - 2
139+ if bodyWidth < 1 {
140+ bodyWidth = 1
141+ }
142+ widgetRows := 0
143+ widgetTruncated := false
144+ appendWidgetRow := func(line string) bool {
145+ if widgetRows >= maxExtensionWidgetRows-1 {
146+ widgetTruncated = true
147+ return false
148+ }
149+ out = append(out, line)
150+ widgetRows++
151+ return true
152+ }
153+
154+ stopWidgets := false
155+ for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
156+ if stopWidgets {
157+ break
158+ }
159+ for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
160+ widget := i.extWidgets[extName][id]
161+ if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
162+ continue
163+ }
164+ label := " [" + extName + "]"
165+ if widget.Title != "" {
166+ label += " " + widget.Title
167+ }
168+ if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
169+ stopWidgets = true
170+ break
171+ }
172+ for _, line := range widget.Lines {
173+ if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
174+ stopWidgets = true
175+ break
176+ }
177+ }
178+ if stopWidgets {
179+ break
180+ }
181+ }
182+ }
183+ if widgetTruncated {
184+ out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
185+ }
186+
187+ statusRows := 0
188+ statusTruncated := false
189+ for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
190+ for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
191+ if statusRows >= maxExtensionStatusRows-1 {
192+ statusTruncated = true
193+ break
194+ }
195+ status := i.extStatuses[extName][key]
196+ color := i.cfg.Theme.Muted
197+ switch status.Level {
198+ case "warn":
199+ color = i.cfg.Theme.Warning
200+ case "error":
201+ color = i.cfg.Theme.Error
202+ case "success":
203+ color = i.cfg.Theme.Tool
204+ }
205+ label := " [" + extName + "] " + status.Text
206+ out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
207+ statusRows++
208+ }
209+ if statusTruncated {
210+ break
211+ }
212+ }
213+ if statusTruncated {
214+ out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
215+ }
216+ return out
217+}
218+func (i *Interactive) stableChatRowsLocked(cols int) []string {
219+ key := i.chatCacheKeyLocked(cols)
220+ if i.stableChatCacheValid && i.stableChatCacheKey == key {
221+ return append([]string(nil), i.stableChatCache...)
222+ }
223+
224+ renderView := i.view.CloneForRender()
225+ renderView.Streaming = ""
226+ renderView.StreamingActive = false
227+ renderView.ToolCalls = nil
228+ renderView.Err = ""
229+ if !i.renderOutsideLock {
230+ rows := renderView.Build(cols)
231+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
232+ i.stableChatCacheKey = key
233+ i.stableChatCacheValid = true
234+ return append([]string(nil), rows...)
235+ }
236+ // Markdown, syntax highlighting, and wrapping are the expensive part of
237+ // a frame. Build the immutable snapshot without holding the interactive
238+ // mutex so key processing can continue while a cold transcript renders.
239+ i.mu.Unlock()
240+ rows := renderView.Build(cols)
241+ i.mu.Lock()
242+ if i.view.MessagesRevision == renderView.MessagesRevision &&
243+ i.view.RenderCacheRevision == renderView.RenderCacheRevision {
244+ i.view.AdoptRenderCacheFrom(renderView)
245+ }
246+
247+ i.stableChatCache = append(i.stableChatCache[:0], rows...)
248+ i.stableChatCacheKey = key
249+ i.stableChatCacheValid = true
250+ return append([]string(nil), rows...)
251+}
252+func (i *Interactive) buildChatLocked(cols int) []string {
253+ if i.agent != nil {
254+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
255+ i.view.MessagesRevision = i.agent.Revision()
256+ } else {
257+ i.view.Messages = nil
258+ i.view.MessagesRevision = 0
259+ }
260+ // Pacer flush: while the streaming pacer is still draining the
261+ // buffer (i.e. EvAssistantMessage already fired but more runes
262+ // are queued), the final assistant message is already in
263+ // i.agent.Messages() in full. Painting it in the transcript
264+ // AND the streaming block at the same time shows the user the
265+ // complete text immediately — which defeats the whole pacer.
266+ // Hide the last message until the pacer catches up; once the
267+ // flush-pending latch clears, the message is revealed (the
268+ // streaming block disappears the same frame because streamOn
269+ // flips off, so the transition is seamless).
270+ if i.streamFlushPending && len(i.view.Messages) > 0 {
271+ i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
272+ }
273+ i.view.Streaming = i.streaming.String()
274+ i.view.StreamingActive = i.streamOn
275+ // Guard against the narrow race where EvAssistantMessage has
276+ // just promoted a streaming reply into the transcript but a
277+ // render tick hasn't flipped streamOn off yet. Without the
278+ // guard, the same text would appear twice (once as the
279+ // in-flight streaming block, once as the last transcript
280+ // message). We detect the duplicate strictly: the last
281+ // assistant message's visible text must equal the streaming
282+ // buffer. Just matching on role is too broad — it also hides
283+ // the next round's typewriter streaming after a tool turn,
284+ // because the last transcript message is always assistant
285+ // (the tool-use block) until the follow-up summary lands.
286+ if i.streamOn && i.streaming.Len() > 0 {
287+ if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
288+ if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
289+ i.view.StreamingActive = false
290+ }
291+ }
292+ }
293+ // Live tool-call view: only shown while a turn is in flight. Once
294+ // the agent is idle, every tool call has already been folded into
295+ // the transcript (as assistant.ToolCallBlock + a tool-role message),
296+ // so showing v.ToolCalls a second time would duplicate them below
297+ // the final assistant text — which looks like the summary came
298+ // "before" the tools.
299+ i.view.ToolCalls = i.view.ToolCalls[:0]
300+ if i.busy {
301+ for _, id := range i.toolOrder {
302+ // Deterministic ordering: a tool block stays hidden until
303+ // the paced assistant text that preceded it has finished
304+ // typing out. toolOrder is append-only in arrival order,
305+ // so once one tool is still gated, every later tool is too,
306+ // so stop here to avoid showing a tool out of sequence.
307+ if !i.toolGateOpenLocked(id) {
308+ break
309+ }
310+ if tc, ok := i.toolCalls[id]; ok {
311+ i.view.ToolCalls = append(i.view.ToolCalls, *tc)
312+ }
313+ }
314+ }
315+ i.view.Err = i.statusErr
316+ // Live streaming/tool rows are appended to the chat buffer (not
317+ // hoisted into a separate live block above the editor). That keeps
318+ // the renderer's diff view append-only: when a tool finishes the
319+ // rows update in place at the end of the buffer, instead of the
320+ // whole bottom band shrinking and shifting chat lines around.
321+ i.liveBlock = nil
322+ stable := i.stableChatRowsLocked(cols)
323+ chat := append([]string(nil), stable...)
324+ chat = append(chat, i.view.BuildLive(cols)...)
325+
326+ // Welcome banner: shown at the top of the chat area when there is
327+ // no transcript yet. Disappears after the first message is sent.
328+ // The version suffix is shown for welcomeVersionDuration after
329+ // startup, then drops off automatically.
330+ if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
331+ showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
332+ chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
333+ }
334+
335+ // Update-available banner: prepended above everything else so it's
336+ // the first thing the user sees when opening a new zut session.
337+ // Once rendered, it stays until the user updates to a newer
338+ // version — we don't persist a "dismissed" flag because this is
339+ // cheap and re-showing it is how most users remember to update.
340+ if i.updateInfo.Available {
341+ banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
342+ chat = append(banner, chat...)
343+ }
344+
345+ // /help block: appended to the transcript so it appears at the
346+ // bottom of the chat area (right above the status bar / editor).
347+ // Prepending it would push long conversations off the top of the
348+ // viewport, which users would miss entirely.
349+ if len(i.helpBlock) > 0 {
350+ chat = append(chat, i.helpBlock...)
351+ }
352+
353+ if i.statusOK != "" {
354+ // Hard-truncate the OK line to the visible width so a long
355+ // session path ("resumed session: /Users/.../sessions/...")
356+ // doesn't overflow past the right edge and look broken on a
357+ // narrow terminal.
358+ line := "✓ " + i.statusOK
359+ if cols > 4 && len(line) > cols {
360+ line = line[:cols-3] + "..."
361+ }
362+ chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
363+ }
364+
365+ // Live shell-escape output (!command / entry.pre) streams into the
366+ // transcript area while the command runs, then is replaced by the
367+ // final user-context message when it finishes.
368+ if i.shellRunning && i.shellLive != "" {
369+ chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...)
370+ chat = append(chat, "")
371+ }
372+
373+ // Extension notes (notify / display) live just under the
374+ // transcript, above the dialog/editor band. Cleared by /clear.
375+ if len(i.extNotes) > 0 {
376+ chat = append(chat, i.extNotes...)
377+ chat = append(chat, "")
378+ }
379+
380+ // Reload failures remain in the scrolling chat until /clear. They are
381+ // host-only display state, not provider messages or persisted context.
382+ // While the latest failure is still the temporary status error, hide its
383+ // stored copy to avoid painting the same message twice.
384+ reloadErrors := i.reloadErrors
385+ if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
386+ reloadErrors = reloadErrors[:n-1]
387+ }
388+ if len(reloadErrors) > 0 {
389+ chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
390+ chat = append(chat, "")
391+ }
392+
393+ // Strip trailing blank rows so the chat content sits flush
394+ // against the new "blank above status bar" row added by the
395+ // bottom-region assembly. Build() ends every message with a
396+ // blank separator; without this trim, the final message in
397+ // the transcript would have its own trailing blank plus the
398+ // status block's leading blank, doubling the gap.
399+ for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
400+ chat = chat[:len(chat)-1]
401+ }
402+ return chat
403+}
404+func (i *Interactive) sessionsRoot() string {
405+ if i.cfg.SessionsRoot != "" {
406+ return i.cfg.SessionsRoot
407+ }
408+ return i.cfg.ZutHome
409+}
410+func (i *Interactive) lastCols() int {
411+ cols, _ := i.cfg.Terminal.Size()
412+ return cols
413+}
414+func (i *Interactive) chatPage() int {
415+ _, rows := i.cfg.Terminal.Size()
416+ p := rows - 6 // rough reservation for status + editor + a dialog line
417+ if p < 4 {
418+ p = 4
419+ }
420+ return p
421+}
422+func (i *Interactive) scrollBy(delta int) {
423+ i.mu.Lock()
424+ i.scrollOffset += delta
425+ if i.scrollOffset < 0 {
426+ i.scrollOffset = 0
427+ }
428+ if i.scrollOffset == 0 {
429+ i.parkedTurn = 0
430+ i.parkedTotal = 0
431+ }
432+ if i.rend != nil {
433+ // VS Code's terminal is especially prone to leaving stray
434+ // wrapped-character fragments behind during scroll-driven
435+ // viewport changes. Force a full repaint on scroll, but
436+ // avoid a whole-screen clear because that visibly flickers.
437+ i.requestRendererInvalidate()
438+ }
439+ i.mu.Unlock()
440+ i.invalidate()
441+}
442+func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
443+ adj := (newLen - prevLen) - (newRows - prevRows)
444+ offset += adj
445+ if offset < 0 {
446+ offset = 0
447+ }
448+ if offset > newLen {
449+ offset = newLen
450+ }
451+ return offset
452+}
453+func (i *Interactive) scrollToBottom() {
454+ i.mu.Lock()
455+ i.scrollOffset = 0
456+ i.parkedTurn = 0
457+ i.parkedTotal = 0
458+ // Reset the auto-follow baseline. scrollToBottom is the resume /
459+ // session-swap snap point, where the chat buffer changes length
460+ // wholesale. Without zeroing these, the next render's follow guard
461+ // compares the fresh transcript's length against a stale baseline
462+ // and nudges scrollOffset, which reads as a viewport jump right
463+ // after resume. See commit 43da5e5 for the same fix on new turns.
464+ i.prevChatLen = 0
465+ i.prevChatCols = 0
466+ i.requestRendererInvalidate()
467+ i.mu.Unlock()
468+ i.invalidate()
469+}
470+func (i *Interactive) redraw() {
471+ i.mu.Lock()
472+ renderRevision := i.renderRevision.Load()
473+
474+ cols, rows := i.cfg.Terminal.Size()
475+ mainCols := cols
476+ rightBarWidgets := i.rightBarWidgetsLocked()
477+ rightBarActive := false
478+ var rightBarWidth int
479+ if !i.rightBarHidden && len(rightBarWidgets) > 0 {
480+ if width, rail, ok := tui.RightBarColumns(cols); ok {
481+ mainCols = width
482+ rightBarWidth = rail
483+ rightBarActive = true
484+ }
485+ }
486+ rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
487+ i.renderOutsideLock = true
488+ chat := i.cachedChatLocked(mainCols)
489+ i.renderOutsideLock = false
490+ if i.renderRevision.Load() != renderRevision {
491+ i.mu.Unlock()
492+ return
493+ }
494+
495+ // Dialogs (login or model picker) render between chat and the editor.
496+ var dialog []string
497+ switch {
498+ case i.dialog.Active():
499+ dialog = i.dialog.Render(i.cfg.Theme, mainCols)
500+ case i.modelDialog.Active():
501+ dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
502+ case i.llamaDialog.Active():
503+ dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
504+ case i.rescueDialog.Active():
505+ dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
506+ case i.sessionDialog.Active():
507+ // Reserve rows for the editor (~3), status line (1-2),
508+ // dialog chrome (header + hint + rule + indicators, ~5),
509+ // and leave the remainder for session rows. Minimum of 3
510+ // rows so even a very small terminal shows something.
511+ _, rows := i.cfg.Terminal.Size()
512+ avail := rows - 12
513+ if avail < 3 {
514+ avail = 3
515+ }
516+ i.sessionDialog.MaxRows = avail
517+ dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
518+ case i.residentSubagentsDialog.Active():
519+ _, terminalRows := i.cfg.Terminal.Size()
520+ dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows)
521+ case i.residentChildSession != nil:
522+ _, terminalRows := i.cfg.Terminal.Size()
523+ dialog = i.residentChildSession.Render(mainCols, terminalRows)
524+ case i.jumpDialog.Active():
525+ dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
526+ case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
527+ dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
528+ case i.confirmDialog.Active():
529+ if i.btwDialog.Active() {
530+ // Keep the side-chat transcript and its live tool preview visible
531+ // above confirmation, matching how the main transcript remains
532+ // visible while --no-yolo waits for a decision.
533+ dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
534+ } else {
535+ dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
536+ }
537+ case i.btwDialog.Active():
538+ dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
539+ case i.skillsDialog.Active():
540+ dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
541+ case i.changelogDialog.Active():
542+ dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
543+ case i.logoutDialog.Active():
544+ dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
545+ case i.telegramDialog.Active():
546+ dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
547+ case i.settingsDialog.Active():
548+ dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
549+ case i.sessionOpsDialog.Active():
550+ dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
551+ case i.sessionTreeDialog.Active():
552+ // Reserve rows for the editor, status, and tree chrome while using
553+ // the same budget for rendering and PageUp/PageDown movement.
554+ _, rows := i.cfg.Terminal.Size()
555+ avail := rows - 12
556+ if avail < 3 {
557+ avail = 3
558+ }
559+ i.sessionTreeDialog.MaxRows = avail
560+ dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
561+ }
562+ if len(dialog) > 0 {
563+ dialog = padDialogFrame(dialog)
564+ }
565+ modalBackdrop := len(dialog) > 0
566+
567+ // Slash-command autocomplete: popup above the status line, only
568+ // when the editor starts with "/" and no dialog is already open.
569+ // Feed extension-registered commands into the suggester first so
570+ // they show up in tab-complete + the popup alongside the built-ins.
571+ i.suggest.SetJailed(i.cfg.Sandbox.Locked())
572+ i.suggest.SetLlamaConfigured(i.llamaConfigured)
573+ if i.cfg.Extensions != nil {
574+ catalog := i.cfg.Extensions.Commands()
575+ extra := make([]slashCommand, 0, len(catalog))
576+ for _, c := range catalog {
577+ // The popup renders extension commands under a dedicated
578+ // "── extensions ───" divider, so the description doesn't
579+ // need to repeat the source. If the description is empty,
580+ // fall back to the extension name so the row isn't blank.
581+ desc := c.Description
582+ if strings.TrimSpace(desc) == "" {
583+ desc = "(" + c.Extension + ")"
584+ }
585+ extra = append(extra, slashCommand{
586+ Name: "/" + c.Name,
587+ Desc: desc,
588+ })
589+ }
590+ i.suggest.SetExtra(extra)
591+ }
592+ var suggest []string
593+ currentInput := i.ed.Value()
594+ if i.suggest.SkillInputStarted(currentInput) {
595+ var list []*skills.Skill
596+ if i.cfg.SkillSnapshot != nil {
597+ list = i.cfg.SkillSnapshot()
598+ }
599+ i.suggest.SetSkills(list)
600+ }
601+ // Slash popup renders even while the agent is busy so the user
602+ // can queue a destructive command (/clear, /compact, /logout,
603+ // /model) or a read-only one (/help, /jump, /sessions, etc.)
604+ // without waiting for the current turn to finish. The dispatcher
605+ // in runSlash already handles the busy case per-command: safe
606+ // ones run immediately, destructive ones cancel the turn first.
607+ i.fileSuggest.SetCWD(i.cfg.CWD)
608+ mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
609+ if mainInputFocused && i.suggest.Active(currentInput) {
610+ suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
611+ } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
612+ suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
613+ }
614+
615+ // Detect overlay close (any dialog or slash/file suggestion popup
616+ // just transitioned from open to closed). Force a full redraw so
617+ // the rows the overlay occupied are guaranteed to be repainted
618+ // from the chat below, instead of the diff path leaving stale
619+ // dialog content behind. Equivalent to the user pressing ctrl+l.
620+ overlayOpen := len(dialog) > 0 || len(suggest) > 0
621+ if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
622+ // An overlay (dialog or slash/file popup) just closed, so the
623+ // bottom band shrinks. On terminals where we can drop
624+ // scrollback, a full Clear is the simplest way to guarantee
625+ // the vacated rows are repainted from the chat below.
626+ //
627+ // On VS Code's terminal closing a dialog leaves the stale
628+ // overlay rows in the retained scrollback (we can't drop them
629+ // with the quiet in-place diff). Run the same full Clear() that
630+ // Ctrl+L uses so the scrollback is purged and the conversation
631+ // is repainted clean, matching what the user expects after
632+ // dismissing a picker. Clear() is keepScrollback-aware and
633+ // emits \x1b[3J there.
634+ i.rend.Clear()
635+ }
636+ i.prevOverlayOpen = overlayOpen
637+ if len(suggest) > 0 {
638+ // One blank row above the popup so it doesn't sit flush
639+ // against the chat / welcome content above.
640+ suggest = append([]string{""}, suggest...)
641+ }
642+
643+ // Busy prefix shown at the far left of the status bar. The spinner owns
644+ // only its frame; the activity label reports the operation in progress.
645+ var busyPrefix string
646+ if i.busy {
647+ label := i.activity.label()
648+ if label == "" {
649+ label = "Preparing request"
650+ }
651+ busyPrefix = fmt.Sprintf("%s %s %s %s",
652+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
653+ i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
654+ i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
655+ i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
656+ )
657+ }
658+
659+ ctxMax := 0
660+ if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
661+ ctxMax = m.ContextWindow
662+ }
663+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
664+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
665+ workingWithStatus := statusPosition == workingPosition
666+ statusBusyPrefix := ""
667+ if workingWithStatus {
668+ statusBusyPrefix = busyPrefix
669+ }
670+ goalStatus := string(i.goalStatus)
671+ statusLines := tui.StatusBar(tui.StatusBarParams{
672+ Theme: i.cfg.Theme,
673+ Provider: i.cfg.Provider,
674+ Model: i.cfg.Model,
675+ Reasoning: i.cfg.Reasoning,
676+ FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
677+ Busy: i.busy,
678+ BusyPrefix: statusBusyPrefix,
679+ CWD: i.cfg.CWD,
680+ Locked: i.cfg.Sandbox.Locked(),
681+ NoYolo: i.cfg.NoYolo,
682+ GoalStatus: goalStatus,
683+ Usage: i.cumUsage,
684+ Subscription: i.cfg.AuthMethod == "oauth",
685+ ContextUsed: i.lastCtxInput,
686+ ContextMax: ctxMax,
687+ AutoCompacting: i.autoCompacting,
688+ Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
689+ Cols: mainCols,
690+ })
691+ inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
692+ if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
693+ i.ed.Prompt = ""
694+ } else {
695+ i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
696+ }
697+ edLines, curR, curC := i.ed.Render(mainCols)
698+ dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
699+ var allResidentSubagentLines []string
700+ residentSubagentHidden := 0
701+ if !dashboardActive && i.cfg.ResidentManager != nil {
702+ const residentIndicatorSnapshotLimit = 8
703+ snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
704+ allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
705+ residentSubagentHidden = total - len(snapshots)
706+ }
707+ var workingLines []string
708+ if busyPrefix != "" && !workingWithStatus {
709+ workingLines = []string{" " + busyPrefix}
710+ }
711+ inputCursorOffset := 0
712+ inputCursorColOffset := 0
713+ switch inputStyle {
714+ case tui.InputStyleLines:
715+ edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
716+ inputCursorOffset = 1
717+ case tui.InputStyleBlock:
718+ edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
719+ inputCursorOffset = 1
720+ inputCursorColOffset = 2
721+ }
722+
723+ // "Sliding in" chips for messages the user typed while a turn is
724+ // in flight. Shown directly above the status bar so they're close
725+ // to the editor but don't push the chat around.
726+ var queue []string
727+ queued := append([]string(nil), i.queued...)
728+ if i.agent != nil {
729+ queued = append(queued, i.agent.PendingQueuedMessages()...)
730+ }
731+ if len(queued) > 0 {
732+ queue = append(queue, "")
733+ for _, q := range queued {
734+ label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
735+ text := truncateLine(q, mainCols-17)
736+ queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
737+ }
738+ // Hint row, rendered in the same muted tone as the model
739+ // info on the status bar so it reads as ambient metadata
740+ // rather than a chip. Tells the user how to recover the
741+ // most recent queued message back into the editor.
742+ hint := " Press " + slideBackChordHint() + " to slide back into input"
743+ queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
744+ }
745+
746+ extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
747+
748+ // Bottom-sticky sections (always visible, never scroll). Each
749+ // non-empty subsection (dialog, suggest popup, sliding-in queue)
750+ // is preceded by one blank row so it has air above the chat
751+ // content. The status block and editor get their own dedicated
752+ // blanks so spacing stays consistent whether or not a dialog or
753+ // popup is showing.
754+ composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
755+ bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
756+ inputStartRow = -1
757+ if len(dialog) > 0 {
758+ bottom = append(bottom, "")
759+ }
760+ bottom = append(bottom, dialog...)
761+ // The resident child views own the bottom of the screen while active.
762+ if dashboardActive {
763+ return bottom, inputStartRow
764+ }
765+
766+ bottom = append(bottom, suggest...)
767+ bottom = append(bottom, queue...)
768+ lineInput := inputStyle == tui.InputStyleLines
769+ statusBelow := statusPosition == tui.StatusPositionBelowInput
770+ workingBelow := workingPosition == tui.WorkingPositionBelowInput
771+ var aboveInput []string
772+ aboveInput = append(aboveInput, extensionLines...)
773+ if !statusBelow {
774+ aboveInput = append(aboveInput, statusLines...)
775+ }
776+ if !workingBelow {
777+ aboveInput = append(aboveInput, workingLines...)
778+ }
779+ var belowInput []string
780+ // Background resident work belongs directly below the editor. It is
781+ // deliberately independent of the main turn's busy/status placement.
782+ belowInput = append(belowInput, residentSubagentLines...)
783+ if workingBelow {
784+ belowInput = append(belowInput, workingLines...)
785+ }
786+ if statusBelow {
787+ belowInput = append(belowInput, statusLines...)
788+ }
789+
790+ bottom = append(bottom, "")
791+ bottom = append(bottom, aboveInput...)
792+ needsGapAboveInput := len(aboveInput) > 0 && !lineInput
793+ if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
794+ needsGapAboveInput = true
795+ }
796+ if needsGapAboveInput {
797+ bottom = append(bottom, "")
798+ }
799+ inputStartRow = len(bottom)
800+ bottom = append(bottom, edLines...)
801+ if len(belowInput) > 0 {
802+ // Active resident work sits immediately below the prompt. Other
803+ // below-input chrome keeps the usual breathing room.
804+ if !lineInput && len(residentSubagentLines) == 0 {
805+ bottom = append(bottom, "")
806+ }
807+ bottom = append(bottom, belowInput...)
808+ }
809+ return bottom, inputStartRow
810+ }
811+
812+ const rendererBottomMarginRows = 1
813+ maxBottomRows := rows - rendererBottomMarginRows - 1
814+ var residentSubagentLines []string
815+ var bottom []string
816+ var inputStartRow int
817+ if !dashboardActive && len(allResidentSubagentLines) > 0 {
818+ for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
819+ candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
820+ candidateBottom, _ := composeBottom(candidate)
821+ if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
822+ residentSubagentLines = candidate
823+ break
824+ }
825+ }
826+ }
827+ bottom, inputStartRow = composeBottom(residentSubagentLines)
828+
829+ chatRows := rows - len(bottom)
830+ if chatRows < 1 {
831+ chatRows = 1
832+ }
833+
834+ // Auto-follow guard: when the user has scrolled up (scrollOffset
835+ // > 0) and the agent appends new content below the viewport while
836+ // they're reading, compensate so the visible content stays
837+ // anchored. scrollOffset is measured from the bottom of `chat`,
838+ // so without compensation a growing buffer pushes the window
839+ // downward through the content and the lines the user was
840+ // reading scroll off the top.
841+ //
842+ // Skip compensation when the terminal width changed (a resize
843+ // reflows the whole buffer and the line-count delta no longer
844+ // corresponds to appended content) and when scrollOffset is 0
845+ // (the user is following the tail and wants new content to push
846+ // the view down as usual).
847+ //
848+ // The window the user sees starts at row
849+ // start = len(chat) - scrollOffset - chatRows
850+ // so to keep `start` fixed across a redraw we must offset by both
851+ // the buffer growth (len delta) AND the viewport-height change
852+ // (chatRows delta, e.g. the status band or sliding-in queue
853+ // appearing during a turn). Compensating only for the len delta
854+ // let a shrinking chatRows pull the window toward the tail, which
855+ // read as the viewport jumping to the bottom whenever the agent
856+ // streamed text or a tool call grew the bottom band.
857+ if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
858+ i.scrollOffset = anchorScrollOffset(i.scrollOffset,
859+ i.prevChatLen, len(chat), i.prevChatRows, chatRows)
860+ }
861+ i.prevChatLen = len(chat)
862+ i.prevChatCols = mainCols
863+ i.prevChatRows = chatRows
864+
865+ // Apply scroll offset to the chat slice.
866+ maxOffset := len(chat) - chatRows
867+ if maxOffset < 0 {
868+ maxOffset = 0
869+ }
870+ // Tail-render expansion: if the user has scrolled to (or above)
871+ // the top of the currently rendered tail and there are still
872+ // truncated messages above, widen view.TailLimit and rebuild.
873+ // The chat cache is keyed on tailLimit so the next cachedChatLocked
874+ // will re-issue Build instead of returning the stale slice. We
875+ // rebuild immediately so the same redraw shows the freshly-revealed
876+ // rows; otherwise the user would have to scroll again to see them.
877+ if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
878+ prevLen := len(chat)
879+ i.view.TailLimit += resumeTailExpandStep
880+ if i.view.TailLimit >= len(i.view.Messages) {
881+ i.view.TailLimit = 0 // unbounded
882+ }
883+ i.chatCacheValid = false
884+ chat = i.cachedChatLocked(mainCols)
885+ for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
886+ chat = chat[:len(chat)-1]
887+ }
888+ // Newly-revealed rows are older messages prepended at the top.
889+ // scrollOffset counts rows from the bottom, so to keep the
890+ // viewport visually anchored on the same content the user was
891+ // looking at we shift it up by the number of rows added. Keep
892+ // the auto-follow baseline (prevChatLen) in sync with the
893+ // post-expansion length too, so the next render's follow guard
894+ // doesn't see this growth as a synthetic delta and yank the
895+ // viewport again.
896+ if grew := len(chat) - prevLen; grew > 0 {
897+ i.scrollOffset += grew
898+ }
899+ i.prevChatLen = len(chat)
900+ i.prevChatCols = mainCols
901+ maxOffset = len(chat) - chatRows
902+ if maxOffset < 0 {
903+ maxOffset = 0
904+ }
905+ }
906+ if i.scrollOffset > maxOffset {
907+ i.scrollOffset = maxOffset
908+ }
909+ if i.scrollOffset < 0 {
910+ i.scrollOffset = 0
911+ }
912+
913+ var visibleChat []string
914+ if len(chat) <= chatRows {
915+ visibleChat = chat
916+ } else {
917+ end := len(chat) - i.scrollOffset
918+ rawStart := end - chatRows
919+ if rawStart < 0 {
920+ rawStart = 0
921+ }
922+ start := snapViewportStartToImageBlock(chat, rawStart)
923+ // If the snap pulled start upward (an image-block was atomic) while
924+ // the user is scrolling downward, the viewport would sit on the same
925+ // image until the user mashes down past every reserved row. Bump
926+ // scrollOffset past the image so one keypress always clears it.
927+ if start < rawStart && i.scrollOffset < i.prevScrollOffset {
928+ jump := rawStart - start
929+ i.scrollOffset -= jump
930+ if i.scrollOffset < 0 {
931+ i.scrollOffset = 0
932+ }
933+ end = len(chat) - i.scrollOffset
934+ rawStart = end - chatRows
935+ if rawStart < 0 {
936+ rawStart = 0
937+ }
938+ start = snapViewportStartToImageBlock(chat, rawStart)
939+ }
940+ end = start + chatRows
941+ if end > len(chat) {
942+ end = len(chat)
943+ start = end - chatRows
944+ if start < 0 {
945+ start = 0
946+ }
947+ }
948+ visibleChat = chat[start:end]
949+ }
950+ i.prevScrollOffset = i.scrollOffset
951+ visibleChat = clipBottomClippedImages(visibleChat)
952+
953+ // A tiny "scrolled up" indicator in the top-right of the chat pane
954+ // so you know you're not at the bottom. When the viewport was
955+ // parked by /jump we include the turn number so the user remembers
956+ // they're reading history rather than the live conversation.
957+ if i.scrollOffset > 0 && len(visibleChat) > 0 {
958+ var text string
959+ if i.parkedTurn > 0 && i.parkedTotal > 0 {
960+ text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
961+ i.parkedTurn, i.parkedTotal, i.scrollOffset)
962+ } else {
963+ text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
964+ }
965+ note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
966+ visibleChat = append([]string{note}, visibleChat...)
967+ if len(visibleChat) > chatRows {
968+ visibleChat = visibleChat[:chatRows]
969+ }
970+ }
971+
972+ // A dialog is the foreground layer. Dim every other visible surface,
973+ // including the main input beneath it, without changing the dialog's
974+ // own styling. Keep the source slices untouched because chat is cached.
975+ if modalBackdrop {
976+ chat = tui.DimLines(chat)
977+ visibleChat = tui.DimLines(visibleChat)
978+ dimmedBottom := tui.DimLines(bottom)
979+ copy(dimmedBottom[1:1+len(dialog)], dialog)
980+ bottom = dimmedBottom
981+ }
982+
983+ // Default: the real terminal cursor sits on the main editor's
984+ // input position. In main-screen log mode cursor rows are relative
985+ // to the fixed bottom band, not the chat transcript.
986+ // dialogLead is 1 when the bottom region prepends a blank above
987+ // the dialog block (whenever a dialog is showing) so popup-side
988+ // cursor positions still land in the right cell.
989+ dialogLead := 0
990+ if len(dialog) > 0 {
991+ dialogLead = 1
992+ }
993+ cursorRow := inputStartRow + inputCursorOffset + curR
994+ cursorCol := curC + inputCursorColOffset
995+ cursorInDialog := false
996+ if i.btwDialog.Active() {
997+ if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
998+ cursorRow = dialogLead + r
999+ cursorCol = c
1000+ cursorInDialog = true
1001+ }
1002+ }
1003+ if i.dialog.Active() {
1004+ if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
1005+ cursorRow = dialogLead + r
1006+ cursorCol = c
1007+ cursorInDialog = true
1008+ }
1009+ }
1010+ if i.llamaDialog.Active() {
1011+ if r, c := i.llamaDialog.CursorPos(); r >= 0 {
1012+ cursorRow = dialogLead + r
1013+ cursorCol = c
1014+ cursorInDialog = true
1015+ } else {
1016+ cursorRow = -1
1017+ cursorCol = 0
1018+ }
1019+ }
1020+ if i.sessionDialog.Active() {
1021+ if r, c := i.sessionDialog.CursorPos(); r >= 0 {
1022+ cursorRow = dialogLead + r
1023+ cursorCol = c
1024+ cursorInDialog = true
1025+ }
1026+ }
1027+ if i.sessionTreeDialog.Active() {
1028+ if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1029+ cursorRow = dialogLead + r
1030+ cursorCol = c
1031+ cursorInDialog = true
1032+ }
1033+ }
1034+ if i.extPanel.Active() {
1035+ cursorRow = -1
1036+ cursorCol = 0
1037+ }
1038+ i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
1039+ if i.renderRevision.Load() != renderRevision {
1040+ i.mu.Unlock()
1041+ return
1042+ }
1043+ theme := i.cfg.Theme
1044+ // State assembly above is synchronized; terminal output is owned by
1045+ // this renderer goroutine and must not hold the global state mutex.
1046+ i.mu.Unlock()
1047+ if rightBarActive {
1048+ rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
1049+ if modalBackdrop {
1050+ i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
1051+ } else {
1052+ i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
1053+ }
1054+ } else {
1055+ _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
1056+ i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
1057+ }
1058+ i.mu.Lock()
1059+ if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
1060+ alert := *i.pendingAlert
1061+ i.pendingAlert = nil
1062+ i.emitAlertLocked(alert)
1063+ }
1064+ i.mu.Unlock()
1065+}
1066+func hasImageEscape(line string) bool {
1067+ return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
1068+}
1069+func snapViewportStartToImageBlock(chat []string, start int) int {
1070+ if start <= 0 || start >= len(chat) {
1071+ return start
1072+ }
1073+ if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
1074+ return start
1075+ }
1076+ for k := start - 1; k >= 0; k-- {
1077+ line := chat[k]
1078+ if hasImageEscape(line) {
1079+ return k
1080+ }
1081+ if !isBoxBlankLine(line) {
1082+ break
1083+ }
1084+ }
1085+ return start
1086+}
1087+func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
1088+ if len(msgs) == 0 {
1089+ return nil
1090+ }
1091+ out := make([]provider.Message, 0, len(msgs))
1092+ for _, m := range msgs {
1093+ if isHiddenTranscriptMessage(m) {
1094+ continue
1095+ }
1096+ out = append(out, m)
1097+ }
1098+ return out
1099+}
1100+func isHiddenTranscriptMessage(m provider.Message) bool {
1101+ if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
1102+ return true
1103+ }
1104+ if m.Role != provider.RoleUser || len(m.Content) == 0 {
1105+ return false
1106+ }
1107+ tb, ok := m.Content[0].(provider.TextBlock)
1108+ if !ok {
1109+ return false
1110+ }
1111+ return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
1112+}
1113+func clipBottomClippedImages(lines []string) []string {
1114+ if len(lines) == 0 {
1115+ return lines
1116+ }
1117+ out := append([]string(nil), lines...)
1118+ for i, line := range out {
1119+ if !hasImageEscape(line) {
1120+ continue
1121+ }
1122+ // Image blocks render as: image escape, zero or more blank
1123+ // reservation rows, then the muted "image - ..." info line,
1124+ // then one trailing blank. If the info line isn't visible in
1125+ // the current chat slice, the image would paint down into the
1126+ // fixed status bar area. Suppress that image for this frame.
1127+ //
1128+ // When the image lives inside a tool box, the reservation rows
1129+ // are wrapped in vertical box edges ("│ ... │"); those rows
1130+ // look non-blank under a naive whitespace check but are still
1131+ // reservation rows for this scan, so treat them as blank.
1132+ foundInfo := false
1133+ for j := i + 1; j < len(out); j++ {
1134+ if strings.Contains(out[j], "image - ") {
1135+ foundInfo = true
1136+ break
1137+ }
1138+ if !isBoxBlankLine(out[j]) {
1139+ break
1140+ }
1141+ }
1142+ if !foundInfo {
1143+ out[i] = ""
1144+ }
1145+ }
1146+ return out
1147+}
1148+func isBoxBlankLine(line string) bool {
1149+ stripped := stripANSIBytes(line)
1150+ stripped = strings.TrimSpace(stripped)
1151+ stripped = strings.Trim(stripped, "│")
1152+ stripped = strings.TrimSpace(stripped)
1153+ return stripped == ""
1154+}
1155+func stripANSIBytes(s string) string {
1156+ if !strings.Contains(s, "\x1b") {
1157+ return s
1158+ }
1159+ var b strings.Builder
1160+ b.Grow(len(s))
1161+ i := 0
1162+ for i < len(s) {
1163+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
1164+ end := i + 2
1165+ for end < len(s) {
1166+ c := s[end]
1167+ end++
1168+ if c >= 0x40 && c <= 0x7e {
1169+ break
1170+ }
1171+ }
1172+ i = end
1173+ continue
1174+ }
1175+ b.WriteByte(s[i])
1176+ i++
1177+ }
1178+ return b.String()
1179+}
1180+func panelKeyName(k tui.Key) string {
1181+ switch k.Kind {
1182+ case tui.KeyUp:
1183+ return "up"
1184+ case tui.KeyDown:
1185+ return "down"
1186+ case tui.KeyLeft:
1187+ return "left"
1188+ case tui.KeyRight:
1189+ return "right"
1190+ case tui.KeyEnter:
1191+ return "enter"
1192+ case tui.KeyEsc:
1193+ return "esc"
1194+ case tui.KeyTab:
1195+ return "tab"
1196+ case tui.KeyBackspace:
1197+ return "backspace"
1198+ case tui.KeyDelete:
1199+ return "delete"
1200+ case tui.KeyHome:
1201+ return "home"
1202+ case tui.KeyEnd:
1203+ return "end"
1204+ case tui.KeyPageUp:
1205+ return "pageup"
1206+ case tui.KeyPageDown:
1207+ return "pagedown"
1208+ case tui.KeyRune:
1209+ return "rune"
1210+ default:
1211+ return "unknown"
1212+ }
1213+}
1214+func panelKeyText(k tui.Key) string {
1215+ if k.Kind == tui.KeyRune {
1216+ return string(k.Rune)
1217+ }
1218+ return ""
1219+}
1220+func truncateLine(s string, n int) string {
1221+ if n <= 0 {
1222+ return ""
1223+ }
1224+ // Collapse newlines — chips are single line.
1225+ s = strings.ReplaceAll(s, "\n", " ↩ ")
1226+ runes := []rune(s)
1227+ if len(runes) <= n {
1228+ return s
1229+ }
1230+ if n <= 3 {
1231+ return strings.Repeat(".", n)
1232+ }
1233+ return string(runes[:n-3]) + "..."
1234+}
A packages/agent/modes/interactive_resident_subagents.go +1107 -0
@@ -0,0 +1,1107 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/zut/packages/agent/internal/orchestration"
12+ "github.com/bnema/zut/packages/agent/modes/telegram"
13+ "github.com/bnema/zut/packages/agent/subagents"
14+ "github.com/bnema/zut/packages/agent/tools"
15+ "github.com/bnema/zut/packages/core"
16+ "github.com/bnema/zut/packages/provider"
17+ "github.com/bnema/zut/packages/tui"
18+)
19+
20+func (i *Interactive) TrackResidentSubagent(childID string) {
21+ if i == nil {
22+ return
23+ }
24+ i.ensureCompletionTracker().TrackResident()
25+ i.registerCoordinatorWorker(childID)
26+ i.invalidate()
27+ i.requestCompletionDelivery()
28+}
29+func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) {
30+ if i == nil {
31+ return
32+ }
33+ status, errText := "completed", ""
34+ if completion.Err != nil {
35+ status, errText = "failed", completion.Err.Error()
36+ }
37+ i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary})
38+ i.reloadOpenResidentChildSession(completion.ChildID)
39+ i.invalidate()
40+ i.requestCompletionDelivery()
41+}
42+func (i *Interactive) reloadOpenResidentChildSession(childID string) {
43+ if i == nil {
44+ return
45+ }
46+ i.mu.Lock()
47+ session := i.residentChildSession
48+ i.mu.Unlock()
49+ if session == nil || session.childID != childID || !session.RequestRecentReload() {
50+ return
51+ }
52+ i.reloadResidentChildSession(session)
53+}
54+func (i *Interactive) reloadResidentChildSession(session *residentChildSession) {
55+ if i == nil || session == nil {
56+ return
57+ }
58+ go func() {
59+ err := session.ReloadRecent(200)
60+ if session.FinishLoad(err) {
61+ if session.RequestRecentReload() {
62+ i.reloadResidentChildSession(session)
63+ }
64+ }
65+ i.invalidate()
66+ }()
67+}
68+func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
69+ // Tests and lightweight embedders may construct Interactive with a struct
70+ // literal instead of NewInteractive, so retain this lazy fallback.
71+ i.completionDeliveryMu.Lock()
72+ defer i.completionDeliveryMu.Unlock()
73+ if i.completionTracker == nil {
74+ i.completionTracker = subagents.NewCompletionTracker()
75+ }
76+ return i.completionTracker
77+}
78+func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator {
79+ if i.turnCoordinator == nil {
80+ i.turnCoordinator = orchestration.New()
81+ }
82+ return i.turnCoordinator
83+}
84+func (i *Interactive) coordinatorAcceptsUserInput() bool {
85+ if i == nil {
86+ return false
87+ }
88+ i.completionDeliveryMu.Lock()
89+ accepts := i.ensureCoordinatorLocked().AcceptsUserInput()
90+ i.completionDeliveryMu.Unlock()
91+ return accepts
92+}
93+func (i *Interactive) coordinatorHasPendingWorkers() bool {
94+ if i == nil {
95+ return false
96+ }
97+ i.completionDeliveryMu.Lock()
98+ pending := i.ensureCoordinatorLocked().HasPendingWorkers()
99+ i.completionDeliveryMu.Unlock()
100+ return pending
101+}
102+func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action {
103+ if i == nil {
104+ return nil
105+ }
106+ i.completionDeliveryMu.Lock()
107+ result := i.ensureCoordinatorLocked().Apply(event)
108+ i.completionDeliveryMu.Unlock()
109+ return result.Actions
110+}
111+func (i *Interactive) registerCoordinatorWorker(workerID string) {
112+ if i == nil || workerID == "" {
113+ return
114+ }
115+ i.completionDeliveryMu.Lock()
116+ coordinator := i.ensureCoordinatorLocked()
117+ implicitWave := i.completionDeliveryHolds == 0
118+ if implicitWave {
119+ coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
120+ }
121+ i.coordinatorWorkerSeq++
122+ registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq)
123+ if i.coordinatorWorkerIDs == nil {
124+ i.coordinatorWorkerIDs = make(map[string][]string)
125+ }
126+ i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID)
127+ coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID})
128+ var actions []orchestration.Action
129+ if implicitWave {
130+ actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
131+ }
132+ i.completionDeliveryMu.Unlock()
133+ i.executeCoordinatorActions(actions)
134+}
135+func (i *Interactive) cancelCoordinator() {
136+ if i == nil {
137+ return
138+ }
139+ i.completionDeliveryMu.Lock()
140+ coordinator := i.ensureCoordinatorLocked()
141+ actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions
142+ // A later user turn starts a fresh wave. Drop resident registrations as
143+ // well as coordinator identities so late outcomes cannot match a new child.
144+ if i.completionTracker != nil {
145+ i.completionTracker.Reset()
146+ }
147+ i.turnCoordinator = orchestration.New()
148+ i.coordinatorWorkerIDs = nil
149+ i.completionDeliveryMu.Unlock()
150+ i.executeCoordinatorActions(actions)
151+}
152+func (i *Interactive) takeCoordinatorWorkerID(agentID string) string {
153+ if i == nil || agentID == "" {
154+ return ""
155+ }
156+ i.completionDeliveryMu.Lock()
157+ defer i.completionDeliveryMu.Unlock()
158+ ids := i.coordinatorWorkerIDs[agentID]
159+ if len(ids) == 0 {
160+ return ""
161+ }
162+ workerID := ids[0]
163+ if len(ids) == 1 {
164+ delete(i.coordinatorWorkerIDs, agentID)
165+ } else {
166+ i.coordinatorWorkerIDs[agentID] = ids[1:]
167+ }
168+ return workerID
169+}
170+func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) {
171+ for _, action := range actions {
172+ if action.Kind != orchestration.ActionRunManager {
173+ continue
174+ }
175+ prompt := action.Text
176+ if len(action.Completions) != 0 {
177+ instruction := "Briefly summarise the collective outcome for the user. Reference the agents by id. If any failed, suggest a follow-up; otherwise confirm completion. Do not spawn new sub-agents unless the user asks."
178+ update := subagents.FormatCompletionUpdate(action.Completions, instruction)
179+ if prompt != "" {
180+ prompt = update + "\n\nQueued user request:\n" + prompt
181+ } else {
182+ prompt = update
183+ }
184+ }
185+ if prompt != "" {
186+ i.submitOrQueue(prompt, nil, false)
187+ } else if action.Reason == orchestration.WakeGoal {
188+ parent := i.runCtx
189+ if parent == nil {
190+ parent = context.Background()
191+ }
192+ i.requestGoalContinuationIfIdle(parent)
193+ }
194+ }
195+}
196+func (i *Interactive) requestCompletionDelivery() {
197+ if i == nil {
198+ return
199+ }
200+ i.ensureCompletionTracker()
201+ i.completionDeliveryMu.Lock()
202+ i.completionDeliveryRequest = true
203+ if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
204+ i.completionDeliveryMu.Unlock()
205+ return
206+ }
207+ i.completionDeliveryRunning = true
208+ i.completionDeliveryMu.Unlock()
209+
210+ go i.deliverCompletionUpdates()
211+}
212+func (i *Interactive) beginCompletionDeliveryHold() func() {
213+ if i == nil {
214+ return func() {}
215+ }
216+ i.completionDeliveryMu.Lock()
217+ i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
218+ i.completionDeliveryHolds++
219+ i.completionDeliveryMu.Unlock()
220+ return i.releaseCompletionDeliveryHold
221+}
222+func (i *Interactive) releaseCompletionDeliveryHold() {
223+ if i == nil {
224+ return
225+ }
226+ start := false
227+ var actions []orchestration.Action
228+ i.completionDeliveryMu.Lock()
229+ if i.completionDeliveryHolds > 0 {
230+ i.completionDeliveryHolds--
231+ }
232+ if i.completionDeliveryHolds == 0 {
233+ // The coordinator must observe the goal before sealing the wave, so a
234+ // pending worker wins the wake decision instead of a direct continuation.
235+ _, goalActive := i.goalContinuationMessage()
236+ coordinator := i.ensureCoordinatorLocked()
237+ coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
238+ actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
239+ }
240+ if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
241+ i.completionDeliveryRunning = true
242+ start = true
243+ }
244+ i.completionDeliveryMu.Unlock()
245+ i.executeCoordinatorActions(actions)
246+ if start {
247+ go i.deliverCompletionUpdates()
248+ }
249+}
250+func (i *Interactive) completionWaitContext() context.Context {
251+ i.mu.Lock()
252+ ctx := i.runCtx
253+ i.mu.Unlock()
254+ if ctx == nil {
255+ return context.Background()
256+ }
257+ return ctx
258+}
259+func (i *Interactive) deliverCompletionUpdates() {
260+ for {
261+ i.completionDeliveryMu.Lock()
262+ i.completionDeliveryRequest = false
263+ tracker := i.completionTracker
264+ i.completionDeliveryMu.Unlock()
265+
266+ batch, err := tracker.WaitIdle(i.completionWaitContext())
267+ if err == nil && len(batch) != 0 {
268+ var actions []orchestration.Action
269+ for _, completion := range batch {
270+ workerID := i.takeCoordinatorWorkerID(completion.AgentID)
271+ if workerID == "" {
272+ continue
273+ }
274+ actions = append(actions, i.applyCoordinator(orchestration.Event{
275+ Kind: orchestration.EventWorkerFinished,
276+ WorkerID: workerID,
277+ Completion: completion,
278+ })...)
279+ }
280+ i.executeCoordinatorActions(actions)
281+ }
282+
283+ i.completionDeliveryMu.Lock()
284+ if i.completionDeliveryRequest {
285+ i.completionDeliveryMu.Unlock()
286+ continue
287+ }
288+ i.completionDeliveryRunning = false
289+ i.completionDeliveryMu.Unlock()
290+ return
291+ }
292+}
293+func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
294+ addenda := make([]string, 0, 2)
295+ if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) {
296+ if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
297+ addenda = append(addenda, addendum)
298+ }
299+ }
300+ if !orchestrating {
301+ if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
302+ if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
303+ addenda = append(addenda, addendum)
304+ }
305+ }
306+ return addenda
307+ }
308+
309+ if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
310+ addenda = append(addenda, addendum)
311+ }
312+ return addenda
313+}
314+func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
315+ idx := strings.LastIndex(system, addendum)
316+ if idx < 0 {
317+ return system, false
318+ }
319+ return system[:idx] + system[idx+len(addendum):], true
320+}
321+func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
322+ // i.mu is sufficient here: this path updates the existing agent's system
323+ // prompt and managed addenda in place; it does not replace the agent or
324+ // its tool registry, so agentMu is not required.
325+ i.mu.Lock()
326+ defer i.mu.Unlock()
327+ if i.agent == nil {
328+ return
329+ }
330+
331+ sys, _ := i.agent.PromptConfig()
332+ changed := false
333+ if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
334+ var removed bool
335+ sys, removed = removeLastAutoSubagentsAddendum(sys, guidance)
336+ changed = changed || removed
337+ }
338+ for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
339+ var removed bool
340+ sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
341+ changed = changed || removed
342+ }
343+ i.managedAutoSubagentsAddenda = nil
344+
345+ addenda := autoSubagentsAddenda(i.cfg, orchestrating)
346+ if changed {
347+ sys = strings.TrimRight(sys, "\n")
348+ }
349+ for _, addendum := range addenda {
350+ if sys != "" {
351+ sys += "\n\n"
352+ }
353+ sys += addendum
354+ i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
355+ changed = true
356+ }
357+ if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
358+ if sys != "" {
359+ sys += "\n\n"
360+ }
361+ sys += guidance
362+ changed = true
363+ }
364+ if !changed {
365+ return
366+ }
367+ i.agent.SetSystemPrompt(sys)
368+}
369+func (i *Interactive) autoSubagentsAvailable() bool {
370+ return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
371+}
372+func (i *Interactive) autoSubagentsUnavailableHint() string {
373+ var hints []string
374+ if i.cfg.ResidentManager == nil {
375+ hints = append(hints, "resident subagent manager is unavailable in this mode")
376+ }
377+ if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
378+ hints = append(hints, "launch-time tool policy excludes subagent manager tools")
379+ }
380+ return strings.Join(hints, "; ")
381+}
382+func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
383+ return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
384+}
385+func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
386+ if cfg.AutoSubagentsStatusToolAllowed != nil {
387+ return *cfg.AutoSubagentsStatusToolAllowed
388+ }
389+ // Preserve the old single-boolean embedding contract: when callers have
390+ // not supplied the new field, status follows the existing delegation gate.
391+ return autoSubagentsToolAllowedConfig(cfg)
392+}
393+func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
394+ if cfg.AutoSubagentsStopToolAllowed != nil {
395+ return *cfg.AutoSubagentsStopToolAllowed
396+ }
397+ return autoSubagentsToolAllowedConfig(cfg)
398+}
399+func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
400+ if cfg.AutoSubagentsResumeToolAllowed != nil {
401+ return *cfg.AutoSubagentsResumeToolAllowed
402+ }
403+ return autoSubagentsToolAllowedConfig(cfg)
404+}
405+func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
406+ return autoSubagentsToolAllowedConfig(cfg) ||
407+ autoSubagentsStatusToolAllowedConfig(cfg) ||
408+ autoSubagentsStopToolAllowedConfig(cfg) ||
409+ autoSubagentsResumeToolAllowedConfig(cfg)
410+}
411+func (i *Interactive) autoSubagentsToolAllowed() bool {
412+ return autoSubagentsToolAllowedConfig(i.cfg)
413+}
414+func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
415+ return autoSubagentsStatusToolAllowedConfig(i.cfg)
416+}
417+func (i *Interactive) autoSubagentsStopToolAllowed() bool {
418+ return autoSubagentsStopToolAllowedConfig(i.cfg)
419+}
420+func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
421+ return autoSubagentsResumeToolAllowedConfig(i.cfg)
422+}
423+func (i *Interactive) autoSubagentsEnabledLocked() bool {
424+ return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
425+}
426+func (i *Interactive) applyAutoSubagentsTool() {
427+ i.agentMu.Lock()
428+ defer i.agentMu.Unlock()
429+ i.mu.Lock()
430+ defer i.mu.Unlock()
431+ if i.agent == nil {
432+ return
433+ }
434+ current := i.agent.ToolsSnapshot()
435+ next := core.Registry{}
436+ for name, t := range current {
437+ if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
438+ continue
439+ }
440+ next[name] = t
441+ }
442+ if i.autoSubagentsAvailable() {
443+ if i.autoSubagentsToolAllowed() {
444+ canonical := &tools.SubagentSpawnTool{
445+ ResidentManager: i.cfg.ResidentManager,
446+ BuildResidentSpec: i.cfg.BuildResidentSpec,
447+ Enabled: func() bool { return true },
448+ DefaultModel: func() string { return i.cfg.Model },
449+ DefaultProvider: func() string { return i.cfg.Provider },
450+ DefaultReasoning: func() string { return i.cfg.Reasoning },
451+ ResolveSubagent: i.cfg.ResolveSubagent,
452+ }
453+ next[canonical.Name()] = canonical
454+ }
455+ if i.autoSubagentsStatusToolAllowed() {
456+ statusTool := &tools.SubagentStatusTool{
457+ ResidentManager: i.cfg.ResidentManager,
458+ Enabled: func() bool { return true },
459+ }
460+ next[statusTool.Name()] = statusTool
461+ }
462+ if i.autoSubagentsStopToolAllowed() {
463+ stopTool := &tools.SubagentStopTool{
464+ ResidentManager: i.cfg.ResidentManager,
465+ Enabled: func() bool { return true },
466+ }
467+ next[stopTool.Name()] = stopTool
468+ }
469+ if i.autoSubagentsResumeToolAllowed() {
470+ resumeTool := &tools.SubagentResumeTool{
471+ ResidentManager: i.cfg.ResidentManager,
472+ Enabled: func() bool { return true },
473+ }
474+ next[resumeTool.Name()] = resumeTool
475+ }
476+ }
477+ i.agent.SetTools(next)
478+}
479+func (i *Interactive) applyTelegramTools(active bool) {
480+ if active {
481+ // The child ceiling must be in place even when no live main agent exists,
482+ // and before Bridge.Start can accept an external prompt.
483+ i.setWebSearchAvailable(false)
484+ }
485+ i.agentMu.Lock()
486+ defer i.agentMu.Unlock()
487+ i.mu.Lock()
488+ defer i.mu.Unlock()
489+ if i.agent == nil {
490+ return
491+ }
492+ current := i.agent.ToolsSnapshot()
493+ next := core.Registry{}
494+ for name, t := range current {
495+ if name == "telegram_send_image" || name == "telegram_send_file" {
496+ continue
497+ }
498+ if active && name == "web_search" {
499+ // External Telegram prompts have no per-request confirmation
500+ // surface, so V1 does not expose native web search while paired.
501+ continue
502+ }
503+ next[name] = t
504+ }
505+ if active {
506+ sender := telegramSenderAdapter{bridge: i.telegramBridge}
507+ next["telegram_send_image"] = &tools.TelegramSendImageTool{
508+ CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
509+ }
510+ next["telegram_send_file"] = &tools.TelegramSendFileTool{
511+ CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
512+ }
513+ }
514+ i.agent.SetTools(next)
515+ _, webSearchAvailable := next["web_search"]
516+ i.setWebSearchAvailable(webSearchAvailable && !active)
517+}
518+func (i *Interactive) telegramStatus() {
519+ var msg string
520+ if i.telegramBridge != nil && i.telegramBridge.Active() {
521+ s := i.telegramBridge.State()
522+ msg = "telegram: connected (tui bridge)"
523+ if s.Username != "" {
524+ msg += " as @" + s.Username
525+ }
526+ if s.PairedID != 0 {
527+ msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
528+ } else {
529+ msg += " - awaiting pairing"
530+ }
531+ } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
532+ msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
533+ } else {
534+ cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
535+ if cfg.BotToken == "" {
536+ msg = "telegram: not configured. run `zut telegram-bot setup` first."
537+ } else {
538+ msg = "telegram: disconnected"
539+ if cfg.BotUsername != "" {
540+ msg += " (@" + cfg.BotUsername + " ready to connect)"
541+ }
542+ }
543+ }
544+ i.mu.Lock()
545+ i.statusOK = msg
546+ i.statusErr = ""
547+ i.mu.Unlock()
548+ i.invalidate()
549+}
550+func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
551+ h.iv.SubmitOrQueue(prompt, images)
552+}
553+func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
554+func (h *telegramHost) Status() string {
555+ h.iv.mu.Lock()
556+ providerName := h.iv.cfg.Provider
557+ model := h.iv.cfg.Model
558+ cwd := h.iv.cfg.CWD
559+ usage := h.iv.cumUsage
560+ subscription := h.iv.cfg.AuthMethod == "oauth"
561+ ctxUsed := h.iv.lastCtxInput
562+ busy := h.iv.busy
563+ queued := len(h.iv.queued)
564+ h.iv.mu.Unlock()
565+
566+ ctxMax := 0
567+ if m, err := provider.FindModel(providerName, model); err == nil {
568+ ctxMax = m.ContextWindow
569+ }
570+ return telegram.FormatStatus(telegram.StatusSnapshot{
571+ Provider: providerName,
572+ Model: model,
573+ CWD: cwd,
574+ Usage: usage,
575+ Subscription: subscription,
576+ ContextUsed: ctxUsed,
577+ ContextMax: ctxMax,
578+ Busy: busy,
579+ Queued: queued,
580+ })
581+}
582+func (h *telegramHost) Notify(level, message string) {
583+ h.iv.mu.Lock()
584+ switch level {
585+ case "error", "warn":
586+ h.iv.statusErr = message
587+ h.iv.statusOK = ""
588+ default:
589+ h.iv.statusOK = message
590+ h.iv.statusErr = ""
591+ }
592+ h.iv.mu.Unlock()
593+ h.iv.invalidate()
594+}
595+func (i *Interactive) openSessionOpsDialog() {
596+ items := []sessionOpsItem{
597+ {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
598+ {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
599+ {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
600+ {label: "tree", action: "tree", hint: "switch between branches in this directory"},
601+ }
602+ i.sessionOpsDialog.Open(items)
603+ i.invalidate()
604+}
605+func (i *Interactive) doSessionOp(action, arg string) {
606+ switch action {
607+ case "export":
608+ i.doSessionExport(arg)
609+ case "import":
610+ i.doSessionImport(arg)
611+ case "fork":
612+ i.doSessionFork()
613+ case "tree":
614+ i.doSessionTree()
615+ default:
616+ i.mu.Lock()
617+ i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
618+ i.mu.Unlock()
619+ i.invalidate()
620+ }
621+}
622+func (i *Interactive) doSessionExport(dst string) {
623+ src := i.currentSessionPath()
624+ if src == "" {
625+ i.mu.Lock()
626+ i.statusErr = "export: no session is active (running with --no-session?)"
627+ i.mu.Unlock()
628+ i.invalidate()
629+ return
630+ }
631+ // Persist any in-memory agent messages to the session file so
632+ // the export carries the full conversation. Without this, the
633+ // default lazy-flush-at-exit strategy leaves most of a running
634+ // session unwritten and the export ends up with only the meta.
635+ if i.cfg.FlushSession != nil {
636+ i.cfg.FlushSession()
637+ }
638+ dst = unquotePath(dst)
639+ if dst == "" {
640+ dst = defaultExportDir()
641+ } else {
642+ dst = expandTilde(dst)
643+ }
644+ out, err := core.ExportSession(src, dst)
645+ if err != nil {
646+ i.mu.Lock()
647+ i.statusErr = "export: " + err.Error()
648+ i.mu.Unlock()
649+ i.invalidate()
650+ return
651+ }
652+ i.mu.Lock()
653+ i.statusOK = "exported session to " + friendlyPath(out)
654+ i.statusErr = ""
655+ i.mu.Unlock()
656+ i.invalidate()
657+}
658+func (i *Interactive) doSessionImport(src string) {
659+ src = unquotePath(src)
660+ if src == "" {
661+ i.mu.Lock()
662+ i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
663+ i.mu.Unlock()
664+ i.invalidate()
665+ return
666+ }
667+ src = expandTilde(src)
668+ if _, err := os.Stat(src); err != nil {
669+ i.mu.Lock()
670+ i.statusErr = "import: " + err.Error()
671+ i.mu.Unlock()
672+ i.invalidate()
673+ return
674+ }
675+ newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
676+ if err != nil {
677+ i.mu.Lock()
678+ i.statusErr = "import: " + err.Error()
679+ i.mu.Unlock()
680+ i.invalidate()
681+ return
682+ }
683+ if i.cfg.LoadSession == nil {
684+ i.mu.Lock()
685+ i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
686+ i.statusErr = ""
687+ i.mu.Unlock()
688+ i.invalidate()
689+ return
690+ }
691+ i.markSessionTitleSwitching()
692+ if err := i.cfg.LoadSession(newPath); err != nil {
693+ i.restoreFailedSessionTitle()
694+ i.mu.Lock()
695+ i.statusErr = "import: load failed: " + err.Error()
696+ i.mu.Unlock()
697+ i.invalidate()
698+ return
699+ }
700+ i.restoreLoadedSessionTitle()
701+ state := i.restoreCurrentCompactHandoff()
702+ i.mu.Lock()
703+ i.statusOK = "imported and switched to session " + friendlyPath(newPath)
704+ i.statusErr = ""
705+ if i.agent != nil {
706+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
707+ i.cumUsage = i.agent.Cost()
708+ last := i.agent.LastTurnUsage()
709+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
710+ if len(i.view.Messages) > initialResumeTailLimit {
711+ i.view.TailLimit = initialResumeTailLimit
712+ } else {
713+ i.view.TailLimit = 0
714+ }
715+ i.view.InvalidateRenderCache()
716+ }
717+ i.mu.Unlock()
718+ i.invalidate()
719+ if state.reason != compactContinuationNone {
720+ i.startRestoredCompactHandoff(i.runCtx)
721+ }
722+}
723+func defaultExportDir() string {
724+ home, err := os.UserHomeDir()
725+ if err != nil || home == "" {
726+ return os.TempDir()
727+ }
728+ downloads := filepath.Join(home, "Downloads")
729+ if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
730+ return downloads
731+ }
732+ return home
733+}
734+func expandTilde(p string) string {
735+ if p == "" || p[0] != '~' {
736+ return p
737+ }
738+ home, err := os.UserHomeDir()
739+ if err != nil || home == "" {
740+ return p
741+ }
742+ if len(p) == 1 {
743+ return home
744+ }
745+ if p[1] == '/' || p[1] == filepath.Separator {
746+ return filepath.Join(home, p[2:])
747+ }
748+ return p
749+}
750+func unquotePath(p string) string {
751+ p = strings.TrimSpace(p)
752+ if len(p) >= 2 {
753+ first := p[0]
754+ last := p[len(p)-1]
755+ if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
756+ p = p[1 : len(p)-1]
757+ }
758+ }
759+ return p
760+}
761+func friendlyPath(p string) string {
762+ home, err := os.UserHomeDir()
763+ if err != nil || home == "" {
764+ return p
765+ }
766+ if strings.HasPrefix(p, home+string(filepath.Separator)) {
767+ return "~" + p[len(home):]
768+ }
769+ return p
770+}
771+func (i *Interactive) doSessionFork() {
772+ path := i.currentSessionPath()
773+ if path == "" {
774+ i.mu.Lock()
775+ i.statusErr = "fork: no session is active (running with --no-session?)"
776+ i.mu.Unlock()
777+ i.invalidate()
778+ return
779+ }
780+ i.mu.Lock()
781+ busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
782+ queued := len(i.queued) != 0
783+ ag := i.agent
784+ i.mu.Unlock()
785+ if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
786+ i.mu.Lock()
787+ i.statusErr = "fork: wait until the current turn and queued messages finish"
788+ i.mu.Unlock()
789+ i.invalidate()
790+ return
791+ }
792+ if i.cfg.FlushSession != nil {
793+ i.cfg.FlushSession()
794+ }
795+ snapshot, err := core.ReadSessionSnapshot(path)
796+ if err != nil {
797+ i.mu.Lock()
798+ i.statusErr = "fork: " + err.Error()
799+ i.mu.Unlock()
800+ i.invalidate()
801+ return
802+ }
803+ msgs := snapshot.Messages
804+ if len(msgs) == 0 {
805+ i.mu.Lock()
806+ i.statusErr = "fork: transcript is empty; nothing to fork from"
807+ i.mu.Unlock()
808+ i.invalidate()
809+ return
810+ }
811+ i.pendingFork = true
812+ i.jumpDialog.Open(msgs, "")
813+ i.invalidate()
814+}
815+func (i *Interactive) doSessionTree() {
816+ i.openSessionTree()
817+}
818+func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
819+ target := sessionTreeTarget{
820+ SourcePath: src,
821+ EffectiveIndex: msgIdx,
822+ SelectionBoundary: msgIdx + 1,
823+ Role: role,
824+ UserDraft: prompt,
825+ Boundary: sessionTreeMessageBoundary,
826+ }
827+ if role == provider.RoleUser {
828+ target.SelectionBoundary = msgIdx
829+ }
830+ i.applySessionTreeTarget(target, turnNo)
831+}
832+func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
833+ // Selection is a state-changing operation too. Use the selection gate so
834+ // a queued/busy turn cannot race the hidden branch or accidentally submit
835+ // a provider turn while the session is being swapped. The open gate cannot
836+ // be reused here because the tree dialog itself owns the keyboard.
837+ if !i.canCommitSessionTreeSelection() {
838+ i.setSessionTreeError("tree: session branching is not available in this build")
839+ return
840+ }
841+ src := target.SourcePath
842+ if src == "" {
843+ src = i.currentSessionPath()
844+ }
845+ if src == "" {
846+ i.setSessionTreeError("tree: no session is active")
847+ return
848+ }
849+
850+ // Read and validate the selected row before flushing or creating anything.
851+ // This gives tool-call/result pairs an atomic boundary and prevents a
852+ // malformed selection from changing the active session. Historical rows
853+ // come from a pre-compaction segment; current rows use the effective
854+ // snapshot that the running agent resumes from.
855+ var msgs []provider.Message
856+ var historicalSegment core.SessionHistorySegment
857+ if target.Historical {
858+ history, err := core.ReadSessionHistory(src)
859+ if err != nil {
860+ i.setSessionTreeError("tree: read selection: " + err.Error())
861+ return
862+ }
863+ if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
864+ i.setSessionTreeError("tree: selected history segment is unavailable")
865+ return
866+ }
867+ historicalSegment = history.Segments[target.HistorySegment]
868+ msgs = historicalSegment.Messages
869+ } else {
870+ sess, current, err := core.OpenSession(src)
871+ if err != nil {
872+ i.setSessionTreeError("tree: read selection: " + err.Error())
873+ return
874+ }
875+ if err := sess.Close(); err != nil {
876+ i.setSessionTreeError("tree: close selection: " + err.Error())
877+ return
878+ }
879+ msgs = current
880+ }
881+ selection, err := sessionTreeSelection(msgs, target)
882+ if err != nil {
883+ i.setSessionTreeError("tree: " + err.Error())
884+ return
885+ }
886+ if i.cfg.FlushSession != nil {
887+ i.cfg.FlushSession()
888+ }
889+ var newPath string
890+ if target.Historical {
891+ newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
892+ } else {
893+ newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
894+ }
895+ if err != nil {
896+ i.setSessionTreeError("tree: " + err.Error())
897+ return
898+ }
899+ i.markSessionTitleSwitching()
900+ if err := i.cfg.LoadSession(newPath); err != nil {
901+ i.restoreFailedSessionTitle()
902+ i.setSessionTreeError("tree: checkout failed: " + err.Error())
903+ return
904+ }
905+ state := i.restoreCurrentCompactHandoff()
906+
907+ // LoadSession is intentionally the old func(string) error callback: the
908+ // CLI and embedders already own agent/session swapping. Refresh the view
909+ // from the swapped agent here instead of requiring a new callback result.
910+ i.resetSessionTitleForFreshBranch()
911+ i.scrollToBottom()
912+ i.mu.Lock()
913+ i.lastCtxInput = 0
914+ if i.agent != nil {
915+ i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
916+ i.cumUsage = i.agent.Cost()
917+ last := i.agent.LastTurnUsage()
918+ i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
919+ i.view.InvalidateRenderCache()
920+ }
921+ i.toolCalls = map[string]*tui.ToolCallView{}
922+ i.toolOrder = nil
923+ i.toolGate = map[string]int{}
924+ i.extNotes = nil
925+ if selection.restoreDraft {
926+ i.ed.SetValue(selection.draftText)
927+ i.clipboardImages = selection.images
928+ i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
929+ } else {
930+ i.ed.Clear()
931+ i.clipboardImages = nil
932+ i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
933+ }
934+ i.statusErr = ""
935+ i.mu.Unlock()
936+ i.scrollToBottom()
937+ // Selection does not submit, start, or queue the restored user draft; only
938+ // a later explicit Enter does that. An inherited compact handoff may resume
939+ // its hidden continuation below when this branch has the complete effective
940+ // transcript, without submitting the restored draft.
941+ i.invalidate()
942+ if state.reason != compactContinuationNone {
943+ i.startRestoredCompactHandoff(i.runCtx)
944+ }
945+}
946+func (i *Interactive) applyForkSelection(msgIdx int) {
947+ i.pendingFork = false
948+ src := i.currentSessionPath()
949+ if src == "" {
950+ i.mu.Lock()
951+ i.statusErr = "fork: no session is active"
952+ i.mu.Unlock()
953+ i.invalidate()
954+ return
955+ }
956+ if i.cfg.FlushSession != nil {
957+ i.cfg.FlushSession()
958+ }
959+ // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
960+ // the selected user message is included.
961+ upTo := msgIdx + 1
962+ newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
963+ if err != nil {
964+ i.mu.Lock()
965+ i.statusErr = "fork: " + err.Error()
966+ i.mu.Unlock()
967+ i.invalidate()
968+ return
969+ }
970+ if i.cfg.LoadSession == nil {
971+ i.mu.Lock()
972+ i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
973+ i.statusErr = ""
974+ i.mu.Unlock()
975+ i.invalidate()
976+ return
977+ }
978+ i.markSessionTitleSwitching()
979+ if err := i.cfg.LoadSession(newPath); err != nil {
980+ i.restoreFailedSessionTitle()
981+ i.mu.Lock()
982+ i.statusErr = "fork: switch failed: " + err.Error()
983+ i.mu.Unlock()
984+ i.invalidate()
985+ return
986+ }
987+ state := i.restoreCurrentCompactHandoff()
988+ i.resetSessionTitleForFreshBranch()
989+ i.mu.Lock()
990+ i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
991+ i.statusErr = ""
992+ i.mu.Unlock()
993+ i.invalidate()
994+ if state.reason != compactContinuationNone {
995+ i.startRestoredCompactHandoff(i.runCtx)
996+ }
997+}
998+func formatInt(n int) string {
999+ return fmt.Sprintf("%d", n)
1000+}
1001+func assistantText(m provider.Message) string {
1002+ var sb strings.Builder
1003+ for _, c := range m.Content {
1004+ if tb, ok := c.(provider.TextBlock); ok {
1005+ if sb.Len() > 0 {
1006+ sb.WriteByte('\n')
1007+ }
1008+ sb.WriteString(tb.Text)
1009+ }
1010+ }
1011+ return sb.String()
1012+}
1013+func (i *Interactive) resetTranscriptRenderLocked() {
1014+ i.view.InvalidateRenderCache()
1015+ i.chatCacheValid = false
1016+ i.prevChatLen = 0
1017+ i.prevChatCols = 0
1018+ i.prevChatRows = 0
1019+ i.prevScrollOffset = 0
1020+ i.requestRendererInvalidate()
1021+}
1022+func (i *Interactive) resetStreamingStateLocked() {
1023+ i.streaming.Reset()
1024+ i.streamPending = i.streamPending[:0]
1025+ i.streamFlushPending = false
1026+ i.streamOn = false
1027+ i.openAllToolGatesLocked()
1028+}
1029+func (i *Interactive) openAllToolGatesLocked() {
1030+ for id := range i.toolGate {
1031+ i.toolGate[id] = 0
1032+ }
1033+}
1034+func (i *Interactive) gateToolLocked(id string) {
1035+ if _, ok := i.toolGate[id]; ok {
1036+ return
1037+ }
1038+ if !i.streamOn {
1039+ i.toolGate[id] = 0
1040+ return
1041+ }
1042+ i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
1043+}
1044+func (i *Interactive) toolGateOpenLocked(id string) bool {
1045+ gate, ok := i.toolGate[id]
1046+ if !ok || gate == 0 {
1047+ return true
1048+ }
1049+ return i.streaming.Len() >= gate
1050+}
1051+func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
1052+ if i.cfg.OnAssistant != nil {
1053+ i.cfg.OnAssistant(m)
1054+ }
1055+ if i.telegramBridge != nil && i.telegramBridge.Active() {
1056+ var sb strings.Builder
1057+ for _, c := range m.Content {
1058+ if tb, ok := c.(provider.TextBlock); ok {
1059+ if sb.Len() > 0 {
1060+ sb.WriteString("\n")
1061+ }
1062+ sb.WriteString(tb.Text)
1063+ }
1064+ }
1065+ if text := sb.String(); strings.TrimSpace(text) != "" {
1066+ go i.telegramBridge.OnAssistantText(text)
1067+ }
1068+ }
1069+}
1070+func (i *Interactive) runStreamPacer(ctx context.Context) {
1071+ t := time.NewTicker(paintPaceInterval)
1072+ defer t.Stop()
1073+ for {
1074+ select {
1075+ case <-ctx.Done():
1076+ return
1077+ case <-t.C:
1078+ i.mu.Lock()
1079+ if len(i.streamPending) == 0 {
1080+ // EvAssistantMessage already fired but the pacer
1081+ // was still draining a tick ago. Everything is now
1082+ // painted; clear the streaming flags so the next
1083+ // redraw shows the finalised transcript message
1084+ // and hides the streaming overlay.
1085+ if i.streamFlushPending {
1086+ i.streamFlushPending = false
1087+ i.streaming.Reset()
1088+ i.streamOn = false
1089+ i.openAllToolGatesLocked()
1090+ i.mu.Unlock()
1091+ i.invalidate()
1092+ continue
1093+ }
1094+ i.mu.Unlock()
1095+ continue
1096+ }
1097+ n := paintPaceRate
1098+ if n > len(i.streamPending) {
1099+ n = len(i.streamPending)
1100+ }
1101+ i.streaming.WriteString(string(i.streamPending[:n]))
1102+ i.streamPending = i.streamPending[n:]
1103+ i.mu.Unlock()
1104+ i.invalidate()
1105+ }
1106+ }
1107+}
A packages/agent/modes/interactive_settings.go +1237 -0
@@ -0,0 +1,1237 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "runtime"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/provider"
11+ "github.com/bnema/zut/packages/tui"
12+)
13+
14+func effectiveImageProtocol(override *bool) tui.ImageProtocol {
15+ detected := tui.DetectImageProtocol()
16+ if override == nil {
17+ return detected
18+ }
19+ if !*override {
20+ return tui.ImageProtocolNone
21+ }
22+ return detected
23+}
24+func imageProtocolName(p tui.ImageProtocol) string {
25+ switch p {
26+ case tui.ImageProtocolKitty:
27+ return "kitty/ghostty"
28+ case tui.ImageProtocolITerm2:
29+ return "iTerm2"
30+ default:
31+ return "none"
32+ }
33+}
34+func onOff(v bool) string {
35+ if v {
36+ return "enabled"
37+ }
38+ return "disabled"
39+}
40+func (i *Interactive) applyInputCursorColor() {
41+ if i == nil || i.cfg.Terminal == nil {
42+ return
43+ }
44+ color := tui.Color256(15)
45+ if i.cursorDimmed {
46+ color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
47+ }
48+ _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
49+}
50+func (i *Interactive) setInputCursorDimmed(dimmed bool) {
51+ if i == nil || i.cursorDimmed == dimmed {
52+ return
53+ }
54+ i.cursorDimmed = dimmed
55+ i.applyInputCursorColor()
56+}
57+func reasoningSettingOptions(levels []string) []settingsOption {
58+ descriptions := map[string]string{
59+ "": "no reasoning",
60+ "minimum": "very brief (~1k tokens)",
61+ "low": "light (~2k tokens)",
62+ "medium": "moderate (~8k tokens)",
63+ "high": "deep (~16k tokens)",
64+ "xhigh": "extra-high effort",
65+ "max": "unconstrained effort",
66+ }
67+ options := make([]settingsOption, 0, len(levels))
68+ for _, level := range levels {
69+ label := level
70+ if label == "" {
71+ label = "off"
72+ }
73+ options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
74+ }
75+ return options
76+}
77+func (i *Interactive) reasoningSettingItem() settingsItem {
78+ levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
79+ reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
80+ desc := "reasoning depth for reasoning-capable models"
81+ hint := ""
82+ if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
83+ levels = provider.AvailableReasoningLevels(m)
84+ reasoning = provider.ClampReasoningForModel(m, reasoning)
85+ if !m.Reasoning {
86+ hint = "current model does not support reasoning"
87+ desc += "; current model does not support reasoning"
88+ } else if len(levels) == 1 {
89+ hint = "current model has no adjustable reasoning levels"
90+ desc += "; current model has no adjustable reasoning levels"
91+ } else {
92+ desc += "; only levels supported by the current model are shown"
93+ }
94+ }
95+ options := reasoningSettingOptions(levels)
96+ choice := 0
97+ for idx, opt := range options {
98+ if opt.value == reasoning {
99+ choice = idx
100+ break
101+ }
102+ }
103+
104+ return settingsItem{
105+ key: "reasoning",
106+ label: "reasoning level",
107+ desc: desc,
108+ options: options,
109+ choice: choice,
110+ hint: hint,
111+ }
112+}
113+func (i *Interactive) openReasoningDialog() {
114+ i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
115+}
116+func (i *Interactive) openSettingsDialog() {
117+ detected := tui.DetectImageProtocol()
118+ imgEnabled := detected != tui.ImageProtocolNone
119+ if i.cfg.InlineImagesEnabled != nil {
120+ imgEnabled = *i.cfg.InlineImagesEnabled
121+ }
122+ imgDisabled := detected == tui.ImageProtocolNone
123+ imgHint := ""
124+ if imgDisabled {
125+ imgEnabled = false
126+ imgHint = "this terminal does not support inline images"
127+ } else {
128+ imgHint = "terminal supports " + imageProtocolName(detected)
129+ }
130+
131+ terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
132+ terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
133+ autoSubagents := false
134+ if i.cfg.AutoSubagentsEnabled != nil {
135+ autoSubagents = *i.cfg.AutoSubagentsEnabled
136+ }
137+ autoSubagentsDisabled := !i.autoSubagentsAvailable()
138+ autoSubagentsHint := i.autoSubagentsUnavailableHint()
139+ if autoSubagentsDisabled {
140+ autoSubagents = false
141+ }
142+
143+ ponytailEnabled := i.ponytailEnabled()
144+ ponytailDisabled := !i.ponytailSettingsAvailable()
145+ ponytailHint := ""
146+ if ponytailDisabled {
147+ ponytailHint = "requires persistent settings and live prompt refresh support"
148+ }
149+
150+ webSearchEnabled := i.webSearchEffectiveEnabled()
151+ webSearchDisabled := !i.webSearchSettingsAvailable()
152+ webSearchHint := i.webSearchUnavailableHint()
153+
154+ fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
155+ fastModeHint := "OpenAI service tier"
156+ if !provider.SupportsFastMode(i.cfg.Provider) {
157+ fastModeHint = "only supported for OpenAI providers"
158+ }
159+ lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
160+ subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
161+
162+ jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
163+ recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
164+ respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
165+ compactMode := i.compactModeEnabled()
166+ showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
167+ inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
168+ statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
169+ workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
170+ quickItems := i.quickModelSettingItems()
171+
172+ autoCompactOptions := []settingsOption{
173+ {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
174+ {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
175+ {value: "80", label: "80%", desc: "compact with moderate context headroom"},
176+ {value: "85", label: "85%", desc: "default balance between history and headroom"},
177+ {value: "90", label: "90%", desc: "retain more history before compacting"},
178+ {value: "95", label: "95%", desc: "retain most history before compacting"},
179+ }
180+ autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
181+ autoCompactChoice := 0
182+ for idx, opt := range autoCompactOptions {
183+ if opt.value == strconv.Itoa(autoCompactThreshold) {
184+ autoCompactChoice = idx
185+ break
186+ }
187+ }
188+
189+ reasoningItem := i.reasoningSettingItem()
190+
191+ themeName := i.cfg.ThemeName
192+ if themeName == "" {
193+ themeName = "auto"
194+ }
195+ if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) {
196+ themeName = "auto"
197+ i.cfg.ThemeName = ""
198+ if i.cfg.SettingsStore != nil {
199+ _ = i.cfg.SettingsStore.SetTheme("auto")
200+ }
201+ i.applyThemeNow("auto")
202+ }
203+ themeOptions := []settingsOption{}
204+ themeChoice := 0
205+ availableThemes := tui.AvailableThemes(i.cfg.ZutHome)
206+ if i.cfg.ExtensionThemes != nil {
207+ availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
208+ }
209+ for idx, opt := range availableThemes {
210+ themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
211+ if opt.Value == themeName {
212+ themeChoice = idx
213+ }
214+ }
215+
216+ inputStyleOptions := []settingsOption{
217+ {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
218+ {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
219+ {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
220+ }
221+ inputStyleChoice := 0
222+ for idx, opt := range inputStyleOptions {
223+ if opt.value == inputStyle {
224+ inputStyleChoice = idx
225+ break
226+ }
227+ }
228+ statusPositionOptions := []settingsOption{
229+ {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
230+ {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
231+ }
232+ statusPositionChoice := 0
233+ for idx, opt := range statusPositionOptions {
234+ if opt.value == statusPosition {
235+ statusPositionChoice = idx
236+ break
237+ }
238+ }
239+
240+ workingPositionOptions := []settingsOption{
241+ {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
242+ {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
243+ }
244+ workingPositionChoice := 0
245+ for idx, opt := range workingPositionOptions {
246+ if opt.value == workingPosition {
247+ workingPositionChoice = idx
248+ break
249+ }
250+ }
251+
252+ items := []settingsItem{
253+ {
254+ key: "inline_images_enabled",
255+ label: "render images when supported",
256+ desc: "draw screenshots inline instead of showing a text placeholder",
257+ value: imgEnabled,
258+ disabled: imgDisabled,
259+ hint: imgHint,
260+ },
261+ {
262+ key: "terminal_alerts_enabled",
263+ label: "terminal alerts",
264+ desc: "ring the terminal when the main session needs attention or an extension raises an alert",
265+ value: terminalAlerts,
266+ },
267+ {
268+ key: "terminal_title_enabled",
269+ label: "AI terminal titles",
270+ desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
271+ value: terminalTitles,
272+ },
273+ {
274+ key: "auto_subagents_enabled",
275+ label: "Subagent Orchestrator",
276+ desc: "automatically delegate coding work to sub-agents; tools stay available when disabled",
277+ value: autoSubagents,
278+ disabled: autoSubagentsDisabled,
279+ hint: autoSubagentsHint,
280+ },
281+ {
282+ key: "ponytail_enabled",
283+ label: "ponytail coding mode",
284+ desc: "apply compact coding guidance that favors small, validated, maintainable changes",
285+ value: ponytailEnabled,
286+ disabled: ponytailDisabled,
287+ hint: ponytailHint,
288+ },
289+ {
290+ key: "web_search_enabled",
291+ label: "web search",
292+ desc: "search DuckDuckGo for bounded public-web sources; sends queries to the public backend",
293+ value: webSearchEnabled,
294+ disabled: webSearchDisabled,
295+ hint: webSearchHint,
296+ },
297+ {
298+ key: "fast_mode",
299+ label: "fast mode",
300+ desc: "request the provider's fast tier where supported; unsupported providers return an error",
301+ value: fastMode,
302+ hint: fastModeHint,
303+ },
304+ {
305+ key: "lsp_enabled",
306+ label: "lsp in main session",
307+ desc: "enable the lsp tool and code diagnostics for the main agent",
308+ value: lspEnabled,
309+ },
310+ {
311+ key: "subagent_lsp_enabled",
312+ label: "lsp in sub-agents",
313+ desc: "allow newly spawned background sub-agents to use the lsp tool",
314+ value: subagentLSPEnabled,
315+ },
316+ {
317+ key: "auto_compact_threshold",
318+ label: "auto-compact threshold",
319+ desc: "choose how full the model context can get before zut condenses conversation history",
320+ options: autoCompactOptions,
321+ choice: autoCompactChoice,
322+ },
323+ {
324+ key: "jail_by_default",
325+ label: "jail new sessions by default",
326+ desc: "confine tools to the session working directory unless /unjail is used",
327+ value: jailByDefault,
328+ },
329+ {
330+ key: "recursive_file_suggest",
331+ label: "recursive @-file search",
332+ desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
333+ value: recursiveFiles,
334+ },
335+ {
336+ key: "respect_gitignore",
337+ label: "hide gitignored files in @-picker",
338+ desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
339+ value: respectGitignore,
340+ },
341+ {
342+ key: "compact_mode",
343+ label: "compact transcript rendering",
344+ desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
345+ value: compactMode,
346+ },
347+ {
348+ key: "show_instructions_at_startup",
349+ label: "show loaded resources at startup",
350+ desc: "list loaded context files, extensions, and user-installed skills above the transcript",
351+ value: showInstructions,
352+ },
353+ {
354+ key: "tui_settings",
355+ label: "tui settings",
356+ desc: "choose input chrome in its order around the prompt",
357+ children: []settingsItem{
358+ {
359+ key: "tui_status_position",
360+ label: "status position",
361+ desc: "place model, usage, and working directory above or below the input",
362+ options: statusPositionOptions,
363+ choice: statusPositionChoice,
364+ },
365+ {
366+ key: "tui_working_position",
367+ label: "working spinner position",
368+ desc: "place the working spinner above or below the input",
369+ options: workingPositionOptions,
370+ choice: workingPositionChoice,
371+ },
372+ {
373+ key: "tui_input_style",
374+ label: "input style",
375+ desc: "choose between the plain prompt, lines, and a block input area",
376+ options: inputStyleOptions,
377+ choice: inputStyleChoice,
378+ },
379+ },
380+ },
381+ reasoningItem,
382+ {
383+ key: "theme",
384+ label: "color theme",
385+ desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
386+ options: themeOptions,
387+ choice: themeChoice,
388+ },
389+ }
390+ if len(quickItems) > 0 {
391+ items = append(items, settingsItem{
392+ key: "quick_models",
393+ label: "model shortcuts",
394+ desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
395+ children: quickItems,
396+ })
397+ }
398+ i.settingsDialog.Open(items)
399+}
400+func (i *Interactive) applySettingChange(act settingsAction) {
401+ switch {
402+ case strings.HasPrefix(act.Key, "quick_model_"):
403+ i.applyQuickModelSetting(act.Key, act.StringValue)
404+ case act.Key == "reasoning":
405+ i.applyReasoningSetting(act.StringValue)
406+ case act.Key == "theme":
407+ i.applyThemeSetting(act.StringValue)
408+ case act.Key == "auto_compact_threshold":
409+ i.applyAutoCompactThresholdSetting(act.StringValue)
410+ case act.Key == "tui_input_style":
411+ i.applyTUIInputStyleSetting(act.StringValue)
412+ case act.Key == "tui_status_position":
413+ i.applyTUIStatusPositionSetting(act.StringValue)
414+ case act.Key == "tui_working_position":
415+ i.applyTUIWorkingPositionSetting(act.StringValue)
416+ default:
417+ i.applySettingToggle(act.Key, act.Value)
418+ }
419+}
420+func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
421+ threshold, err := strconv.Atoi(value)
422+ if err != nil {
423+ return
424+ }
425+ threshold = NormalizeAutoCompactThreshold(&threshold)
426+ if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
427+ if err := store.SetAutoCompactThreshold(threshold); err != nil {
428+ i.mu.Lock()
429+ i.statusErr = "settings: " + err.Error()
430+ i.mu.Unlock()
431+ return
432+ }
433+ }
434+ i.mu.Lock()
435+ i.cfg.AutoCompactThreshold = &threshold
436+ if threshold == 0 {
437+ i.statusOK = "auto-compact threshold off"
438+ } else {
439+ i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
440+ }
441+ i.statusErr = ""
442+ i.mu.Unlock()
443+ i.invalidate()
444+}
445+func (i *Interactive) quickModelSettingItems() []settingsItem {
446+ if len(i.cfg.QuickModelShortcuts) < 9 {
447+ next := make([]QuickModelShortcut, 9)
448+ copy(next, i.cfg.QuickModelShortcuts)
449+ i.cfg.QuickModelShortcuts = next
450+ }
451+ items := make([]settingsItem, 0, 9)
452+ for slot := 1; slot <= 9; slot++ {
453+ items = append(items, i.quickModelSettingItem(slot))
454+ }
455+ return items
456+}
457+func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
458+ current := QuickModelShortcut{}
459+ if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
460+ current = i.cfg.QuickModelShortcuts[slot-1]
461+ }
462+ hint := "not assigned"
463+ if current.Provider != "" && current.Model != "" {
464+ hint = current.Provider + " / " + current.Model
465+ }
466+ return settingsItem{
467+ key: "quick_model_" + strconv.Itoa(slot),
468+ label: "model " + strconv.Itoa(slot),
469+ desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
470+ picker: true,
471+ hint: hint,
472+ }
473+}
474+func (i *Interactive) compactModeEnabled() bool {
475+ return i.cfg.CompactMode != nil && *i.cfg.CompactMode
476+}
477+func quickModelShortcutSlot(k tui.Key) int {
478+ if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
479+ return 0
480+ }
481+ if runtime.GOOS == "darwin" {
482+ if !k.Super && !k.Ctrl {
483+ return 0
484+ }
485+ } else if !k.Ctrl {
486+ return 0
487+ }
488+ return int(k.Rune - '0')
489+}
490+func quickModelShortcutPrefix() string {
491+ return "Ctrl"
492+}
493+func quickModelShortcutLabel(slot int) string {
494+ return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
495+}
496+func (i *Interactive) openQuickModelPicker(slot int) {
497+ if slot < 1 || slot > 9 {
498+ return
499+ }
500+ i.quickModelAssign = slot
501+ current := i.cfg.Model
502+ if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
503+ current = i.cfg.QuickModelShortcuts[slot-1].Model
504+ }
505+ var loggedIn []string
506+ if i.cfg.LoggedInProviders != nil {
507+ loggedIn = i.cfg.LoggedInProviders()
508+ }
509+ i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
510+}
511+func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
512+ i.setQuickModelShortcut(slot, providerName, model)
513+}
514+func (i *Interactive) applyQuickModelShortcut(slot int) {
515+ if slot < 1 || slot > 9 {
516+ return
517+ }
518+ if i.busy {
519+ i.mu.Lock()
520+ i.statusErr = "cannot switch model while a turn is running"
521+ i.statusOK = ""
522+ i.mu.Unlock()
523+ i.invalidate()
524+ return
525+ }
526+ if len(i.cfg.QuickModelShortcuts) < slot {
527+ i.mu.Lock()
528+ i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
529+ i.statusOK = ""
530+ i.mu.Unlock()
531+ i.invalidate()
532+ return
533+ }
534+ shortcut := i.cfg.QuickModelShortcuts[slot-1]
535+ if shortcut.Provider == "" || shortcut.Model == "" {
536+ i.mu.Lock()
537+ i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
538+ i.statusOK = ""
539+ i.mu.Unlock()
540+ i.invalidate()
541+ return
542+ }
543+ i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
544+ i.invalidate()
545+}
546+func (i *Interactive) applyQuickModelSetting(key, value string) {
547+ slotText := strings.TrimPrefix(key, "quick_model_")
548+ slot, err := strconv.Atoi(slotText)
549+ if err != nil || slot < 1 || slot > 9 {
550+ return
551+ }
552+ providerName, model := "", ""
553+ if value != "" {
554+ parts := strings.SplitN(value, "\t", 2)
555+ if len(parts) == 2 {
556+ providerName, model = parts[0], parts[1]
557+ }
558+ }
559+ i.setQuickModelShortcut(slot, providerName, model)
560+}
561+func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
562+ if len(i.cfg.QuickModelShortcuts) < slot {
563+ next := make([]QuickModelShortcut, slot)
564+ copy(next, i.cfg.QuickModelShortcuts)
565+ i.cfg.QuickModelShortcuts = next
566+ }
567+ i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
568+ if i.cfg.SettingsStore != nil {
569+ if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
570+ i.mu.Lock()
571+ i.statusErr = "settings: " + err.Error()
572+ i.mu.Unlock()
573+ return
574+ }
575+ }
576+ i.mu.Lock()
577+ if model == "" {
578+ i.statusOK = quickModelShortcutLabel(slot) + " cleared"
579+ } else {
580+ i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
581+ }
582+ i.statusErr = ""
583+ i.mu.Unlock()
584+ i.refreshQuickModelSettingsItem(slot)
585+ i.invalidate()
586+}
587+func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
588+ if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
589+ return
590+ }
591+ key := "quick_model_" + strconv.Itoa(slot)
592+ for idx, it := range i.settingsDialog.items {
593+ if it.key == key {
594+ i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
595+ return
596+ }
597+ }
598+}
599+func (i *Interactive) resetSettingsToggle(key string, value bool) {
600+ if i.settingsDialog == nil {
601+ return
602+ }
603+ for idx := range i.settingsDialog.items {
604+ if i.settingsDialog.items[idx].key == key {
605+ i.settingsDialog.items[idx].value = value
606+ return
607+ }
608+ }
609+}
610+func (i *Interactive) ponytailEnabled() bool {
611+ return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
612+}
613+func (i *Interactive) ponytailSettingsAvailable() bool {
614+ _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
615+ return persistent && i.cfg.RefreshPrompt != nil
616+}
617+func (i *Interactive) webSearchEnabled() bool {
618+ return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled
619+}
620+func (i *Interactive) webSearchToolAllowed() bool {
621+ return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed
622+}
623+func (i *Interactive) webSearchEffectiveEnabled() bool {
624+ return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled())
625+}
626+func (i *Interactive) webSearchSettingsAvailable() bool {
627+ _, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
628+ return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil
629+}
630+func (i *Interactive) webSearchUnavailableHint() string {
631+ if !i.webSearchToolAllowed() {
632+ return "unavailable in this session due to the launch-time tool capability ceiling"
633+ }
634+ if i.cfg.WebSearchInvocationOverride {
635+ return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools"
636+ }
637+ if !i.webSearchSettingsAvailable() {
638+ return "requires persistent settings and live tool refresh support"
639+ }
640+ return ""
641+}
642+func (i *Interactive) setWebSearchAvailable(available bool) {
643+ // Advance before publishing the new gate/child ceiling. A resolver that
644+ // captured the prior generation can no longer commit after this transition.
645+ // Callers may hold agentMu and i.mu; the callback must complete without
646+ // calling back into Interactive or requiring either lock.
647+ i.webSearchPolicyGeneration.Add(1)
648+ if i.cfg.SetWebSearchAvailable != nil {
649+ i.cfg.SetWebSearchAvailable(available)
650+ }
651+}
652+func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) {
653+ previous := i.webSearchEnabled()
654+ store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
655+ if !i.webSearchSettingsAvailable() || !persistent {
656+ i.resetSettingsToggle(key, i.webSearchEffectiveEnabled())
657+ i.mu.Lock()
658+ i.statusOK = ""
659+ i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint()
660+ i.mu.Unlock()
661+ return
662+ }
663+ if err := store.SetWebSearchEnabled(value); err != nil {
664+ i.resetSettingsToggle(key, previous)
665+ i.mu.Lock()
666+ i.statusOK = ""
667+ i.statusErr = "settings: " + err.Error()
668+ i.mu.Unlock()
669+ return
670+ }
671+ val := value
672+ i.cfg.WebSearchEnabled = &val
673+ // Revoke and advance the policy generation for both directions. Enabling
674+ // remains fail-closed until the freshly resolved registry commits, while a
675+ // stale resolve from before this persisted transition is rejected.
676+ i.stripWebSearchTool()
677+ if err := i.cfg.RefreshTools(); err != nil {
678+ // Refresh failure leaves the old registry's state unknown. Revoke both
679+ // live execution and generic-child inheritance before rollback.
680+ i.stripWebSearchTool()
681+ errMsg := "settings: web search tool refresh: " + err.Error()
682+ if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil {
683+ failClosed := false
684+ i.cfg.WebSearchEnabled = &failClosed
685+ i.resetSettingsToggle(key, false)
686+ errMsg += "; rollback persistence: " + rollbackErr.Error()
687+ } else {
688+ previousVal := previous
689+ i.cfg.WebSearchEnabled = &previousVal
690+ i.resetSettingsToggle(key, previous)
691+ if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
692+ errMsg += "; rollback refresh: " + refreshErr.Error()
693+ i.stripWebSearchTool()
694+ }
695+ }
696+ i.mu.Lock()
697+ i.statusOK = ""
698+ i.statusErr = errMsg
699+ i.mu.Unlock()
700+ return
701+ }
702+ i.mu.Lock()
703+ i.statusOK = "web search " + onOff(value)
704+ i.statusErr = ""
705+ i.mu.Unlock()
706+}
707+func (i *Interactive) stripWebSearchTool() {
708+ // Revoke first so a stale tool snapshotted by core cannot execute while
709+ // the advertised registry is being replaced.
710+ i.setWebSearchAvailable(false)
711+ i.agentMu.Lock()
712+ defer i.agentMu.Unlock()
713+ i.mu.Lock()
714+ defer i.mu.Unlock()
715+ if i.agent == nil {
716+ return
717+ }
718+ tools := i.agent.ToolsSnapshot()
719+ if _, ok := tools["web_search"]; !ok {
720+ return
721+ }
722+ delete(tools, "web_search")
723+ i.agent.SetTools(tools)
724+}
725+func (i *Interactive) applySettingToggle(key string, value bool) {
726+ // Every setting toggle forces a full repaint at the end — same
727+ // effect as the user pressing Ctrl+L — so any per-setting visual
728+ // change (image rendering, status copy, future toggles) lands
729+ // immediately instead of waiting for the next diff frame.
730+ defer func() {
731+ i.requestRendererClear()
732+ i.invalidate()
733+ }()
734+ switch key {
735+ case "inline_images_enabled":
736+ val := value
737+ i.cfg.InlineImagesEnabled = &val
738+ if i.cfg.SettingsStore != nil {
739+ if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
740+ i.mu.Lock()
741+ i.statusErr = "settings: " + err.Error()
742+ i.mu.Unlock()
743+ return
744+ }
745+ }
746+ i.mu.Lock()
747+ i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
748+ i.view.InvalidateRenderCache()
749+ i.statusOK = "inline image rendering " + onOff(value)
750+ i.statusErr = ""
751+ i.mu.Unlock()
752+ case "terminal_alerts_enabled":
753+ val := value
754+ i.mu.Lock()
755+ i.cfg.TerminalAlertsEnabled = &val
756+ i.mu.Unlock()
757+ if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
758+ if err := store.SetTerminalAlertsEnabled(value); err != nil {
759+ i.mu.Lock()
760+ i.statusErr = "settings: " + err.Error()
761+ i.mu.Unlock()
762+ return
763+ }
764+ }
765+ i.mu.Lock()
766+ i.statusOK = "terminal alerts " + onOff(value)
767+ i.statusErr = ""
768+ i.mu.Unlock()
769+ case "terminal_title_enabled":
770+ val := value
771+ var cancel context.CancelFunc
772+ i.mu.Lock()
773+ i.cfg.TerminalTitleEnabled = &val
774+ if value {
775+ i.writeTerminalTitleLocked(i.sessionTitle)
776+ } else {
777+ cancel = i.titleCancel
778+ i.titleCancel = nil
779+ i.titleVersion++
780+ i.writeTerminalTitleLocked("")
781+ }
782+ i.mu.Unlock()
783+ if cancel != nil {
784+ cancel()
785+ }
786+ if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
787+ if err := store.SetTerminalTitleEnabled(value); err != nil {
788+ i.mu.Lock()
789+ i.statusErr = "settings: " + err.Error()
790+ i.mu.Unlock()
791+ return
792+ }
793+ }
794+ i.mu.Lock()
795+ i.statusOK = "AI terminal titles " + onOff(value)
796+ i.statusErr = ""
797+ i.mu.Unlock()
798+ case "web_search_enabled":
799+ apply := func() { i.applyWebSearchSettingToggle(key, value) }
800+ if i.cfg.SessionTransition != nil {
801+ i.cfg.SessionTransition(apply)
802+ } else {
803+ apply()
804+ }
805+ case "ponytail_enabled":
806+ previous := i.ponytailEnabled()
807+ store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
808+ if !available || i.cfg.RefreshPrompt == nil {
809+ i.resetSettingsToggle(key, previous)
810+ i.mu.Lock()
811+ i.statusOK = ""
812+ i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
813+ i.mu.Unlock()
814+ return
815+ }
816+ if err := store.SetPonytailEnabled(value); err != nil {
817+ i.resetSettingsToggle(key, previous)
818+ i.mu.Lock()
819+ i.statusOK = ""
820+ i.statusErr = "settings: " + err.Error()
821+ i.mu.Unlock()
822+ return
823+ }
824+ val := value
825+ i.cfg.PonytailEnabled = &val
826+ if err := i.cfg.RefreshPrompt(); err != nil {
827+ errMsg := "settings: ponytail prompt refresh: " + err.Error()
828+ if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
829+ // The first write succeeded, so keep the in-memory setting at
830+ // the value that remains durable instead of claiming that the
831+ // rollback took effect.
832+ i.resetSettingsToggle(key, value)
833+ errMsg += "; rollback persistence: " + rollbackErr.Error()
834+ if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
835+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
836+ }
837+ } else {
838+ previousVal := previous
839+ i.cfg.PonytailEnabled = &previousVal
840+ i.resetSettingsToggle(key, previous)
841+ if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
842+ errMsg += "; rollback refresh: " + refreshErr.Error()
843+ }
844+ }
845+ i.mu.Lock()
846+ i.statusOK = ""
847+ i.statusErr = errMsg
848+ i.mu.Unlock()
849+ return
850+ }
851+ i.mu.Lock()
852+ i.statusOK = "ponytail coding mode " + onOff(value)
853+ i.statusErr = ""
854+ i.mu.Unlock()
855+ case "auto_subagents_enabled":
856+ if value && !i.autoSubagentsAvailable() {
857+ i.mu.Lock()
858+ i.statusOK = ""
859+ i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint()
860+ i.mu.Unlock()
861+ return
862+ }
863+ i.mu.Lock()
864+ previousFlag := i.cfg.AutoSubagentsEnabled
865+ previous := previousFlag != nil && *previousFlag
866+ val := value
867+ i.cfg.AutoSubagentsEnabled = &val
868+ i.mu.Unlock()
869+ if i.cfg.SettingsStore != nil {
870+ if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
871+ i.mu.Lock()
872+ i.cfg.AutoSubagentsEnabled = previousFlag
873+ i.statusOK = ""
874+ i.statusErr = "settings: " + err.Error()
875+ i.mu.Unlock()
876+ i.resetSettingsToggle(key, previous)
877+ return
878+ }
879+ }
880+ // The subagent tools remain available for explicit user requests.
881+ // Swap only the system-prompt guidance between strict orchestration and
882+ // on-demand delegation.
883+ i.applyAutoSubagentsTool()
884+ // Also swap the system-prompt addendum so the model knows whether it
885+ // should orchestrate proactively or delegate only on user request.
886+ i.applyAutoSubagentsSystemPrompt(value)
887+ i.mu.Lock()
888+ i.statusOK = "subagent orchestrator " + onOff(value)
889+ i.statusErr = ""
890+ i.mu.Unlock()
891+ case "fast_mode":
892+ previous := i.cfg.FastMode != nil && *i.cfg.FastMode
893+ if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
894+ i.resetSettingsToggle(key, previous)
895+ i.mu.Lock()
896+ i.statusErr = "settings: " + err.Error()
897+ i.mu.Unlock()
898+ return
899+ }
900+ if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
901+ if err := store.SetFastMode(value); err != nil {
902+ i.resetSettingsToggle(key, previous)
903+ i.mu.Lock()
904+ i.statusErr = "settings: " + err.Error()
905+ i.mu.Unlock()
906+ return
907+ }
908+ }
909+ val := value
910+ i.cfg.FastMode = &val
911+ if i.agent != nil {
912+ i.agent.SetFastMode(value)
913+ }
914+ i.mu.Lock()
915+ i.statusOK = "fast mode " + onOff(value)
916+ i.statusErr = ""
917+ i.mu.Unlock()
918+ case "lsp_enabled":
919+ previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
920+ store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
921+ if hasStore {
922+ if err := store.SetLSPEnabled(value); err != nil {
923+ i.mu.Lock()
924+ i.statusErr = "settings: " + err.Error()
925+ i.mu.Unlock()
926+ return
927+ }
928+ }
929+ val := value
930+ i.cfg.LSPEnabled = &val
931+ if i.cfg.RefreshTools != nil {
932+ if err := i.cfg.RefreshTools(); err != nil {
933+ errMsg := "settings: refresh tools: " + err.Error()
934+ rollbackErr := error(nil)
935+ if hasStore {
936+ rollbackErr = store.SetLSPEnabled(previous)
937+ }
938+ if rollbackErr != nil {
939+ // The first write succeeded, so keep the in-memory setting at
940+ // the value that remains durable instead of claiming that the
941+ // rollback took effect.
942+ errMsg += "; rollback persistence: " + rollbackErr.Error()
943+ if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
944+ errMsg += "; durable-state refresh: " + reconcileErr.Error()
945+ }
946+ } else {
947+ previousVal := previous
948+ i.cfg.LSPEnabled = &previousVal
949+ if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
950+ errMsg += "; rollback refresh: " + refreshErr.Error()
951+ }
952+ }
953+ i.mu.Lock()
954+ i.statusOK = ""
955+ i.statusErr = errMsg
956+ i.mu.Unlock()
957+ return
958+ }
959+ }
960+ i.mu.Lock()
961+ i.statusOK = "lsp in main session " + onOff(value)
962+ i.statusErr = ""
963+ i.mu.Unlock()
964+ case "subagent_lsp_enabled":
965+ if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
966+ if err := store.SetSubagentLSPEnabled(value); err != nil {
967+ i.mu.Lock()
968+ i.statusErr = "settings: " + err.Error()
969+ i.mu.Unlock()
970+ return
971+ }
972+ }
973+ val := value
974+ i.cfg.SubagentLSPEnabled = &val
975+ i.mu.Lock()
976+ i.statusOK = "lsp in sub-agents " + onOff(value)
977+ i.statusErr = ""
978+ i.mu.Unlock()
979+ case "jail_by_default":
980+ val := value
981+ i.cfg.JailByDefault = &val
982+ if i.cfg.SettingsStore != nil {
983+ if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
984+ i.mu.Lock()
985+ i.statusErr = "settings: " + err.Error()
986+ i.mu.Unlock()
987+ return
988+ }
989+ }
990+ if i.cfg.Sandbox != nil {
991+ if value {
992+ i.cfg.Sandbox.Lock()
993+ } else {
994+ i.cfg.Sandbox.Unlock()
995+ }
996+ }
997+ i.mu.Lock()
998+ i.statusOK = "jail by default " + onOff(value)
999+ i.statusErr = ""
1000+ i.mu.Unlock()
1001+ case "recursive_file_suggest":
1002+ val := value
1003+ i.cfg.RecursiveFileSuggest = &val
1004+ if i.cfg.SettingsStore != nil {
1005+ if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
1006+ i.mu.Lock()
1007+ i.statusErr = "settings: " + err.Error()
1008+ i.mu.Unlock()
1009+ return
1010+ }
1011+ }
1012+ // Flip the live picker so the next @ reflects the new mode
1013+ // without restarting zut. SetRecursive drops its cache.
1014+ i.fileSuggest.SetRecursive(value)
1015+ i.mu.Lock()
1016+ i.statusOK = "recursive @-file search " + onOff(value)
1017+ i.statusErr = ""
1018+ i.mu.Unlock()
1019+ case "respect_gitignore":
1020+ val := value
1021+ i.cfg.RespectGitignore = &val
1022+ if i.cfg.SettingsStore != nil {
1023+ if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
1024+ i.mu.Lock()
1025+ i.statusErr = "settings: " + err.Error()
1026+ i.mu.Unlock()
1027+ return
1028+ }
1029+ }
1030+ i.fileSuggest.SetRespectGitignore(value)
1031+ i.mu.Lock()
1032+ i.statusOK = "hide gitignored files in @-picker " + onOff(value)
1033+ i.statusErr = ""
1034+ i.mu.Unlock()
1035+ case "compact_mode":
1036+ val := value
1037+ i.cfg.CompactMode = &val
1038+ if i.cfg.SettingsStore != nil {
1039+ if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
1040+ i.mu.Lock()
1041+ i.statusErr = "settings: " + err.Error()
1042+ i.mu.Unlock()
1043+ return
1044+ }
1045+ }
1046+ i.mu.Lock()
1047+ i.view.CompactMode = value
1048+ i.view.InvalidateRenderCache()
1049+ i.statusOK = "compact transcript rendering " + onOff(value)
1050+ i.statusErr = ""
1051+ i.mu.Unlock()
1052+ case "show_instructions_at_startup":
1053+ val := value
1054+ i.cfg.ShowInstructionsAtStartup = &val
1055+ if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
1056+ if err := store.SetShowInstructionsAtStartup(value); err != nil {
1057+ i.mu.Lock()
1058+ i.statusErr = "settings: " + err.Error()
1059+ i.mu.Unlock()
1060+ return
1061+ }
1062+ }
1063+ i.mu.Lock()
1064+ i.view.StartupAgentName = ""
1065+ i.view.StartupContextPaths = nil
1066+ i.view.StartupExtensionNames = nil
1067+ i.view.StartupSkillNames = nil
1068+ i.view.InvalidateRenderCache()
1069+ if value {
1070+ i.view.StartupAgentName = i.cfg.StartupAgentName
1071+ i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
1072+ i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
1073+ i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
1074+ }
1075+ i.statusOK = "show loaded resources at startup " + onOff(value)
1076+ i.statusErr = ""
1077+ i.mu.Unlock()
1078+ }
1079+}
1080+func (i *Interactive) applyTUIInputStyleSetting(style string) {
1081+ defer func() {
1082+ i.requestRendererClear()
1083+ i.invalidate()
1084+ }()
1085+ style = tui.NormalizeInputStyle(style)
1086+ i.cfg.TUIInputStyle = style
1087+ i.applyInputCursorColor()
1088+ if i.cfg.SettingsStore != nil {
1089+ if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
1090+ i.mu.Lock()
1091+ i.statusErr = "settings: " + err.Error()
1092+ i.mu.Unlock()
1093+ return
1094+ }
1095+ }
1096+ i.mu.Lock()
1097+ i.statusOK = "input style " + style
1098+ i.statusErr = ""
1099+ i.mu.Unlock()
1100+}
1101+func (i *Interactive) applyTUIStatusPositionSetting(position string) {
1102+ defer func() {
1103+ i.requestRendererClear()
1104+ i.invalidate()
1105+ }()
1106+ position = tui.NormalizeStatusPosition(position)
1107+ i.cfg.TUIStatusPosition = position
1108+ if i.cfg.SettingsStore != nil {
1109+ if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
1110+ i.mu.Lock()
1111+ i.statusErr = "settings: " + err.Error()
1112+ i.mu.Unlock()
1113+ return
1114+ }
1115+ }
1116+ i.mu.Lock()
1117+ label := "above input"
1118+ if position == tui.StatusPositionBelowInput {
1119+ label = "below input"
1120+ }
1121+ i.statusOK = "status position " + label
1122+ i.statusErr = ""
1123+ i.mu.Unlock()
1124+}
1125+func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
1126+ defer func() {
1127+ i.requestRendererClear()
1128+ i.invalidate()
1129+ }()
1130+ position = tui.NormalizeWorkingPosition(position)
1131+ i.cfg.TUIWorkingPosition = position
1132+ if i.cfg.SettingsStore != nil {
1133+ if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
1134+ i.mu.Lock()
1135+ i.statusErr = "settings: " + err.Error()
1136+ i.mu.Unlock()
1137+ return
1138+ }
1139+ }
1140+ i.mu.Lock()
1141+ label := "above input"
1142+ if position == tui.WorkingPositionBelowInput {
1143+ label = "below input"
1144+ }
1145+ i.statusOK = "working spinner position " + label
1146+ i.statusErr = ""
1147+ i.mu.Unlock()
1148+}
1149+func (i *Interactive) applyThemeSetting(name string) {
1150+ if i.cfg.SettingsStore != nil {
1151+ if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
1152+ i.mu.Lock()
1153+ i.statusErr = "settings: " + err.Error()
1154+ i.mu.Unlock()
1155+ return
1156+ }
1157+ }
1158+ i.cfg.ThemeName = name
1159+ if name == "auto" {
1160+ i.cfg.ThemeName = ""
1161+ }
1162+ i.applyThemeNow(name)
1163+}
1164+func (i *Interactive) applyThemeNow(name string) {
1165+ if name == "" {
1166+ name = "auto"
1167+ }
1168+ detected := tui.Dark
1169+ if tui.IsLightTheme(i.cfg.Theme) {
1170+ detected = tui.Light
1171+ }
1172+ // Keep the startup terminal snapshot available when a user switches
1173+ // themes live; inherited mode must not issue OSC queries while the TUI
1174+ // already owns raw stdin.
1175+ detected.Terminal = i.cfg.Theme.Terminal
1176+ th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
1177+ if err != nil {
1178+ if i.cfg.SettingsStore != nil {
1179+ _ = i.cfg.SettingsStore.SetTheme("auto")
1180+ }
1181+ i.cfg.ThemeName = ""
1182+ th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)
1183+ i.mu.Lock()
1184+ i.statusErr = "theme missing; reset to default"
1185+ i.mu.Unlock()
1186+ } else {
1187+ i.mu.Lock()
1188+ label := applied
1189+ if label == "" {
1190+ label = "auto"
1191+ }
1192+ i.statusOK = "theme " + label
1193+ i.statusErr = ""
1194+ i.mu.Unlock()
1195+ }
1196+ i.mu.Lock()
1197+ i.cfg.Theme = th
1198+ i.view.Theme = th
1199+ i.view.InvalidateRenderCache()
1200+ i.mu.Unlock()
1201+ i.ed.Prompt = th.AccentBar(th.Accent)
1202+ i.applyInputCursorColor()
1203+ i.spin.Configure(th)
1204+ i.requestRendererTheme(th)
1205+ i.requestRendererClear()
1206+ i.invalidate()
1207+}
1208+func (i *Interactive) applyReasoningSetting(level string) {
1209+ defer func() {
1210+ i.requestRendererClear()
1211+ i.invalidate()
1212+ }()
1213+ level = provider.NormalizeReasoning(level)
1214+ i.cfg.Reasoning = level
1215+ if i.cfg.SettingsStore != nil {
1216+ if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
1217+ i.mu.Lock()
1218+ i.statusErr = "settings: " + err.Error()
1219+ i.mu.Unlock()
1220+ return
1221+ }
1222+ }
1223+ if i.cfg.OnReasoningChanged != nil {
1224+ i.cfg.OnReasoningChanged(level)
1225+ }
1226+ i.mu.Lock()
1227+ if i.agent != nil {
1228+ i.agent.Reasoning = level
1229+ }
1230+ label := level
1231+ if label == "" {
1232+ label = "off"
1233+ }
1234+ i.statusOK = "reasoning level " + label
1235+ i.statusErr = ""
1236+ i.mu.Unlock()
1237+}
A packages/agent/modes/interactive_submit.go +61 -0
@@ -0,0 +1,61 @@
1+package modes
2+
3+import "context"
4+
5+// Submit feeds text through the agent loop as if the user had typed it.
6+func (i *Interactive) Submit(text string) {
7+ if cmd, ok := shellEscapeCommand(text); ok {
8+ i.startShellEscape(i.runCtx, cmd)
9+ return
10+ }
11+ parent := i.runCtx
12+ if parent == nil {
13+ parent = context.Background()
14+ }
15+ i.mu.Lock()
16+ awaitingStartupPre := i.awaitingStartupPre
17+ i.mu.Unlock()
18+ if !awaitingStartupPre {
19+ i.maybeStartSessionTitle(parent, text)
20+ }
21+ i.startTurn(parent, text)
22+}
23+
24+// completeStartupPre applies deferred InitialInput after entry.pre finishes.
25+// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
26+// it only pre-fills the editor (CLI-supplied prompts).
27+func (i *Interactive) completeStartupPre() {
28+ i.mu.Lock()
29+ if !i.awaitingStartupPre {
30+ i.mu.Unlock()
31+ return
32+ }
33+ i.awaitingStartupPre = false
34+ deferred := i.deferredInitialInput
35+ auto := i.autoSubmitDeferred
36+ i.deferredInitialInput = ""
37+ i.autoSubmitDeferred = false
38+ onDone := i.cfg.OnStartupPreDone
39+ i.mu.Unlock()
40+ if onDone != nil {
41+ onDone()
42+ }
43+ i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
44+ i.invalidate()
45+}
46+
47+// applyStartupPreResult runs on the TUI event loop so the editor remains
48+// single-threaded. Input entered while resources were reloading wins over the
49+// deferred prefill rather than being overwritten.
50+func (i *Interactive) applyStartupPreResult(result startupPreResult) {
51+ if result.deferred == "" {
52+ return
53+ }
54+ if result.autoSubmit {
55+ i.Submit(result.deferred)
56+ return
57+ }
58+ if i.ed.IsEmpty() {
59+ i.ed.SetValue(result.deferred)
60+ }
61+}
A packages/agent/modes/interactive_telegram.go +198 -0
@@ -0,0 +1,198 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/bnema/zut/packages/agent/modes/telegram"
9+)
10+
11+func (i *Interactive) openTelegramDialog() {
12+ items := i.telegramMenuItems()
13+ if len(items) == 0 {
14+ i.mu.Lock()
15+ i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
16+ i.mu.Unlock()
17+ i.invalidate()
18+ return
19+ }
20+ i.telegramDialog.Open(items)
21+ i.invalidate()
22+}
23+func (i *Interactive) telegramMenuItems() []telegramItem {
24+ cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
25+ if err != nil || cfg.BotToken == "" {
26+ return nil
27+ }
28+ var items []telegramItem
29+ if i.telegramBridge != nil && i.telegramBridge.Active() {
30+ items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
31+ st := i.telegramBridge.State()
32+ hint := "active"
33+ if st.Username != "" {
34+ hint += " as @" + st.Username
35+ }
36+ items = append(items, telegramItem{label: "status", action: "status", hint: hint})
37+ } else {
38+ label := "connect"
39+ hint := "start mirroring dms into this session"
40+ if cfg.AllowedUserID == 0 {
41+ hint = "awaiting pairing (send /start to the bot once connected)"
42+ }
43+ items = append(items, telegramItem{label: label, action: "connect", hint: hint})
44+ items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
45+ }
46+ return items
47+}
48+func (i *Interactive) doTelegram(action string) {
49+ switch action {
50+ case "connect":
51+ i.telegramConnect()
52+ case "disconnect":
53+ i.telegramDisconnect()
54+ case "status":
55+ i.telegramStatus()
56+ default:
57+ i.mu.Lock()
58+ i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
59+ i.mu.Unlock()
60+ i.invalidate()
61+ }
62+}
63+func (i *Interactive) telegramConnect() {
64+ if i.telegramBridge != nil && i.telegramBridge.Active() {
65+ i.mu.Lock()
66+ i.statusOK = "telegram already connected"
67+ i.statusErr = ""
68+ i.mu.Unlock()
69+ i.invalidate()
70+ return
71+ }
72+ cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
73+ if err != nil {
74+ i.mu.Lock()
75+ i.statusErr = "telegram: " + err.Error()
76+ i.mu.Unlock()
77+ i.invalidate()
78+ return
79+ }
80+ if cfg.BotToken == "" {
81+ i.mu.Lock()
82+ i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
83+ i.mu.Unlock()
84+ i.invalidate()
85+ return
86+ }
87+ // Refuse to start when a background daemon is already polling
88+ // the same bot. Two concurrent long-poll consumers race each
89+ // update and one always loses, so DMs get half-delivered. The
90+ // user can `zut telegram-bot stop` first, then /telegram connect.
91+ if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
92+ i.mu.Lock()
93+ i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
94+ i.mu.Unlock()
95+ i.invalidate()
96+ return
97+ }
98+ bridge := &telegram.Bridge{
99+ Client: telegram.NewClient(cfg.BotToken),
100+ Config: cfg,
101+ Save: func(next telegram.Config) error {
102+ return telegram.SaveConfig(i.cfg.ZutHome, next)
103+ },
104+ Host: &telegramHost{iv: i},
105+ }
106+ i.mu.Lock()
107+ i.telegramBridge = bridge
108+ i.mu.Unlock()
109+ // Strip web_search before the bridge's startup handshake can accept a
110+ // Telegram update. ApplyAgentPromptConfig also keeps it stripped from
111+ // concurrent refreshes while this bridge pointer is attached.
112+ i.applyTelegramTools(true)
113+ if err := bridge.Start(i.runCtx); err != nil {
114+ i.mu.Lock()
115+ i.telegramBridge = nil
116+ i.mu.Unlock()
117+ refreshErr := i.refreshToolsAfterTelegram()
118+ i.mu.Lock()
119+ i.statusErr = "telegram connect failed: " + err.Error()
120+ if refreshErr != nil {
121+ i.statusErr += "; tool refresh: " + refreshErr.Error()
122+ }
123+ i.mu.Unlock()
124+ i.invalidate()
125+ return
126+ }
127+ state := bridge.State()
128+ label := "telegram connected"
129+ if state.Username != "" {
130+ label += " as @" + state.Username
131+ }
132+ if state.PairedID == 0 {
133+ label += " — send /start to the bot from your phone to claim it"
134+ }
135+ i.mu.Lock()
136+ i.statusOK = label
137+ i.statusErr = ""
138+ i.mu.Unlock()
139+ i.invalidate()
140+}
141+func (i *Interactive) telegramDisconnect() {
142+ if i.telegramBridge == nil || !i.telegramBridge.Active() {
143+ i.mu.Lock()
144+ i.statusOK = "telegram already disconnected"
145+ i.statusErr = ""
146+ i.mu.Unlock()
147+ i.invalidate()
148+ return
149+ }
150+ bridge := i.telegramBridge
151+ bridge.Stop()
152+ i.mu.Lock()
153+ // Clear the pointer before rebuilding the normal registry. This both
154+ // allows web_search back into the refresh and marks the bridge inactive
155+ // for any concurrent prompt-config commit.
156+ i.telegramBridge = nil
157+ i.mu.Unlock()
158+ refreshErr := i.refreshToolsAfterTelegram()
159+ i.mu.Lock()
160+ if refreshErr != nil {
161+ i.statusOK = ""
162+ i.statusErr = "telegram disconnect tool refresh: " + refreshErr.Error()
163+ } else {
164+ i.statusOK = "telegram disconnected"
165+ i.statusErr = ""
166+ }
167+ i.mu.Unlock()
168+ i.invalidate()
169+}
170+func (i *Interactive) refreshToolsAfterTelegram() error {
171+ if i.cfg.RefreshTools == nil {
172+ i.stripWebSearchTool()
173+ i.applyTelegramTools(false)
174+ return errors.New("live tool refresh is unavailable")
175+ }
176+ if err := i.cfg.RefreshTools(); err != nil {
177+ i.stripWebSearchTool()
178+ i.applyTelegramTools(false)
179+ return err
180+ }
181+ i.applyTelegramTools(false)
182+ return nil
183+}
184+func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
185+ if a.bridge == nil {
186+ return fmt.Errorf("telegram bridge is not connected")
187+ }
188+ return a.bridge.SendImage(ctx, path, caption)
189+}
190+func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
191+ if a.bridge == nil {
192+ return fmt.Errorf("telegram bridge is not connected")
193+ }
194+ return a.bridge.SendDocument(ctx, path, caption)
195+}
196+func (a telegramSenderAdapter) Active() bool {
197+ return a.bridge != nil && a.bridge.Active()
198+}
A packages/agent/modes/interactive_turn.go +637 -0
@@ -0,0 +1,637 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "unicode"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/zut/packages/agent/tools"
11+ "github.com/bnema/zut/packages/core"
12+ "github.com/bnema/zut/packages/provider"
13+ "github.com/bnema/zut/packages/tui"
14+)
15+
16+func shellEscapeCommand(text string) (string, bool) {
17+ return ShellEscapeCommand(text)
18+}
19+func ShellEscapeCommand(text string) (string, bool) {
20+ trimmed := strings.TrimLeft(text, " \t")
21+ if !strings.HasPrefix(trimmed, "!") {
22+ return "", false
23+ }
24+ cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
25+ if cmd == "" {
26+ return "", false
27+ }
28+ return cmd, true
29+}
30+func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
31+ i.mu.Lock()
32+ if i.busy || i.shellRunning {
33+ i.statusErr = "busy — wait for the current turn to finish before running a shell command"
34+ i.statusOK = ""
35+ i.mu.Unlock()
36+ i.invalidate()
37+ return
38+ }
39+ if parent == nil {
40+ parent = i.runCtx
41+ }
42+ if parent == nil {
43+ parent = context.Background()
44+ }
45+ ctx, cancel := context.WithCancel(parent)
46+ i.busy = true
47+ i.shellRunning = true
48+ i.shellLive = "$ " + cmd + "\n\n"
49+ i.cancelTurn = cancel
50+ i.statusErr = ""
51+ i.statusOK = ""
52+ i.spin.Start()
53+ i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
54+ // Clear stale extension notes the same way a new turn would so the
55+ // screen doesn't accumulate transient state.
56+ i.extNotes = nil
57+ i.scrollOffset = 0
58+ i.parkedTurn = 0
59+ i.parkedTotal = 0
60+ i.helpBlock = nil
61+ sandbox := i.cfg.Sandbox
62+ cwd := i.cfg.CWD
63+ i.mu.Unlock()
64+ i.invalidate()
65+
66+ go func() {
67+ defer cancel()
68+ raw, _ := json.Marshal(map[string]any{
69+ "command": cmd,
70+ "timeout": ShellEscapeTimeoutSeconds,
71+ })
72+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
73+ progress := func(chunk string) {
74+ i.mu.Lock()
75+ i.shellLive += chunk
76+ i.mu.Unlock()
77+ i.invalidate()
78+ }
79+ res, err := bash.Execute(ctx, raw, progress)
80+
81+ var out string
82+ if err != nil {
83+ out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
84+ } else {
85+ for _, c := range res.Content {
86+ if tb, ok := c.(provider.TextBlock); ok {
87+ out += tb.Text
88+ }
89+ }
90+ }
91+ cancelled := ctx.Err() != nil
92+ failed := err != nil || res.IsError || cancelled
93+ if cancelled {
94+ out += "\n\n[cancelled]"
95+ }
96+
97+ if i.agent != nil {
98+ i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
99+ }
100+
101+ i.mu.Lock()
102+ i.shellRunning = false
103+ i.shellLive = ""
104+ i.busy = false
105+ i.cancelTurn = nil
106+ pendingIdleWork := i.takePendingIdleWorkLocked()
107+ awaitingPre := i.awaitingStartupPre
108+ if failed {
109+ if cancelled {
110+ i.statusErr = "shell command cancelled"
111+ } else {
112+ i.statusErr = "shell command failed"
113+ }
114+ i.statusOK = ""
115+ } else {
116+ i.statusOK = "shell command finished"
117+ i.statusErr = ""
118+ }
119+ i.mu.Unlock()
120+ runPendingIdleWork(pendingIdleWork)
121+ i.invalidate()
122+ if awaitingPre {
123+ i.completeStartupPre()
124+ }
125+ }()
126+}
127+func (i *Interactive) startTurn(parent context.Context, prompt string) {
128+ i.startTurnWithImages(parent, prompt, nil)
129+}
130+func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
131+ i.startTurnRequest(parent, prompt, images, false, false)
132+}
133+func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
134+ if i.agent == nil {
135+ // Text startup pre cannot run without credentials; continue so
136+ // deferred InitialInput (pre-fill or auto-submit) still applies.
137+ i.mu.Lock()
138+ awaitingPre := i.awaitingStartupPre
139+ i.mu.Unlock()
140+ if awaitingPre {
141+ i.completeStartupPre()
142+ }
143+ return
144+ }
145+ // Pre-turn safety: if the most recent context measurement is
146+ // already past the auto-compact threshold, condense before
147+ // sending so the next outbound request stays under the limit.
148+ // The condense flow re-fires the user's queued prompt for us, so
149+ // we just hand it off and exit.
150+ i.mu.Lock()
151+ var resetHandoff json.RawMessage
152+ var persistResetHandoff bool
153+ if !continueExisting {
154+ resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
155+ }
156+ needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
157+ if needsPreCompact {
158+ // Reserve the compaction hand-off before releasing i.mu. A completion
159+ // update arriving in the small gap before runCompact acquires the
160+ // mutex must join the host queue rather than starting a competing turn.
161+ i.busy = true
162+ i.compacting = true
163+ i.autoCompacting = true
164+ if continueExisting {
165+ i.continueAfterCompact = true
166+ } else {
167+ i.pendingCompactPrompt = prompt
168+ i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
169+ i.hasPendingCompactPrompt = true
170+ }
171+ i.statusErr = ""
172+ i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
173+ i.pendingPostCompactNote = "context auto-compacted; sending your last message"
174+ i.mu.Unlock()
175+ if persistResetHandoff {
176+ i.persistCompactHandoff(resetHandoff)
177+ }
178+ i.invalidate()
179+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
180+ return
181+ }
182+ i.mu.Unlock()
183+ if persistResetHandoff {
184+ i.persistCompactHandoff(resetHandoff)
185+ }
186+
187+ ctx, cancel := context.WithCancel(parent)
188+ i.mu.Lock()
189+ i.busy = true
190+ i.spin.Start()
191+ i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
192+ i.cancelTurn = cancel
193+ i.statusErr = ""
194+ i.statusOK = ""
195+ i.streaming.Reset()
196+ i.streamOn = true
197+ i.pendingAlert = nil
198+ i.toolCalls = map[string]*tui.ToolCallView{}
199+ i.toolOrder = nil
200+ i.toolGate = map[string]int{}
201+ i.extNotes = nil // ext notes are one-shot; a new prompt clears them
202+ i.scrollOffset = 0 // jump back to the bottom on new turn
203+ // Lift the resume tail cap once the user starts interacting. The
204+ // cap is purely a first-paint optimization (don't markdown the
205+ // whole history before showing anything). Keeping it active during
206+ // a turn makes the rendered chat a sliding window: appended
207+ // messages push older ones off the TOP of the buffer, which the
208+ // renderer must treat as a change above the viewport and repaint
209+ // fully, snapping the terminal's native scrollback to the bottom on
210+ // every streamed chunk. A fresh session has no cap (append-only),
211+ // which is why the jump only shows up in resumed sessions. Dropping
212+ // the cap here makes resumed turns append-only too.
213+ i.view.TailLimit = 0
214+ // Reset the auto-follow baseline so the very next render at
215+ // interactive.go:1053 doesn't see a synthetic shrink between
216+ // "last frame had the previous turn's tool overlay" and
217+ // "this frame had it cleared above". Without this, the guard
218+ // reads delta = -(rows in cleared overlay) and decrements
219+ // scrollOffset, which on terminals that mirror zut's pane
220+ // scroll into the host scrollbar visibly yanks the viewport.
221+ // See autofollow_shrink_test.go for the exact arithmetic.
222+ i.prevChatLen = 0
223+ i.prevChatCols = 0
224+ i.parkedTurn = 0 // starting a turn clears the /jump parked state
225+ i.parkedTotal = 0
226+ i.helpBlock = nil // hide the help block once the user asks something
227+ i.mu.Unlock()
228+ i.invalidate()
229+
230+ var (
231+ lastStop provider.StopReason
232+ lastTurnErr error
233+ )
234+ sink := func(ev core.AgentEvent) {
235+ if e, ok := ev.(core.EvTurnEnd); ok {
236+ lastStop = e.Stop
237+ lastTurnErr = e.Err
238+ }
239+ i.handleEventForPresentation(ev)
240+ }
241+
242+ releaseCompletionHold := i.beginCompletionDeliveryHold()
243+ go func() {
244+ defer releaseCompletionHold()
245+ var err error
246+ if continueExisting {
247+ err = i.agent.Continue(ctx, sink)
248+ } else {
249+ err = i.agent.Prompt(ctx, prompt, images, sink)
250+ }
251+ i.mu.Lock()
252+ // Keep busy asserted through final cleanup and queue selection. A
253+ // watcher may enqueue a completion summary concurrently with the
254+ // provider's final event; publishing idle before inspecting queues can
255+ // strand that summary in the core agent queue or start an overlapping
256+ // turn.
257+ // Don't touch streamPending / streamFlushPending here — the
258+ // pacer may still be draining the final deltas and needs to
259+ // paint them even though Prompt has returned. It will reset
260+ // streamOn on its own once the buffer empties.
261+ if len(i.streamPending) == 0 {
262+ i.streamOn = false
263+ }
264+ i.cancelTurn = nil
265+ pendingIdleWork := i.takePendingIdleWorkLocked()
266+ if err != nil && ctx.Err() == nil {
267+ i.statusErr = err.Error()
268+ }
269+ // Decide whether to offer a model rescue picker for recoverable
270+ // provider failures (auth/rate/temporary). The picker opens after
271+ // the mutex is released so it can take its own locks freely.
272+ var (
273+ offer bool
274+ rescueWhy string
275+ rescueImgs []provider.ImageBlock
276+ rescueModel string
277+ rescueProv string
278+ rescueFprov string
279+ )
280+ if err != nil && ctx.Err() == nil {
281+ if ok, reason := classifyRescueError(err); ok {
282+ offer = true
283+ rescueWhy = reason
284+ rescueImgs = images
285+ rescueModel = i.cfg.Model
286+ rescueProv = i.cfg.Provider
287+ rescueFprov = extractFailedProvider(err)
288+ if rescueFprov == "" {
289+ rescueFprov = i.cfg.Provider
290+ }
291+ // Suppress the red banner — the rescue dialog already
292+ // surfaces the failure.
293+ i.statusErr = ""
294+ }
295+ }
296+ // Detect responses that reject the current context, either as an
297+ // HTTP 413 payload limit or a model context-window error. Token-
298+ // based auto-compact can miss both when metadata is stale or the
299+ // limit is measured in raw bytes. Compact once, then continue the
300+ // user message already present in the transcript.
301+ contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
302+ recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
303+ if recoverContextOverflow {
304+ i.statusErr = ""
305+ i.continueAfterCompact = true
306+ i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
307+ i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
308+ }
309+ // Persist the assistant's reply (and every tool row before
310+ // it) to the session file while the turn memory is hot.
311+ // Without this, WriteNewTranscript only fires at zut exit,
312+ // meaning a crash or ungraceful kill drops the whole
313+ // conversation. FlushSession is idempotent (it advances the
314+ // baseline so subsequent flushes only write new rows).
315+ flush := i.cfg.FlushSession
316+ i.mu.Unlock()
317+ runPendingIdleWork(pendingIdleWork)
318+ if flush != nil {
319+ flush()
320+ }
321+ terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
322+ if terminalGoalError {
323+ i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
324+ }
325+ i.mu.Lock()
326+ awaitingPre := i.awaitingStartupPre
327+ // A newer explicit prompt may have cleared the handoff while the
328+ // completed turn was being flushed. Re-read it under the mutex before
329+ // deciding whether this continuation may spend another rescue attempt.
330+ statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
331+ // Pop the next queued message, if any, and relaunch.
332+ var next string
333+ var hasNext bool
334+ if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
335+ next, i.queued = i.queued[0], i.queued[1:]
336+ hasNext = true
337+ }
338+ // If the turn was cancelled or errored, drop the queue so the
339+ // user isn't bombarded with stale messages after an interrupt.
340+ if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
341+ i.queued = nil
342+ if i.agent != nil {
343+ i.agent.DrainQueuedMessages()
344+ }
345+ }
346+ // Decide whether the next thing to do is an auto-compaction.
347+ // Only fires when the turn completed cleanly AND no host-side
348+ // or agent-side queued messages are waiting (otherwise a queued
349+ // message would race the condense).
350+ agentQueued := 0
351+ if i.agent != nil {
352+ agentQueued = i.agent.QueuedMessageCount()
353+ }
354+ continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
355+ shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
356+ continueStatusRescue := false
357+ var handoff json.RawMessage
358+ var persistHandoff bool
359+ if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
360+ followUpMessages := i.agent.Messages()
361+ reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
362+ if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
363+ handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
364+ reason: compactContinuationStatusRescue,
365+ rescueAttempts: i.compactContinuation.rescueAttempts + 1,
366+ })
367+ continueStatusRescue = true
368+ } else {
369+ handoff, persistHandoff = i.resetCompactContinuationLocked()
370+ }
371+ }
372+ if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
373+ handoff, persistHandoff = i.resetCompactContinuationLocked()
374+ }
375+ // The agent run can finish before the paced final text reaches the
376+ // transcript. A compaction replaces that transcript, so it must never
377+ // race the still-live stream frame; otherwise stale deltas can repaint
378+ // after the replacement and corrupt the scrollback renderer's model.
379+ if recoverContextOverflow || shouldAutoCompact {
380+ i.resetStreamingStateLocked()
381+ }
382+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
383+ i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
384+ i.mu.Unlock()
385+ if persistHandoff {
386+ i.persistCompactHandoff(handoff)
387+ }
388+ if alertReason != "" {
389+ i.scheduleMainAlert(alertReason)
390+ }
391+ i.invalidate()
392+ parent := i.runCtx
393+ if parent == nil {
394+ parent = context.Background()
395+ }
396+ if awaitingPre {
397+ i.completeStartupPre()
398+ return
399+ }
400+ switch {
401+ case hasNext:
402+ i.startTurn(parent, next)
403+ case continueQueued:
404+ i.startTurnRequest(parent, "", nil, true, false)
405+ case continueStatusRescue:
406+ i.startAutoCompactContinuation(parent)
407+ case offer:
408+ i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
409+ case recoverContextOverflow:
410+ i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
411+ case shouldAutoCompact:
412+ i.runCompact(parent, compactContinuationRequest{
413+ origin: compactOriginAfterTurnThreshold,
414+ force: lastStop == provider.StopLength,
415+ lastStop: lastStop,
416+ turnError: lastTurnErr,
417+ })
418+ }
419+ }()
420+}
421+func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
422+ if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
423+ return ""
424+ }
425+ if rescue {
426+ return "rescue_required"
427+ }
428+ if stop == provider.StopAborted {
429+ return ""
430+ }
431+ if err != nil || turnErr != nil || stop == provider.StopError {
432+ return "agent_error"
433+ }
434+ if stop == provider.StopLength {
435+ return "response_truncated"
436+ }
437+ return "agent_done"
438+}
439+func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
440+ if i.rescueDialog == nil {
441+ return
442+ }
443+ loggedIn := []string{}
444+ if i.cfg.LoggedInProviders != nil {
445+ loggedIn = i.cfg.LoggedInProviders()
446+ }
447+ fprov := failedProvider
448+ if fprov == "" {
449+ fprov = activeProvider
450+ }
451+ i.mu.Lock()
452+ i.pendingRescuePrompt = prompt
453+ i.pendingRescueImages = images
454+ i.mu.Unlock()
455+ i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
456+ i.invalidate()
457+}
458+func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
459+ if model == "" {
460+ return
461+ }
462+ i.applyRescueModelSelection(prov, model)
463+ i.mu.Lock()
464+ images := i.pendingRescueImages
465+ if prompt == "" {
466+ prompt = i.pendingRescuePrompt
467+ }
468+ i.pendingRescuePrompt = ""
469+ i.pendingRescueImages = nil
470+ i.mu.Unlock()
471+ parent := i.runCtx
472+ if parent == nil {
473+ parent = context.Background()
474+ }
475+ i.startTurnWithImages(parent, prompt, images)
476+}
477+func stripAutoCompactNotes(notes []string) []string {
478+ if len(notes) == 0 {
479+ return notes
480+ }
481+ out := notes[:0]
482+ for _, n := range notes {
483+ if strings.Contains(n, "condensing history") {
484+ continue
485+ }
486+ out = append(out, n)
487+ }
488+ return out
489+}
490+func autoCompactNoteLine(th tui.Theme, msg string) string {
491+ return " " + th.FGColor(th.Warning, "⚠ "+msg)
492+}
493+func NormalizeAutoCompactThreshold(threshold *int) int {
494+ const defaultAutoCompactThreshold = 85
495+ if threshold == nil {
496+ return defaultAutoCompactThreshold
497+ }
498+ switch *threshold {
499+ case 0, 70, 80, 85, 90, 95:
500+ return *threshold
501+ default:
502+ return defaultAutoCompactThreshold
503+ }
504+}
505+func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
506+ if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
507+ return false
508+ }
509+ return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
510+}
511+func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
512+ if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
513+ return compactContinuationStructuralTail
514+ }
515+ if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
516+ return compactContinuationNone
517+ }
518+ if likelyForwardWorkStatus(msgs) {
519+ return compactContinuationStatusRescue
520+ }
521+ return compactContinuationNone
522+}
523+func structuralCompactionContinuation(msgs []provider.Message) bool {
524+ if len(msgs) == 0 {
525+ return false
526+ }
527+ last := msgs[len(msgs)-1]
528+ if last.Role != provider.RoleAssistant {
529+ return true
530+ }
531+ hasText := false
532+ for _, content := range last.Content {
533+ switch block := content.(type) {
534+ case provider.ToolCallBlock:
535+ return true
536+ case provider.TextBlock:
537+ if strings.TrimSpace(block.Text) != "" {
538+ hasText = true
539+ }
540+ case provider.ReasoningBlock:
541+ // Reasoning without a visible answer is not terminal.
542+ default:
543+ return true
544+ }
545+ }
546+ return !hasText
547+}
548+func likelyForwardWorkStatus(msgs []provider.Message) bool {
549+ if len(msgs) == 0 {
550+ return false
551+ }
552+ last := msgs[len(msgs)-1]
553+ if last.Role != provider.RoleAssistant {
554+ return false
555+ }
556+ var text strings.Builder
557+ for _, content := range last.Content {
558+ block, ok := content.(provider.TextBlock)
559+ if !ok {
560+ if _, hasTool := content.(provider.ToolCallBlock); hasTool {
561+ return false
562+ }
563+ continue
564+ }
565+ if strings.TrimSpace(block.Text) == "" {
566+ continue
567+ }
568+ if text.Len() > 0 {
569+ text.WriteByte(' ')
570+ }
571+ text.WriteString(block.Text)
572+ }
573+ if text.Len() == 0 {
574+ return false
575+ }
576+ normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
577+ normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
578+ for _, phrase := range []string{
579+ "next i will",
580+ "next, i will",
581+ "i'll now",
582+ "i will now",
583+ "i am going to",
584+ "i need to continue",
585+ "then i will",
586+ "then, i will",
587+ "i will proceed",
588+ } {
589+ if containsFutureWorkPhrase(normalized, phrase) {
590+ return true
591+ }
592+ }
593+ return false
594+}
595+func containsFutureWorkPhrase(text, phrase string) bool {
596+ for offset := 0; offset < len(text); {
597+ index := strings.Index(text[offset:], phrase)
598+ if index < 0 {
599+ return false
600+ }
601+ index += offset
602+ end := index + len(phrase)
603+ if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
604+ return true
605+ }
606+ offset = end
607+ }
608+ return false
609+}
610+func wordRuneBefore(text string, index int) bool {
611+ if index == 0 {
612+ return false
613+ }
614+ rune_, _ := utf8.DecodeLastRuneInString(text[:index])
615+ return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
616+}
617+func wordRuneAfter(text string, index int) bool {
618+ if index == len(text) {
619+ return false
620+ }
621+ rune_, _ := utf8.DecodeRuneInString(text[index:])
622+ return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
623+}
624+func (i *Interactive) shouldAutoCompactLocked() bool {
625+ if i.agent == nil {
626+ return false
627+ }
628+ if i.autoCompacting {
629+ return false
630+ }
631+ m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
632+ if err != nil || m.ContextWindow <= 0 {
633+ return false
634+ }
635+ threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
636+ return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
637+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close