detached cf1ed1b

refactor(modes): split interactive responsibilities

commit
cf1ed1b78ed8717a8262667ee2e3d86d5200eb1b
author
brice · · 2026-08-14 03:49 UTC
committer
brice · · 2026-08-14 03:57 UTC
parent
06af173
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +8017 -8017
M packages/agent/modes/interactive.go +160 -8017
@@ -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,102 +1369,6 @@ func (i *Interactive) Run(ctx context.Context) error {
13771369 }
13781370 }
13791371
1380-func (i *Interactive) cachedChatLocked(cols int) []string {
1381- // A busy frame contains mutable streaming/tool state. Keep the stable
1382- // transcript cache, but never return a full-frame cache entry that would
1383- // hide a newly arrived delta or completed result.
1384- if i.busy || i.streamOn || i.streamFlushPending {
1385- return i.buildChatLocked(cols)
1386- }
1387- key := i.chatCacheKeyLocked(cols)
1388- if i.chatCacheValid && i.chatCacheKey == key {
1389- return append([]string(nil), i.chatCache...)
1390- }
1391- chat := i.buildChatLocked(cols)
1392- key = i.chatCacheKeyLocked(cols)
1393- i.chatCache = append(i.chatCache[:0], chat...)
1394- i.chatCacheKey = key
1395- i.chatCacheValid = true
1396- return chat
1397-}
1398-
1399-func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey {
1400- var rev uint64
1401- if i.agent != nil {
1402- rev = i.agent.Revision()
1403- }
1404- showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1405- return chatCacheKey{
1406- cols: cols,
1407- agentRev: rev,
1408- statusOK: i.statusOK,
1409- statusErr: i.statusErr,
1410- help: strings.Join(i.helpBlock, "\n"),
1411- extNotes: strings.Join(i.extNotes, "\n"),
1412- extStatuses: i.extensionStatusesKeyLocked(),
1413- extWidgets: i.extensionWidgetsKeyLocked(),
1414- reloadErrors: strings.Join(i.reloadErrors, "\n"),
1415- updateAvailable: i.updateInfo.Available,
1416- updateCurrent: i.updateInfo.Current,
1417- updateLatest: i.updateInfo.Latest,
1418- updateURL: i.updateInfo.URL,
1419- welcomeShowVer: showVer,
1420- expandAll: i.view.ExpandAll,
1421- tailLimit: i.view.TailLimit,
1422- renderedMessageCount: len(i.view.Messages),
1423- viewCacheRev: i.view.RenderCacheRevision,
1424- }
1425-}
1426-
1427-func sortedNestedOuterKeys[T any](groups map[string]map[string]T) []string {
1428- keys := make([]string, 0, len(groups))
1429- for key := range groups {
1430- keys = append(keys, key)
1431- }
1432- sort.Strings(keys)
1433- return keys
1434-}
1435-
1436-func sortedNestedInnerKeys[T any](groups map[string]map[string]T, outer string) []string {
1437- items := groups[outer]
1438- keys := make([]string, 0, len(items))
1439- for key := range items {
1440- keys = append(keys, key)
1441- }
1442- sort.Strings(keys)
1443- return keys
1444-}
1445-
1446-func (i *Interactive) extensionStatusesKeyLocked() string {
1447- var sb strings.Builder
1448- extNames := sortedNestedOuterKeys(i.extStatuses)
1449- for _, extName := range extNames {
1450- keys := sortedNestedInnerKeys(i.extStatuses, extName)
1451- for _, key := range keys {
1452- status := i.extStatuses[extName][key]
1453- fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, key, status.Level, status.Text)
1454- }
1455- }
1456- return sb.String()
1457-}
1458-
1459-func (i *Interactive) extensionWidgetsKeyLocked() string {
1460- var sb strings.Builder
1461- extNames := sortedNestedOuterKeys(i.extWidgets)
1462- for _, extName := range extNames {
1463- ids := sortedNestedInnerKeys(i.extWidgets, extName)
1464- for _, id := range ids {
1465- widget := i.extWidgets[extName][id]
1466- fmt.Fprintf(&sb, "%s/%s/%s/%s\n", extName, id, widget.Position, widget.Title)
1467- for _, line := range widget.Lines {
1468- sb.WriteString(line)
1469- sb.WriteByte('\n')
1470- }
1471- }
1472- }
1473- return sb.String()
1474-}
1475-
14761372 const maxExtensionWidgetRows = 12
14771373 const maxExtensionStatusRows = 6
14781374
@@ -1480,360 +1376,17 @@ const maxExtensionStatusRows = 6
14801376 // above the input, leaving one row for the truncation marker.
14811377 const maxNarrowExtensionChromeRows = 6
14821378
1483-func (i *Interactive) rightBarWidgetsLocked() []tui.RightBarWidget {
1484- var out []tui.RightBarWidget
1485- for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1486- for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1487- widget := i.extWidgets[extName][id]
1488- if extproto.NormalizeWidgetPosition(widget.Position) != extproto.WidgetPositionRightBar {
1489- continue
1490- }
1491- out = append(out, tui.RightBarWidget{
1492- Extension: extName,
1493- ID: id,
1494- Title: widget.Title,
1495- Lines: append([]string(nil), widget.Lines...),
1496- })
1497- }
1498- }
1499- return out
1500-}
1501-
1502-func (i *Interactive) extensionChromeLinesLocked(cols int) []string {
1503- return i.extensionChromeLinesAtLocked(cols, false)
1504-}
1505-
1506-func (i *Interactive) extensionChromeLinesForLayoutLocked(cols int, rightBarActive, rightBarFallback bool) []string {
1507- lines := i.extensionChromeLinesAtLocked(cols, rightBarActive)
1508- if !rightBarFallback || len(lines) <= maxNarrowExtensionChromeRows {
1509- return lines
1510- }
1511- limit := maxNarrowExtensionChromeRows
1512- trimmed := append([]string(nil), lines[:limit-1]...)
1513- trimmed = append(trimmed, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension chrome truncated ...", cols)))
1514- return trimmed
1515-}
1516-
1517-func (i *Interactive) extensionChromeLinesAtLocked(cols int, rightBarActive bool) []string {
1518- var out []string
1519- bodyWidth := cols - 2
1520- if bodyWidth < 1 {
1521- bodyWidth = 1
1522- }
1523- widgetRows := 0
1524- widgetTruncated := false
1525- appendWidgetRow := func(line string) bool {
1526- if widgetRows >= maxExtensionWidgetRows-1 {
1527- widgetTruncated = true
1528- return false
1529- }
1530- out = append(out, line)
1531- widgetRows++
1532- return true
1533- }
1534-
1535- stopWidgets := false
1536- for _, extName := range sortedNestedOuterKeys(i.extWidgets) {
1537- if stopWidgets {
1538- break
1539- }
1540- for _, id := range sortedNestedInnerKeys(i.extWidgets, extName) {
1541- widget := i.extWidgets[extName][id]
1542- if rightBarActive && extproto.NormalizeWidgetPosition(widget.Position) == extproto.WidgetPositionRightBar {
1543- continue
1544- }
1545- label := " [" + extName + "]"
1546- if widget.Title != "" {
1547- label += " " + widget.Title
1548- }
1549- if !appendWidgetRow(i.cfg.Theme.FGColor(i.cfg.Theme.Accent, truncateLine(label, cols))) {
1550- stopWidgets = true
1551- break
1552- }
1553- for _, line := range widget.Lines {
1554- if !appendWidgetRow(" " + truncateLine(line, bodyWidth)) {
1555- stopWidgets = true
1556- break
1557- }
1558- }
1559- if stopWidgets {
1560- break
1561- }
1562- }
1563- }
1564- if widgetTruncated {
1565- out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension widgets truncated ...", cols)))
1566- }
1567-
1568- statusRows := 0
1569- statusTruncated := false
1570- for _, extName := range sortedNestedOuterKeys(i.extStatuses) {
1571- for _, key := range sortedNestedInnerKeys(i.extStatuses, extName) {
1572- if statusRows >= maxExtensionStatusRows-1 {
1573- statusTruncated = true
1574- break
1575- }
1576- status := i.extStatuses[extName][key]
1577- color := i.cfg.Theme.Muted
1578- switch status.Level {
1579- case "warn":
1580- color = i.cfg.Theme.Warning
1581- case "error":
1582- color = i.cfg.Theme.Error
1583- case "success":
1584- color = i.cfg.Theme.Tool
1585- }
1586- label := " [" + extName + "] " + status.Text
1587- out = append(out, i.cfg.Theme.FGColor(color, truncateLine(label, cols)))
1588- statusRows++
1589- }
1590- if statusTruncated {
1591- break
1592- }
1593- }
1594- if statusTruncated {
1595- out = append(out, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, truncateLine(" ... extension statuses truncated ...", cols)))
1596- }
1597- return out
1598-}
1599-
1600-func (i *Interactive) stableChatRowsLocked(cols int) []string {
1601- key := i.chatCacheKeyLocked(cols)
1602- if i.stableChatCacheValid && i.stableChatCacheKey == key {
1603- return append([]string(nil), i.stableChatCache...)
1604- }
1605-
1606- renderView := i.view.CloneForRender()
1607- renderView.Streaming = ""
1608- renderView.StreamingActive = false
1609- renderView.ToolCalls = nil
1610- renderView.Err = ""
1611- if !i.renderOutsideLock {
1612- rows := renderView.Build(cols)
1613- i.stableChatCache = append(i.stableChatCache[:0], rows...)
1614- i.stableChatCacheKey = key
1615- i.stableChatCacheValid = true
1616- return append([]string(nil), rows...)
1617- }
1618- // Markdown, syntax highlighting, and wrapping are the expensive part of
1619- // a frame. Build the immutable snapshot without holding the interactive
1620- // mutex so key processing can continue while a cold transcript renders.
1621- i.mu.Unlock()
1622- rows := renderView.Build(cols)
1623- i.mu.Lock()
1624- if i.view.MessagesRevision == renderView.MessagesRevision &&
1625- i.view.RenderCacheRevision == renderView.RenderCacheRevision {
1626- i.view.AdoptRenderCacheFrom(renderView)
1627- }
1628-
1629- i.stableChatCache = append(i.stableChatCache[:0], rows...)
1630- i.stableChatCacheKey = key
1631- i.stableChatCacheValid = true
1632- return append([]string(nil), rows...)
1633-}
1634-
1635-func (i *Interactive) buildChatLocked(cols int) []string {
1636- if i.agent != nil {
1637- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
1638- i.view.MessagesRevision = i.agent.Revision()
1639- } else {
1640- i.view.Messages = nil
1641- i.view.MessagesRevision = 0
1642- }
1643- // Pacer flush: while the streaming pacer is still draining the
1644- // buffer (i.e. EvAssistantMessage already fired but more runes
1645- // are queued), the final assistant message is already in
1646- // i.agent.Messages() in full. Painting it in the transcript
1647- // AND the streaming block at the same time shows the user the
1648- // complete text immediately — which defeats the whole pacer.
1649- // Hide the last message until the pacer catches up; once the
1650- // flush-pending latch clears, the message is revealed (the
1651- // streaming block disappears the same frame because streamOn
1652- // flips off, so the transition is seamless).
1653- if i.streamFlushPending && len(i.view.Messages) > 0 {
1654- i.view.Messages = i.view.Messages[:len(i.view.Messages)-1]
1655- }
1656- i.view.Streaming = i.streaming.String()
1657- i.view.StreamingActive = i.streamOn
1658- // Guard against the narrow race where EvAssistantMessage has
1659- // just promoted a streaming reply into the transcript but a
1660- // render tick hasn't flipped streamOn off yet. Without the
1661- // guard, the same text would appear twice (once as the
1662- // in-flight streaming block, once as the last transcript
1663- // message). We detect the duplicate strictly: the last
1664- // assistant message's visible text must equal the streaming
1665- // buffer. Just matching on role is too broad — it also hides
1666- // the next round's typewriter streaming after a tool turn,
1667- // because the last transcript message is always assistant
1668- // (the tool-use block) until the follow-up summary lands.
1669- if i.streamOn && i.streaming.Len() > 0 {
1670- if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
1671- if assistantText(i.view.Messages[n-1]) == i.streaming.String() {
1672- i.view.StreamingActive = false
1673- }
1674- }
1675- }
1676- // Live tool-call view: only shown while a turn is in flight. Once
1677- // the agent is idle, every tool call has already been folded into
1678- // the transcript (as assistant.ToolCallBlock + a tool-role message),
1679- // so showing v.ToolCalls a second time would duplicate them below
1680- // the final assistant text — which looks like the summary came
1681- // "before" the tools.
1682- i.view.ToolCalls = i.view.ToolCalls[:0]
1683- if i.busy {
1684- for _, id := range i.toolOrder {
1685- // Deterministic ordering: a tool block stays hidden until
1686- // the paced assistant text that preceded it has finished
1687- // typing out. toolOrder is append-only in arrival order,
1688- // so once one tool is still gated, every later tool is too,
1689- // so stop here to avoid showing a tool out of sequence.
1690- if !i.toolGateOpenLocked(id) {
1691- break
1692- }
1693- if tc, ok := i.toolCalls[id]; ok {
1694- i.view.ToolCalls = append(i.view.ToolCalls, *tc)
1695- }
1696- }
1697- }
1698- i.view.Err = i.statusErr
1699- // Live streaming/tool rows are appended to the chat buffer (not
1700- // hoisted into a separate live block above the editor). That keeps
1701- // the renderer's diff view append-only: when a tool finishes the
1702- // rows update in place at the end of the buffer, instead of the
1703- // whole bottom band shrinking and shifting chat lines around.
1704- i.liveBlock = nil
1705- stable := i.stableChatRowsLocked(cols)
1706- chat := append([]string(nil), stable...)
1707- chat = append(chat, i.view.BuildLive(cols)...)
1708-
1709- // Welcome banner: shown at the top of the chat area when there is
1710- // no transcript yet. Disappears after the first message is sent.
1711- // The version suffix is shown for welcomeVersionDuration after
1712- // startup, then drops off automatically.
1713- if len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 {
1714- showVer := !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1715- chat = append(welcomeBanner(i.cfg.Theme, i.cfg.Version, showVer), chat...)
1716- }
1717-
1718- // Update-available banner: prepended above everything else so it's
1719- // the first thing the user sees when opening a new zut session.
1720- // Once rendered, it stays until the user updates to a newer
1721- // version — we don't persist a "dismissed" flag because this is
1722- // cheap and re-showing it is how most users remember to update.
1723- if i.updateInfo.Available {
1724- banner := renderUpdateBanner(i.cfg.Theme, i.updateInfo, cols)
1725- chat = append(banner, chat...)
1726- }
1727-
1728- // /help block: appended to the transcript so it appears at the
1729- // bottom of the chat area (right above the status bar / editor).
1730- // Prepending it would push long conversations off the top of the
1731- // viewport, which users would miss entirely.
1732- if len(i.helpBlock) > 0 {
1733- chat = append(chat, i.helpBlock...)
1734- }
1735-
1736- if i.statusOK != "" {
1737- // Hard-truncate the OK line to the visible width so a long
1738- // session path ("resumed session: /Users/.../sessions/...")
1739- // doesn't overflow past the right edge and look broken on a
1740- // narrow terminal.
1741- line := "✓ " + i.statusOK
1742- if cols > 4 && len(line) > cols {
1743- line = line[:cols-3] + "..."
1744- }
1745- chat = append(chat, i.cfg.Theme.FGColor(i.cfg.Theme.Tool, line), "")
1746- }
1747-
1748- // Live shell-escape output (!command / entry.pre) streams into the
1749- // transcript area while the command runs, then is replaced by the
1750- // final user-context message when it finishes.
1751- if i.shellRunning && i.shellLive != "" {
1752- chat = append(chat, strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n")...)
1753- chat = append(chat, "")
1754- }
1755-
1756- // Extension notes (notify / display) live just under the
1757- // transcript, above the dialog/editor band. Cleared by /clear.
1758- if len(i.extNotes) > 0 {
1759- chat = append(chat, i.extNotes...)
1760- chat = append(chat, "")
1761- }
1762-
1763- // Reload failures remain in the scrolling chat until /clear. They are
1764- // host-only display state, not provider messages or persisted context.
1765- // While the latest failure is still the temporary status error, hide its
1766- // stored copy to avoid painting the same message twice.
1767- reloadErrors := i.reloadErrors
1768- if n := len(reloadErrors); n > 0 && reloadErrors[n-1] == i.statusErr {
1769- reloadErrors = reloadErrors[:n-1]
1770- }
1771- if len(reloadErrors) > 0 {
1772- chat = append(chat, renderReloadErrors(i.cfg.Theme, reloadErrors, cols)...)
1773- chat = append(chat, "")
1774- }
1775-
1776- // Strip trailing blank rows so the chat content sits flush
1777- // against the new "blank above status bar" row added by the
1778- // bottom-region assembly. Build() ends every message with a
1779- // blank separator; without this trim, the final message in
1780- // the transcript would have its own trailing blank plus the
1781- // status block's leading blank, doubling the gap.
1782- for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
1783- chat = chat[:len(chat)-1]
1784- }
1785- return chat
1786-}
1787-
17881379 // sessionsRoot returns the session namespace for this run.
1789-func (i *Interactive) sessionsRoot() string {
1790- if i.cfg.SessionsRoot != "" {
1791- return i.cfg.SessionsRoot
1792- }
1793- return i.cfg.ZutHome
1794-}
17951380
17961381 // lastCols returns the current terminal width in columns.
1797-func (i *Interactive) lastCols() int {
1798- cols, _ := i.cfg.Terminal.Size()
1799- return cols
1800-}
18011382
18021383 // chatPage returns the number of chat rows currently visible, used
18031384 // as the page size for PageUp/PageDown.
1804-func (i *Interactive) chatPage() int {
1805- _, rows := i.cfg.Terminal.Size()
1806- p := rows - 6 // rough reservation for status + editor + a dialog line
1807- if p < 4 {
1808- p = 4
1809- }
1810- return p
1811-}
18121385
18131386 // scrollBy adjusts the scroll offset. Positive = up (into history).
18141387 // Clearing the parked-turn label when we're back at the bottom means
18151388 // the "viewing turn N" footer goes away automatically as soon as you
18161389 // scroll back to the live tail.
1817-func (i *Interactive) scrollBy(delta int) {
1818- i.mu.Lock()
1819- i.scrollOffset += delta
1820- if i.scrollOffset < 0 {
1821- i.scrollOffset = 0
1822- }
1823- if i.scrollOffset == 0 {
1824- i.parkedTurn = 0
1825- i.parkedTotal = 0
1826- }
1827- if i.rend != nil {
1828- // VS Code's terminal is especially prone to leaving stray
1829- // wrapped-character fragments behind during scroll-driven
1830- // viewport changes. Force a full repaint on scroll, but
1831- // avoid a whole-screen clear because that visibly flickers.
1832- i.requestRendererInvalidate()
1833- }
1834- i.mu.Unlock()
1835- i.invalidate()
1836-}
18371390
18381391 // anchorScrollOffset keeps the user's reading position pinned when the
18391392 // chat buffer grows/shrinks or the viewport height changes between two
@@ -1844,637 +1397,8 @@ func (i *Interactive) scrollBy(delta int) {
18441397 // `start` constant we adjust the offset by the buffer-length delta minus
18451398 // the viewport-height delta. The result is clamped to [0, newLen] so a
18461399 // shrinking buffer can't push it negative.
1847-func anchorScrollOffset(offset, prevLen, newLen, prevRows, newRows int) int {
1848- adj := (newLen - prevLen) - (newRows - prevRows)
1849- offset += adj
1850- if offset < 0 {
1851- offset = 0
1852- }
1853- if offset > newLen {
1854- offset = newLen
1855- }
1856- return offset
1857-}
18581400
18591401 // scrollToBottom pins the view to the latest content.
1860-func (i *Interactive) scrollToBottom() {
1861- i.mu.Lock()
1862- i.scrollOffset = 0
1863- i.parkedTurn = 0
1864- i.parkedTotal = 0
1865- // Reset the auto-follow baseline. scrollToBottom is the resume /
1866- // session-swap snap point, where the chat buffer changes length
1867- // wholesale. Without zeroing these, the next render's follow guard
1868- // compares the fresh transcript's length against a stale baseline
1869- // and nudges scrollOffset, which reads as a viewport jump right
1870- // after resume. See commit 43da5e5 for the same fix on new turns.
1871- i.prevChatLen = 0
1872- i.prevChatCols = 0
1873- i.requestRendererInvalidate()
1874- i.mu.Unlock()
1875- i.invalidate()
1876-}
1877-
1878-func (i *Interactive) redraw() {
1879- i.mu.Lock()
1880- renderRevision := i.renderRevision.Load()
1881-
1882- cols, rows := i.cfg.Terminal.Size()
1883- mainCols := cols
1884- rightBarWidgets := i.rightBarWidgetsLocked()
1885- rightBarActive := false
1886- var rightBarWidth int
1887- if !i.rightBarHidden && len(rightBarWidgets) > 0 {
1888- if width, rail, ok := tui.RightBarColumns(cols); ok {
1889- mainCols = width
1890- rightBarWidth = rail
1891- rightBarActive = true
1892- }
1893- }
1894- rightBarFallback := len(rightBarWidgets) > 0 && !rightBarActive
1895- i.renderOutsideLock = true
1896- chat := i.cachedChatLocked(mainCols)
1897- i.renderOutsideLock = false
1898- if i.renderRevision.Load() != renderRevision {
1899- i.mu.Unlock()
1900- return
1901- }
1902-
1903- // Dialogs (login or model picker) render between chat and the editor.
1904- var dialog []string
1905- switch {
1906- case i.dialog.Active():
1907- dialog = i.dialog.Render(i.cfg.Theme, mainCols)
1908- case i.modelDialog.Active():
1909- dialog = i.modelDialog.Render(i.cfg.Theme, mainCols)
1910- case i.llamaDialog.Active():
1911- dialog = i.llamaDialog.Render(i.cfg.Theme, mainCols)
1912- case i.rescueDialog.Active():
1913- dialog = i.rescueDialog.Render(i.cfg.Theme, mainCols)
1914- case i.sessionDialog.Active():
1915- // Reserve rows for the editor (~3), status line (1-2),
1916- // dialog chrome (header + hint + rule + indicators, ~5),
1917- // and leave the remainder for session rows. Minimum of 3
1918- // rows so even a very small terminal shows something.
1919- _, rows := i.cfg.Terminal.Size()
1920- avail := rows - 12
1921- if avail < 3 {
1922- avail = 3
1923- }
1924- i.sessionDialog.MaxRows = avail
1925- dialog = i.sessionDialog.Render(i.cfg.Theme, mainCols)
1926- case i.residentSubagentsDialog.Active():
1927- _, terminalRows := i.cfg.Terminal.Size()
1928- dialog = i.residentSubagentsDialog.Render(i.cfg.Theme, mainCols, terminalRows)
1929- case i.residentChildSession != nil:
1930- _, terminalRows := i.cfg.Terminal.Size()
1931- dialog = i.residentChildSession.Render(mainCols, terminalRows)
1932- case i.jumpDialog.Active():
1933- dialog = i.jumpDialog.Render(i.cfg.Theme, mainCols)
1934- case i.extPanel.Active() && (!i.confirmDialog.Active() || !i.confirmDialog.Focused()):
1935- dialog = i.extPanel.Render(i.cfg.Theme, mainCols)
1936- case i.confirmDialog.Active():
1937- if i.btwDialog.Active() {
1938- // Keep the side-chat transcript and its live tool preview visible
1939- // above confirmation, matching how the main transcript remains
1940- // visible while --no-yolo waits for a decision.
1941- dialog = renderBtwConfirmation(i.cfg.Theme, mainCols, i.btwDialog, i.confirmDialog)
1942- } else {
1943- dialog = i.confirmDialog.Render(i.cfg.Theme, mainCols)
1944- }
1945- case i.btwDialog.Active():
1946- dialog = i.btwDialog.Render(i.cfg.Theme, mainCols)
1947- case i.skillsDialog.Active():
1948- dialog = i.skillsDialog.Render(i.cfg.Theme, mainCols)
1949- case i.changelogDialog.Active():
1950- dialog = i.changelogDialog.Render(i.cfg.Theme, mainCols)
1951- case i.logoutDialog.Active():
1952- dialog = i.logoutDialog.Render(i.cfg.Theme, mainCols)
1953- case i.telegramDialog.Active():
1954- dialog = i.telegramDialog.Render(i.cfg.Theme, mainCols)
1955- case i.settingsDialog.Active():
1956- dialog = i.settingsDialog.Render(i.cfg.Theme, mainCols)
1957- case i.sessionOpsDialog.Active():
1958- dialog = i.sessionOpsDialog.Render(i.cfg.Theme, mainCols)
1959- case i.sessionTreeDialog.Active():
1960- // Reserve rows for the editor, status, and tree chrome while using
1961- // the same budget for rendering and PageUp/PageDown movement.
1962- _, rows := i.cfg.Terminal.Size()
1963- avail := rows - 12
1964- if avail < 3 {
1965- avail = 3
1966- }
1967- i.sessionTreeDialog.MaxRows = avail
1968- dialog = i.sessionTreeDialog.Render(i.cfg.Theme, mainCols)
1969- }
1970- if len(dialog) > 0 {
1971- dialog = padDialogFrame(dialog)
1972- }
1973- modalBackdrop := len(dialog) > 0
1974-
1975- // Slash-command autocomplete: popup above the status line, only
1976- // when the editor starts with "/" and no dialog is already open.
1977- // Feed extension-registered commands into the suggester first so
1978- // they show up in tab-complete + the popup alongside the built-ins.
1979- i.suggest.SetJailed(i.cfg.Sandbox.Locked())
1980- i.suggest.SetLlamaConfigured(i.llamaConfigured)
1981- if i.cfg.Extensions != nil {
1982- catalog := i.cfg.Extensions.Commands()
1983- extra := make([]slashCommand, 0, len(catalog))
1984- for _, c := range catalog {
1985- // The popup renders extension commands under a dedicated
1986- // "── extensions ───" divider, so the description doesn't
1987- // need to repeat the source. If the description is empty,
1988- // fall back to the extension name so the row isn't blank.
1989- desc := c.Description
1990- if strings.TrimSpace(desc) == "" {
1991- desc = "(" + c.Extension + ")"
1992- }
1993- extra = append(extra, slashCommand{
1994- Name: "/" + c.Name,
1995- Desc: desc,
1996- })
1997- }
1998- i.suggest.SetExtra(extra)
1999- }
2000- var suggest []string
2001- currentInput := i.ed.Value()
2002- if i.suggest.SkillInputStarted(currentInput) {
2003- var list []*skills.Skill
2004- if i.cfg.SkillSnapshot != nil {
2005- list = i.cfg.SkillSnapshot()
2006- }
2007- i.suggest.SetSkills(list)
2008- }
2009- // Slash popup renders even while the agent is busy so the user
2010- // can queue a destructive command (/clear, /compact, /logout,
2011- // /model) or a read-only one (/help, /jump, /sessions, etc.)
2012- // without waiting for the current turn to finish. The dispatcher
2013- // in runSlash already handles the busy case per-command: safe
2014- // ones run immediately, destructive ones cancel the turn first.
2015- i.fileSuggest.SetCWD(i.cfg.CWD)
2016- mainInputFocused := len(dialog) == 0 || (i.confirmDialog.Active() && !i.confirmDialog.Focused() && !i.confirmChildActiveLocked())
2017- if mainInputFocused && i.suggest.Active(currentInput) {
2018- suggest = i.suggest.Render(currentInput, i.cfg.Theme, mainCols)
2019- } else if mainInputFocused && i.fileSuggest.Active(currentInput) {
2020- suggest = i.fileSuggest.Render(currentInput, i.cfg.Theme, mainCols)
2021- }
2022-
2023- // Detect overlay close (any dialog or slash/file suggestion popup
2024- // just transitioned from open to closed). Force a full redraw so
2025- // the rows the overlay occupied are guaranteed to be repainted
2026- // from the chat below, instead of the diff path leaving stale
2027- // dialog content behind. Equivalent to the user pressing ctrl+l.
2028- overlayOpen := len(dialog) > 0 || len(suggest) > 0
2029- if i.rend != nil && i.prevOverlayOpen && !overlayOpen {
2030- // An overlay (dialog or slash/file popup) just closed, so the
2031- // bottom band shrinks. On terminals where we can drop
2032- // scrollback, a full Clear is the simplest way to guarantee
2033- // the vacated rows are repainted from the chat below.
2034- //
2035- // On VS Code's terminal closing a dialog leaves the stale
2036- // overlay rows in the retained scrollback (we can't drop them
2037- // with the quiet in-place diff). Run the same full Clear() that
2038- // Ctrl+L uses so the scrollback is purged and the conversation
2039- // is repainted clean, matching what the user expects after
2040- // dismissing a picker. Clear() is keepScrollback-aware and
2041- // emits \x1b[3J there.
2042- i.rend.Clear()
2043- }
2044- i.prevOverlayOpen = overlayOpen
2045- if len(suggest) > 0 {
2046- // One blank row above the popup so it doesn't sit flush
2047- // against the chat / welcome content above.
2048- suggest = append([]string{""}, suggest...)
2049- }
2050-
2051- // Busy prefix shown at the far left of the status bar. The spinner owns
2052- // only its frame; the activity label reports the operation in progress.
2053- var busyPrefix string
2054- if i.busy {
2055- label := i.activity.label()
2056- if label == "" {
2057- label = "Preparing request"
2058- }
2059- busyPrefix = fmt.Sprintf("%s %s %s %s",
2060- i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, i.spin.Frame()),
2061- i.cfg.Theme.FGColor(i.cfg.Theme.Assistant, label),
2062- i.cfg.Theme.FGColor(i.cfg.Theme.Muted, "-"),
2063- i.cfg.Theme.FGColor(i.cfg.Theme.Muted, i.spin.Elapsed().String()),
2064- )
2065- }
2066-
2067- ctxMax := 0
2068- if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
2069- ctxMax = m.ContextWindow
2070- }
2071- statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
2072- workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
2073- workingWithStatus := statusPosition == workingPosition
2074- statusBusyPrefix := ""
2075- if workingWithStatus {
2076- statusBusyPrefix = busyPrefix
2077- }
2078- goalStatus := string(i.goalStatus)
2079- statusLines := tui.StatusBar(tui.StatusBarParams{
2080- Theme: i.cfg.Theme,
2081- Provider: i.cfg.Provider,
2082- Model: i.cfg.Model,
2083- Reasoning: i.cfg.Reasoning,
2084- FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
2085- Busy: i.busy,
2086- BusyPrefix: statusBusyPrefix,
2087- CWD: i.cfg.CWD,
2088- Locked: i.cfg.Sandbox.Locked(),
2089- NoYolo: i.cfg.NoYolo,
2090- GoalStatus: goalStatus,
2091- Usage: i.cumUsage,
2092- Subscription: i.cfg.AuthMethod == "oauth",
2093- ContextUsed: i.lastCtxInput,
2094- ContextMax: ctxMax,
2095- AutoCompacting: i.autoCompacting,
2096- Telegram: i.telegramBridge != nil && i.telegramBridge.Active(),
2097- Cols: mainCols,
2098- })
2099- inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
2100- if inputStyle == tui.InputStyleLines || inputStyle == tui.InputStyleBlock {
2101- i.ed.Prompt = ""
2102- } else {
2103- i.ed.Prompt = i.cfg.Theme.AccentBar(i.cfg.Theme.Accent)
2104- }
2105- edLines, curR, curC := i.ed.Render(mainCols)
2106- dashboardActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
2107- var allResidentSubagentLines []string
2108- residentSubagentHidden := 0
2109- if !dashboardActive && i.cfg.ResidentManager != nil {
2110- const residentIndicatorSnapshotLimit = 8
2111- snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
2112- allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
2113- residentSubagentHidden = total - len(snapshots)
2114- }
2115- var workingLines []string
2116- if busyPrefix != "" && !workingWithStatus {
2117- workingLines = []string{" " + busyPrefix}
2118- }
2119- inputCursorOffset := 0
2120- inputCursorColOffset := 0
2121- switch inputStyle {
2122- case tui.InputStyleLines:
2123- edLines = tui.InputLines(i.cfg.Theme, edLines, mainCols)
2124- inputCursorOffset = 1
2125- case tui.InputStyleBlock:
2126- edLines = tui.InputBlock(i.cfg.Theme, edLines, mainCols)
2127- inputCursorOffset = 1
2128- inputCursorColOffset = 2
2129- }
2130-
2131- // "Sliding in" chips for messages the user typed while a turn is
2132- // in flight. Shown directly above the status bar so they're close
2133- // to the editor but don't push the chat around.
2134- var queue []string
2135- queued := append([]string(nil), i.queued...)
2136- if i.agent != nil {
2137- queued = append(queued, i.agent.PendingQueuedMessages()...)
2138- }
2139- if len(queued) > 0 {
2140- queue = append(queue, "")
2141- for _, q := range queued {
2142- label := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, " sliding in: ")
2143- text := truncateLine(q, mainCols-17)
2144- queue = append(queue, label+i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text))
2145- }
2146- // Hint row, rendered in the same muted tone as the model
2147- // info on the status bar so it reads as ambient metadata
2148- // rather than a chip. Tells the user how to recover the
2149- // most recent queued message back into the editor.
2150- hint := " Press " + slideBackChordHint() + " to slide back into input"
2151- queue = append(queue, i.cfg.Theme.FGColor(i.cfg.Theme.Muted, hint))
2152- }
2153-
2154- extensionLines := i.extensionChromeLinesForLayoutLocked(mainCols, rightBarActive, rightBarFallback)
2155-
2156- // Bottom-sticky sections (always visible, never scroll). Each
2157- // non-empty subsection (dialog, suggest popup, sliding-in queue)
2158- // is preceded by one blank row so it has air above the chat
2159- // content. The status block and editor get their own dedicated
2160- // blanks so spacing stays consistent whether or not a dialog or
2161- // popup is showing.
2162- composeBottom := func(residentSubagentLines []string) (bottom []string, inputStartRow int) {
2163- bottom = make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(extensionLines)+len(statusLines)+len(residentSubagentLines)+len(edLines)+9)
2164- inputStartRow = -1
2165- if len(dialog) > 0 {
2166- bottom = append(bottom, "")
2167- }
2168- bottom = append(bottom, dialog...)
2169- // The resident child views own the bottom of the screen while active.
2170- if dashboardActive {
2171- return bottom, inputStartRow
2172- }
2173-
2174- bottom = append(bottom, suggest...)
2175- bottom = append(bottom, queue...)
2176- lineInput := inputStyle == tui.InputStyleLines
2177- statusBelow := statusPosition == tui.StatusPositionBelowInput
2178- workingBelow := workingPosition == tui.WorkingPositionBelowInput
2179- var aboveInput []string
2180- aboveInput = append(aboveInput, extensionLines...)
2181- if !statusBelow {
2182- aboveInput = append(aboveInput, statusLines...)
2183- }
2184- if !workingBelow {
2185- aboveInput = append(aboveInput, workingLines...)
2186- }
2187- var belowInput []string
2188- // Background resident work belongs directly below the editor. It is
2189- // deliberately independent of the main turn's busy/status placement.
2190- belowInput = append(belowInput, residentSubagentLines...)
2191- if workingBelow {
2192- belowInput = append(belowInput, workingLines...)
2193- }
2194- if statusBelow {
2195- belowInput = append(belowInput, statusLines...)
2196- }
2197-
2198- bottom = append(bottom, "")
2199- bottom = append(bottom, aboveInput...)
2200- needsGapAboveInput := len(aboveInput) > 0 && !lineInput
2201- if lineInput && statusBelow && !workingBelow && len(workingLines) > 0 {
2202- needsGapAboveInput = true
2203- }
2204- if needsGapAboveInput {
2205- bottom = append(bottom, "")
2206- }
2207- inputStartRow = len(bottom)
2208- bottom = append(bottom, edLines...)
2209- if len(belowInput) > 0 {
2210- // Active resident work sits immediately below the prompt. Other
2211- // below-input chrome keeps the usual breathing room.
2212- if !lineInput && len(residentSubagentLines) == 0 {
2213- bottom = append(bottom, "")
2214- }
2215- bottom = append(bottom, belowInput...)
2216- }
2217- return bottom, inputStartRow
2218- }
2219-
2220- const rendererBottomMarginRows = 1
2221- maxBottomRows := rows - rendererBottomMarginRows - 1
2222- var residentSubagentLines []string
2223- var bottom []string
2224- var inputStartRow int
2225- if !dashboardActive && len(allResidentSubagentLines) > 0 {
2226- for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
2227- candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
2228- candidateBottom, _ := composeBottom(candidate)
2229- if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
2230- residentSubagentLines = candidate
2231- break
2232- }
2233- }
2234- }
2235- bottom, inputStartRow = composeBottom(residentSubagentLines)
2236-
2237- chatRows := rows - len(bottom)
2238- if chatRows < 1 {
2239- chatRows = 1
2240- }
2241-
2242- // Auto-follow guard: when the user has scrolled up (scrollOffset
2243- // > 0) and the agent appends new content below the viewport while
2244- // they're reading, compensate so the visible content stays
2245- // anchored. scrollOffset is measured from the bottom of `chat`,
2246- // so without compensation a growing buffer pushes the window
2247- // downward through the content and the lines the user was
2248- // reading scroll off the top.
2249- //
2250- // Skip compensation when the terminal width changed (a resize
2251- // reflows the whole buffer and the line-count delta no longer
2252- // corresponds to appended content) and when scrollOffset is 0
2253- // (the user is following the tail and wants new content to push
2254- // the view down as usual).
2255- //
2256- // The window the user sees starts at row
2257- // start = len(chat) - scrollOffset - chatRows
2258- // so to keep `start` fixed across a redraw we must offset by both
2259- // the buffer growth (len delta) AND the viewport-height change
2260- // (chatRows delta, e.g. the status band or sliding-in queue
2261- // appearing during a turn). Compensating only for the len delta
2262- // let a shrinking chatRows pull the window toward the tail, which
2263- // read as the viewport jumping to the bottom whenever the agent
2264- // streamed text or a tool call grew the bottom band.
2265- if i.scrollOffset > 0 && i.prevChatCols == mainCols && i.prevChatLen > 0 {
2266- i.scrollOffset = anchorScrollOffset(i.scrollOffset,
2267- i.prevChatLen, len(chat), i.prevChatRows, chatRows)
2268- }
2269- i.prevChatLen = len(chat)
2270- i.prevChatCols = mainCols
2271- i.prevChatRows = chatRows
2272-
2273- // Apply scroll offset to the chat slice.
2274- maxOffset := len(chat) - chatRows
2275- if maxOffset < 0 {
2276- maxOffset = 0
2277- }
2278- // Tail-render expansion: if the user has scrolled to (or above)
2279- // the top of the currently rendered tail and there are still
2280- // truncated messages above, widen view.TailLimit and rebuild.
2281- // The chat cache is keyed on tailLimit so the next cachedChatLocked
2282- // will re-issue Build instead of returning the stale slice. We
2283- // rebuild immediately so the same redraw shows the freshly-revealed
2284- // rows; otherwise the user would have to scroll again to see them.
2285- if i.scrollOffset >= maxOffset && i.view.TailLimit > 0 && i.view.TailLimit < len(i.view.Messages) {
2286- prevLen := len(chat)
2287- i.view.TailLimit += resumeTailExpandStep
2288- if i.view.TailLimit >= len(i.view.Messages) {
2289- i.view.TailLimit = 0 // unbounded
2290- }
2291- i.chatCacheValid = false
2292- chat = i.cachedChatLocked(mainCols)
2293- for len(chat) > 0 && strings.TrimSpace(chat[len(chat)-1]) == "" {
2294- chat = chat[:len(chat)-1]
2295- }
2296- // Newly-revealed rows are older messages prepended at the top.
2297- // scrollOffset counts rows from the bottom, so to keep the
2298- // viewport visually anchored on the same content the user was
2299- // looking at we shift it up by the number of rows added. Keep
2300- // the auto-follow baseline (prevChatLen) in sync with the
2301- // post-expansion length too, so the next render's follow guard
2302- // doesn't see this growth as a synthetic delta and yank the
2303- // viewport again.
2304- if grew := len(chat) - prevLen; grew > 0 {
2305- i.scrollOffset += grew
2306- }
2307- i.prevChatLen = len(chat)
2308- i.prevChatCols = mainCols
2309- maxOffset = len(chat) - chatRows
2310- if maxOffset < 0 {
2311- maxOffset = 0
2312- }
2313- }
2314- if i.scrollOffset > maxOffset {
2315- i.scrollOffset = maxOffset
2316- }
2317- if i.scrollOffset < 0 {
2318- i.scrollOffset = 0
2319- }
2320-
2321- var visibleChat []string
2322- if len(chat) <= chatRows {
2323- visibleChat = chat
2324- } else {
2325- end := len(chat) - i.scrollOffset
2326- rawStart := end - chatRows
2327- if rawStart < 0 {
2328- rawStart = 0
2329- }
2330- start := snapViewportStartToImageBlock(chat, rawStart)
2331- // If the snap pulled start upward (an image-block was atomic) while
2332- // the user is scrolling downward, the viewport would sit on the same
2333- // image until the user mashes down past every reserved row. Bump
2334- // scrollOffset past the image so one keypress always clears it.
2335- if start < rawStart && i.scrollOffset < i.prevScrollOffset {
2336- jump := rawStart - start
2337- i.scrollOffset -= jump
2338- if i.scrollOffset < 0 {
2339- i.scrollOffset = 0
2340- }
2341- end = len(chat) - i.scrollOffset
2342- rawStart = end - chatRows
2343- if rawStart < 0 {
2344- rawStart = 0
2345- }
2346- start = snapViewportStartToImageBlock(chat, rawStart)
2347- }
2348- end = start + chatRows
2349- if end > len(chat) {
2350- end = len(chat)
2351- start = end - chatRows
2352- if start < 0 {
2353- start = 0
2354- }
2355- }
2356- visibleChat = chat[start:end]
2357- }
2358- i.prevScrollOffset = i.scrollOffset
2359- visibleChat = clipBottomClippedImages(visibleChat)
2360-
2361- // A tiny "scrolled up" indicator in the top-right of the chat pane
2362- // so you know you're not at the bottom. When the viewport was
2363- // parked by /jump we include the turn number so the user remembers
2364- // they're reading history rather than the live conversation.
2365- if i.scrollOffset > 0 && len(visibleChat) > 0 {
2366- var text string
2367- if i.parkedTurn > 0 && i.parkedTotal > 0 {
2368- text = fmt.Sprintf(" ↑ viewing turn %d of %d - %d lines more below (pgdn / end)",
2369- i.parkedTurn, i.parkedTotal, i.scrollOffset)
2370- } else {
2371- text = fmt.Sprintf(" ↑ %d lines more below (end to jump)", i.scrollOffset)
2372- }
2373- note := i.cfg.Theme.FGColor(i.cfg.Theme.Muted, text)
2374- visibleChat = append([]string{note}, visibleChat...)
2375- if len(visibleChat) > chatRows {
2376- visibleChat = visibleChat[:chatRows]
2377- }
2378- }
2379-
2380- // A dialog is the foreground layer. Dim every other visible surface,
2381- // including the main input beneath it, without changing the dialog's
2382- // own styling. Keep the source slices untouched because chat is cached.
2383- if modalBackdrop {
2384- chat = tui.DimLines(chat)
2385- visibleChat = tui.DimLines(visibleChat)
2386- dimmedBottom := tui.DimLines(bottom)
2387- copy(dimmedBottom[1:1+len(dialog)], dialog)
2388- bottom = dimmedBottom
2389- }
2390-
2391- // Default: the real terminal cursor sits on the main editor's
2392- // input position. In main-screen log mode cursor rows are relative
2393- // to the fixed bottom band, not the chat transcript.
2394- // dialogLead is 1 when the bottom region prepends a blank above
2395- // the dialog block (whenever a dialog is showing) so popup-side
2396- // cursor positions still land in the right cell.
2397- dialogLead := 0
2398- if len(dialog) > 0 {
2399- dialogLead = 1
2400- }
2401- cursorRow := inputStartRow + inputCursorOffset + curR
2402- cursorCol := curC + inputCursorColOffset
2403- cursorInDialog := false
2404- if i.btwDialog.Active() {
2405- if r, c := i.btwDialog.CursorPos(mainCols); r >= 0 {
2406- cursorRow = dialogLead + r
2407- cursorCol = c
2408- cursorInDialog = true
2409- }
2410- }
2411- if i.dialog.Active() {
2412- if r, c := i.dialog.CursorPos(mainCols); r >= 0 {
2413- cursorRow = dialogLead + r
2414- cursorCol = c
2415- cursorInDialog = true
2416- }
2417- }
2418- if i.llamaDialog.Active() {
2419- if r, c := i.llamaDialog.CursorPos(); r >= 0 {
2420- cursorRow = dialogLead + r
2421- cursorCol = c
2422- cursorInDialog = true
2423- } else {
2424- cursorRow = -1
2425- cursorCol = 0
2426- }
2427- }
2428- if i.sessionDialog.Active() {
2429- if r, c := i.sessionDialog.CursorPos(); r >= 0 {
2430- cursorRow = dialogLead + r
2431- cursorCol = c
2432- cursorInDialog = true
2433- }
2434- }
2435- if i.sessionTreeDialog.Active() {
2436- if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
2437- cursorRow = dialogLead + r
2438- cursorCol = c
2439- cursorInDialog = true
2440- }
2441- }
2442- if i.extPanel.Active() {
2443- cursorRow = -1
2444- cursorCol = 0
2445- }
2446- i.setInputCursorDimmed(modalBackdrop && !cursorInDialog)
2447- if i.renderRevision.Load() != renderRevision {
2448- i.mu.Unlock()
2449- return
2450- }
2451- theme := i.cfg.Theme
2452- // State assembly above is synchronized; terminal output is owned by
2453- // this renderer goroutine and must not hold the global state mutex.
2454- i.mu.Unlock()
2455- if rightBarActive {
2456- rightBar := tui.RenderRightBar(theme, rightBarWidgets, rightBarWidth, rows)
2457- if modalBackdrop {
2458- i.rend.DrawRightBarDimmed(visibleChat, bottom, tui.DimLines(rightBar), cursorRow, cursorCol)
2459- } else {
2460- i.rend.DrawRightBar(visibleChat, bottom, rightBar, cursorRow, cursorCol)
2461- }
2462- } else {
2463- _ = visibleChat // maintained for legacy scroll state/indicators; DrawLog owns chat viewport.
2464- i.rend.DrawLog(chat, bottom, cursorRow, cursorCol)
2465- }
2466- i.mu.Lock()
2467- if i.pendingAlert != nil && !i.busy && !i.streamOn && !i.streamFlushPending && len(i.streamPending) == 0 {
2468- alert := *i.pendingAlert
2469- i.pendingAlert = nil
2470- i.emitAlertLocked(alert)
2471- }
2472- i.mu.Unlock()
2473-}
2474-
2475-func hasImageEscape(line string) bool {
2476- return strings.Contains(line, "\x1b]1337;File=") || strings.Contains(line, "\x1b_G")
2477-}
24781402
24791403 // snapViewportStartToImageBlock treats inline images as atomic blocks for
24801404 // scrolling. Terminal image protocols draw from a single escape row into a
@@ -2482,3019 +1406,130 @@ func hasImageEscape(line string) bool {
24821406 // footprint. If the viewport starts on one of those blank rows, there is no
24831407 // correct partial-image state to render. Snap back to the escape row instead
24841408 // so the image is either shown from its beginning or skipped entirely.
2485-func snapViewportStartToImageBlock(chat []string, start int) int {
2486- if start <= 0 || start >= len(chat) {
2487- return start
2488- }
2489- if hasImageEscape(chat[start]) || !isBoxBlankLine(chat[start]) {
2490- return start
2491- }
2492- for k := start - 1; k >= 0; k-- {
2493- line := chat[k]
2494- if hasImageEscape(line) {
2495- return k
2496- }
2497- if !isBoxBlankLine(line) {
2498- break
2499- }
2500- }
2501- return start
2502-}
25031409
25041410 const (
2505- hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
2506- autoCompactContinueMetaKey = "auto_compact_continue"
2507- shellEscapeMetaKey = "shell_escape"
2508-)
2509-
2510-func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message {
2511- if len(msgs) == 0 {
2512- return nil
2513- }
2514- out := make([]provider.Message, 0, len(msgs))
2515- for _, m := range msgs {
2516- if isHiddenTranscriptMessage(m) {
2517- continue
2518- }
2519- out = append(out, m)
2520- }
2521- return out
2522-}
2523-
2524-func isHiddenTranscriptMessage(m provider.Message) bool {
2525- if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
2526- return true
2527- }
2528- if m.Role != provider.RoleUser || len(m.Content) == 0 {
2529- return false
2530- }
2531- tb, ok := m.Content[0].(provider.TextBlock)
2532- if !ok {
2533- return false
2534- }
2535- return strings.TrimSpace(tb.Text) == hiddenOpenAIImageMirrorPrefix
2536-}
2537-
2538-func clipBottomClippedImages(lines []string) []string {
2539- if len(lines) == 0 {
2540- return lines
2541- }
2542- out := append([]string(nil), lines...)
2543- for i, line := range out {
2544- if !hasImageEscape(line) {
2545- continue
2546- }
2547- // Image blocks render as: image escape, zero or more blank
2548- // reservation rows, then the muted "image - ..." info line,
2549- // then one trailing blank. If the info line isn't visible in
2550- // the current chat slice, the image would paint down into the
2551- // fixed status bar area. Suppress that image for this frame.
2552- //
2553- // When the image lives inside a tool box, the reservation rows
2554- // are wrapped in vertical box edges ("│ ... │"); those rows
2555- // look non-blank under a naive whitespace check but are still
2556- // reservation rows for this scan, so treat them as blank.
2557- foundInfo := false
2558- for j := i + 1; j < len(out); j++ {
2559- if strings.Contains(out[j], "image - ") {
2560- foundInfo = true
2561- break
2562- }
2563- if !isBoxBlankLine(out[j]) {
2564- break
2565- }
2566- }
2567- if !foundInfo {
2568- out[i] = ""
2569- }
2570- }
2571- return out
2572-}
2573-
2574-// isBoxBlankLine reports whether line is visually empty after
2575-// stripping ANSI escape sequences, surrounding whitespace, and the
2576-// vertical box edges drawn by the tool-box renderer. Used by
2577-// clipBottomClippedImages so an image's reservation rows still count
2578-// as blank when those rows are wrapped in "│ ... │" inside a tool box.
2579-func isBoxBlankLine(line string) bool {
2580- stripped := stripANSIBytes(line)
2581- stripped = strings.TrimSpace(stripped)
2582- stripped = strings.Trim(stripped, "│")
2583- stripped = strings.TrimSpace(stripped)
2584- return stripped == ""
2585-}
2586-
2587-// stripANSIBytes removes ANSI CSI escape sequences (ESC '[' ... final
2588-// byte) from s without pulling in the regexp package. Mirrors the
2589-// internal helper in package tui; the duplicated copy avoids exporting
2590-// it just for one caller.
2591-func stripANSIBytes(s string) string {
2592- if !strings.Contains(s, "\x1b") {
2593- return s
2594- }
2595- var b strings.Builder
2596- b.Grow(len(s))
2597- i := 0
2598- for i < len(s) {
2599- if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
2600- end := i + 2
2601- for end < len(s) {
2602- c := s[end]
2603- end++
2604- if c >= 0x40 && c <= 0x7e {
2605- break
2606- }
2607- }
2608- i = end
2609- continue
2610- }
2611- b.WriteByte(s[i])
2612- i++
2613- }
2614- return b.String()
2615-}
2616-
2617-// truncateLine shortens s so it fits within n display cells, with an
2618-// ellipsis if trimmed. Used by the "sliding in" chips so a pasted
2619-// novel doesn't blow past the status line.
2620-func panelKeyName(k tui.Key) string {
2621- switch k.Kind {
2622- case tui.KeyUp:
2623- return "up"
2624- case tui.KeyDown:
2625- return "down"
2626- case tui.KeyLeft:
2627- return "left"
2628- case tui.KeyRight:
2629- return "right"
2630- case tui.KeyEnter:
2631- return "enter"
2632- case tui.KeyEsc:
2633- return "esc"
2634- case tui.KeyTab:
2635- return "tab"
2636- case tui.KeyBackspace:
2637- return "backspace"
2638- case tui.KeyDelete:
2639- return "delete"
2640- case tui.KeyHome:
2641- return "home"
2642- case tui.KeyEnd:
2643- return "end"
2644- case tui.KeyPageUp:
2645- return "pageup"
2646- case tui.KeyPageDown:
2647- return "pagedown"
2648- case tui.KeyRune:
2649- return "rune"
2650- default:
2651- return "unknown"
2652- }
2653-}
2654-
2655-func panelKeyText(k tui.Key) string {
2656- if k.Kind == tui.KeyRune {
2657- return string(k.Rune)
2658- }
2659- return ""
2660-}
2661-
2662-func truncateLine(s string, n int) string {
2663- if n <= 0 {
2664- return ""
2665- }
2666- // Collapse newlines — chips are single line.
2667- s = strings.ReplaceAll(s, "\n", " ↩ ")
2668- runes := []rune(s)
2669- if len(runes) <= n {
2670- return s
2671- }
2672- if n <= 3 {
2673- return strings.Repeat(".", n)
2674- }
2675- return string(runes[:n-3]) + "..."
2676-}
2677-
2678-// ctrlCExitWindow is how long after a ctrl+c press a *second* press
2679-// will exit instead of just clearing input. Long enough to be
2680-// deliberate (rules out accidental key chord), short enough that the
2681-// hint stays meaningful.
2682-const ctrlCExitWindow = 2 * time.Second
2683-
2684-// armCtrlCExit records the timestamp of the current ctrl+c so the next
2685-// one within ctrlCExitWindow exits.
2686-func (i *Interactive) armCtrlCExit() {
2687- i.mu.Lock()
2688- i.lastCtrlC = time.Now()
2689- i.mu.Unlock()
2690-}
2691-
2692-// ctrlCExitArmed reports whether a previous ctrl+c was recent enough
2693-// that another press should now exit.
2694-func (i *Interactive) ctrlCExitArmed() bool {
2695- i.mu.Lock()
2696- t := i.lastCtrlC
2697- i.mu.Unlock()
2698- return !t.IsZero() && time.Since(t) <= ctrlCExitWindow
2699-}
2700-
2701-// clearFileSuggestQuery strips the filter the user typed after the
2702-// last "@", leaving the bare "@" so the picker stays open. Called when
2703-// navigating between directory levels (Right/Left): the filter applied
2704-// to the level the user was on, not the one being entered, so carrying
2705-// it forward would wrongly hide the new directory's contents.
2706-func (i *Interactive) clearFileSuggestQuery() {
2707- val := i.ed.Value()
2708- if idx := strings.LastIndex(val, "@"); idx >= 0 {
2709- i.ed.SetValue(val[:idx+1])
2710- }
2711-}
2712-
2713-// setToolExpansion updates long tool result expansion and replays the
2714-// transcript so already-emitted rows reflect the new state.
2715-func (i *Interactive) setToolExpansion(expanded bool) {
2716- i.mu.Lock()
2717- i.view.ExpandAll = expanded
2718- i.requestRendererClear()
2719- i.mu.Unlock()
2720- i.invalidate()
2721-}
2722-
2723-func (i *Interactive) toggleToolExpansion() {
2724- i.mu.Lock()
2725- expanded := !i.view.ExpandAll
2726- i.mu.Unlock()
2727- i.setToolExpansion(expanded)
2728-}
2729-
2730-func (i *Interactive) toggleBtwToolExpansion() {
2731- i.btwDialog.ToggleToolExpansion()
2732- i.mu.Lock()
2733- i.requestRendererClear()
2734- i.mu.Unlock()
2735- i.invalidate()
2736-}
2737-
2738-// confirmChildActive reports whether an interaction opened from slash input
2739-// currently owns the keyboard while a tool confirmation remains pending.
2740-func (i *Interactive) confirmChildActive() bool {
2741- i.mu.Lock()
2742- defer i.mu.Unlock()
2743- return i.confirmChildActiveLocked()
2744-}
2745-
2746-func (i *Interactive) confirmChildActiveLocked() bool {
2747- return len(i.helpBlock) > 0 ||
2748- len(i.extNotes) > 0 ||
2749- i.dialog.Active() ||
2750- i.modelDialog.Active() ||
2751- i.llamaDialog.Active() ||
2752- i.rescueDialog.Active() ||
2753- i.sessionDialog.Active() ||
2754- i.residentSubagentsDialog.Active() ||
2755- i.residentChildSession != nil ||
2756- i.jumpDialog.Active() ||
2757- i.btwDialog.Active() ||
2758- i.skillsDialog.Active() ||
2759- i.changelogDialog.Active() ||
2760- i.logoutDialog.Active() ||
2761- i.telegramDialog.Active() ||
2762- i.settingsDialog.Active() ||
2763- i.sessionOpsDialog.Active() ||
2764- i.sessionTreeDialog.Active() ||
2765- i.extPanel.Active()
2766-}
2767-
2768-// restoreConfirmFocus returns input to confirmation after slash input is
2769-// cleared or a child interaction closes. A non-empty editor keeps command
2770-// input focused, and an active child continues to own its keys.
2771-func (i *Interactive) restoreConfirmFocus() {
2772- if !i.confirmDialog.Active() || i.confirmDialog.Focused() {
2773- return
2774- }
2775- if !i.ed.IsEmpty() || i.confirmChildActive() {
2776- return
2777- }
2778- i.confirmDialog.Focus()
2779- i.invalidate()
2780-}
2781-
2782-func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2783- defer i.restoreConfirmFocus()
2784-
2785- var ok bool
2786- if k, ok = i.prepareInputKey(k); !ok {
2787- return false
2788- }
2789-
2790- // Dialogs consume keys while open (except ctrl+c, which always closes them).
2791-
2792- // Confirmation owns input by default. Pressing / deliberately moves
2793- // focus to the main editor without answering the pending request, so
2794- // slash commands and the dialogs they open can run first.
2795- if i.confirmDialog.Focused() {
2796- if k.Kind == tui.KeyRune && k.Rune == '/' {
2797- i.confirmDialog.Blur()
2798- i.ed.Insert("/")
2799- i.suggest.Reset()
2800- i.invalidate()
2801- return false
2802- }
2803- if k.Kind == tui.KeyCtrlO {
2804- if i.btwDialog != nil && i.btwDialog.Active() {
2805- i.toggleBtwToolExpansion()
2806- } else {
2807- i.toggleToolExpansion()
2808- }
2809- return false
2810- }
2811- i.confirmDialog.HandleKey(k)
2812- i.invalidate()
2813- return false
2814- }
2815- if i.confirmDialog.Active() && !i.confirmChildActive() && k.Kind == tui.KeyEsc {
2816- i.ed.Clear()
2817- i.suggest.Reset()
2818- i.fileSuggest.Reset()
2819- i.confirmDialog.Focus()
2820- i.invalidate()
2821- return false
2822- }
2823- if i.dialog.Active() {
2824- if k.Kind == tui.KeyCtrlC {
2825- i.dialog.Close()
2826- if i.cfg.AuthManager != nil {
2827- i.cfg.AuthManager.CancelOAuth()
2828- }
2829- return false
2830- }
2831- act := i.dialog.HandleKey(k)
2832- if act.StartAPIKey {
2833- i.startAPIKeyFlow(act.Provider)
2834- }
2835- if act.StartOAuth {
2836- i.startOAuthFlow(act.Provider)
2837- }
2838- if act.StartManual {
2839- i.startManualOAuthFlow(act.Provider)
2840- }
2841- if act.SubmitCode != "" {
2842- i.submitManualOAuthCode(act.SubmitCode)
2843- }
2844- if act.SaveLlama {
2845- i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
2846- }
2847- return false
2848- }
2849- if i.modelDialog.Active() {
2850- if k.Kind == tui.KeyCtrlC {
2851- i.modelDialog.Close()
2852- i.quickModelAssign = 0
2853- return false
2854- }
2855- act := i.modelDialog.HandleKey(k)
2856- if act.Close {
2857- i.quickModelAssign = 0
2858- }
2859- if act.ReasoningChanged {
2860- i.applyReasoningSetting(act.Reasoning)
2861- }
2862- if act.Select {
2863- if i.quickModelAssign > 0 {
2864- i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model)
2865- i.quickModelAssign = 0
2866- } else {
2867- i.applyModelSelection(act.Provider, act.Model)
2868- }
2869- }
2870- return false
2871- }
2872- if i.llamaDialog.Active() {
2873- i.llamaDialog.HandleKey(k)
2874- i.invalidate()
2875- return false
2876- }
2877- if i.rescueDialog.Active() {
2878- if k.Kind == tui.KeyCtrlC {
2879- i.rescueDialog.Close()
2880- i.invalidate()
2881- return false
2882- }
2883- act := i.rescueDialog.HandleKey(k)
2884- if act.Select {
2885- i.applyRescueSelection(act.Provider, act.Model, act.Prompt)
2886- }
2887- i.invalidate()
2888- return false
2889- }
2890- i.mu.Lock()
2891- sessionDialogActive := i.sessionDialog.Active()
2892- i.mu.Unlock()
2893- if sessionDialogActive {
2894- if k.Kind == tui.KeyCtrlC {
2895- i.mu.Lock()
2896- i.sessionDialog.Close()
2897- i.sessionLoads = nil
2898- i.mu.Unlock()
2899- i.invalidate()
2900- return false
2901- }
2902- manualRenamePath := ""
2903- i.mu.Lock()
2904- 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 {
2905- manualRenamePath = i.sessionDialog.sessions[i.sessionDialog.cursor].Path
2906- }
2907- i.mu.Unlock()
2908- manualRenameCurrent := manualRenamePath != "" && i.cfg.CurrentSessionPath() == manualRenamePath
2909- if manualRenameCurrent {
2910- i.markSessionTitleSwitching()
2911- }
2912- i.mu.Lock()
2913- act := i.sessionDialog.HandleKey(k)
2914- if act.Select || act.Close {
2915- i.sessionLoads = nil
2916- }
2917- i.mu.Unlock()
2918- if act.Select {
2919- i.applySessionSelection(act.Path)
2920- }
2921- if act.Err != nil {
2922- if manualRenameCurrent {
2923- i.restoreFailedSessionTitle()
2924- }
2925- i.mu.Lock()
2926- i.statusErr = "rename: " + act.Err.Error()
2927- i.statusOK = ""
2928- i.mu.Unlock()
2929- } else if act.Renamed && act.Path != "" && i.cfg.CurrentSessionPath != nil && i.cfg.CurrentSessionPath() == act.Path {
2930- i.setManualSessionTitle(act.RenameTitle)
2931- }
2932- // Always request a redraw after handling a key here: when esc
2933- // closes the picker, the overlay-close detection in the render
2934- // pass needs to run so the tall dialog rows get repainted from
2935- // the chat (otherwise VS Code's retained scrollback leaves a
2936- // duplicate frame on screen).
2937- i.invalidate()
2938- return false
2939- }
2940- i.mu.Lock()
2941- residentDialog := i.residentSubagentsDialog
2942- residentDialogActive := residentDialog != nil && residentDialog.Active()
2943- residentSession := i.residentChildSession
2944- i.mu.Unlock()
2945- if residentDialogActive {
2946- if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
2947- i.mu.Lock()
2948- if i.residentSubagentsDialog == residentDialog {
2949- residentDialog.Close()
2950- }
2951- i.mu.Unlock()
2952- i.invalidate()
2953- return false
2954- }
2955- i.mu.Lock()
2956- childID := residentDialog.HandleKey(k)
2957- i.mu.Unlock()
2958- if childID != "" {
2959- i.openResidentChildSession(childID)
2960- }
2961- i.invalidate()
2962- return false
2963- }
2964- if residentSession != nil {
2965- switch k.Kind {
2966- case tui.KeyCtrlC, tui.KeyEsc:
2967- i.mu.Lock()
2968- if i.residentChildSession == residentSession {
2969- i.residentChildSession = nil
2970- }
2971- i.mu.Unlock()
2972- case tui.KeyPageUp:
2973- session := residentSession
2974- if session.BeginLoad() {
2975- go func() {
2976- err := session.LoadOlder(200)
2977- if session.FinishLoad(err) {
2978- i.reloadResidentChildSession(session)
2979- }
2980- i.invalidate()
2981- }()
2982- }
2983- case tui.KeyPageDown:
2984- residentSession.FollowTail()
2985- case tui.KeyUp:
2986- residentSession.Scroll(1)
2987- case tui.KeyDown:
2988- residentSession.Scroll(-1)
2989- default:
2990- session := residentSession
2991- if prompt, submit := session.HandleKey(k); submit {
2992- go func() {
2993- err := i.cfg.ResidentManager.Resume(ctx, session.childID, prompt)
2994- session.FinishSubmission(err)
2995- i.invalidate()
2996- }()
2997- }
2998- }
2999- i.invalidate()
3000- return false
3001- }
3002- if i.logoutDialog.Active() {
3003- if k.Kind == tui.KeyCtrlC {
3004- i.logoutDialog.Close()
3005- i.invalidate()
3006- return false
3007- }
3008- act := i.logoutDialog.HandleKey(k)
3009- if act.Select {
3010- i.doLogout(act.Target)
3011- }
3012- i.invalidate()
3013- return false
3014- }
3015- if i.telegramDialog.Active() {
3016- if k.Kind == tui.KeyCtrlC {
3017- i.telegramDialog.Close()
3018- i.invalidate()
3019- return false
3020- }
3021- act := i.telegramDialog.HandleKey(k)
3022- if act.Select {
3023- i.doTelegram(act.Action)
3024- }
3025- i.invalidate()
3026- return false
3027- }
3028- if i.settingsDialog.Active() {
3029- if k.Kind == tui.KeyCtrlC {
3030- i.settingsDialog.Close()
3031- i.invalidate()
3032- return false
3033- }
3034- act := i.settingsDialog.HandleKey(k)
3035- if act.ModelShortcutSlot > 0 {
3036- i.openQuickModelPicker(act.ModelShortcutSlot)
3037- }
3038- if act.Toggle {
3039- i.applySettingChange(act)
3040- }
3041- i.invalidate()
3042- return false
3043- }
3044- if i.sessionOpsDialog.Active() {
3045- if k.Kind == tui.KeyCtrlC {
3046- i.sessionOpsDialog.Close()
3047- i.invalidate()
3048- return false
3049- }
3050- act := i.sessionOpsDialog.HandleKey(k)
3051- if act.Select {
3052- i.doSessionOp(act.Action, "")
3053- }
3054- i.invalidate()
3055- return false
3056- }
3057- if i.sessionTreeDialog.Active() {
3058- if k.Kind == tui.KeyCtrlC {
3059- i.sessionTreeDialog.Close()
3060- i.invalidate()
3061- return false
3062- }
3063- act := i.sessionTreeDialog.HandleKey(k)
3064- if act.Select {
3065- if act.Target.Role != "" || act.Target.SourcePath != "" || act.Target.IsBoundary() || act.Target.UserDraft != "" {
3066- i.applySessionTreeTarget(act.Target, act.TurnNo)
3067- } else {
3068- // Keep package embedders that still return the scalar action
3069- // source-compatible while the dialog migrates to Target.
3070- i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
3071- }
3072- }
3073- i.invalidate()
3074- return false
3075- }
3076- if i.extPanel.Active() {
3077- if k.Kind == tui.KeyCtrlC || k.Kind == tui.KeyEsc {
3078- if i.cfg.Extensions != nil {
3079- _ = i.cfg.Extensions.SendPanelClose(i.extPanel.ext, i.extPanel.id)
3080- }
3081- i.extPanel.Close()
3082- i.invalidate()
3083- return false
3084- }
3085- if i.cfg.Extensions != nil {
3086- _ = i.cfg.Extensions.SendPanelKey(i.extPanel.ext, i.extPanel.id, panelKeyName(k), panelKeyText(k))
3087- }
3088- return false
3089- }
3090- if i.jumpDialog.Active() {
3091- if k.Kind == tui.KeyCtrlC {
3092- i.jumpDialog.Close()
3093- i.pendingFork = false
3094- return false
3095- }
3096- act := i.jumpDialog.HandleKey(k)
3097- if act.Select {
3098- if i.pendingFork {
3099- i.applyForkSelection(act.MessageIdx)
3100- } else {
3101- i.applyJumpSelection(act.MessageIdx, act.TurnNo)
3102- }
3103- }
3104- // If the user dismissed the dialog without selecting, also
3105- // clear the pending-fork flag so a later plain /jump isn't
3106- // hijacked.
3107- if act.Close {
3108- i.pendingFork = false
3109- }
3110- return false
3111- }
3112- if i.btwDialog.Active() {
3113- if k.Kind == tui.KeyCtrlC {
3114- i.btwDialog.Close()
3115- i.invalidate()
3116- return false
3117- }
3118- if k.Kind == tui.KeyCtrlO {
3119- i.toggleBtwToolExpansion()
3120- return false
3121- }
3122- i.btwDialog.HandleKey(k, i.invalidate)
3123- return false
3124- }
3125- if i.skillsDialog.Active() {
3126- if k.Kind == tui.KeyCtrlC {
3127- i.skillsDialog.Close()
3128- i.invalidate()
3129- return false
3130- }
3131- i.skillsDialog.HandleKey(k)
3132- i.invalidate()
3133- return false
3134- }
3135- if i.changelogDialog.Active() {
3136- if closed := i.changelogDialog.HandleKey(k); closed {
3137- // User dismissed; let the parent persist the
3138- // LastChangelogShown marker via the close callback.
3139- if i.cfg.OnChangelogDismiss != nil {
3140- i.cfg.OnChangelogDismiss()
3141- }
3142- }
3143- i.invalidate()
3144- return false
3145- }
3146-
3147- if slot := quickModelShortcutSlot(k); slot > 0 {
3148- i.applyQuickModelShortcut(slot)
3149- return false
3150- }
3151-
3152- // The first eligible bare Escape is deliberately left to the existing
3153- // Escape path (which clears idle input or cancels a turn). A second tap
3154- // within the gesture window only gets first refusal while the cheap,
3155- // local eligibility rules still hold. If a turn started, a message was
3156- // queued, or the input became ineligible between taps, reset the detector
3157- // and fall through so Escape retains its normal cancellation behavior.
3158- // Once those rules still hold, the full tree gate owns the second tap:
3159- // family/read failures are fail-closed but are nevertheless consumed so
3160- // they cannot fall through into ordinary editor handling.
3161- if isUnmodifiedEscape(k) {
3162- now := i.sessionTreeEscapeNow()
3163- i.mu.Lock()
3164- consumed := i.doubleEscape.Consume(now)
3165- i.mu.Unlock()
3166- if consumed {
3167- if i.canArmSessionTreeEscape() {
3168- i.openSessionTree()
3169- return false
3170- }
3171- i.mu.Lock()
3172- i.doubleEscape.Reset()
3173- i.mu.Unlock()
3174- } else if i.canArmSessionTreeEscape() {
3175- i.mu.Lock()
3176- i.doubleEscape.Arm(now)
3177- i.mu.Unlock()
3178- }
3179- }
3180-
3181- // Global keys.
3182- switch k.Kind {
3183- case tui.KeyCtrlC:
3184- i.mu.Lock()
3185- loadingSession := i.sessionLoading
3186- i.mu.Unlock()
3187- if loadingSession {
3188- i.markCtrlCExit()
3189- return true
3190- }
3191- // While busy: do NOT cancel the turn. ctrl+c during a
3192- // running turn is almost always reflex muscle memory
3193- // ("be quiet" in a shell) rather than a deliberate
3194- // decision to kill a multi-minute model call that's
3195- // already cost tokens. Use esc to interrupt a turn; use
3196- // a deliberate double-ctrl+c to exit zut entirely. First
3197- // press arms the exit hint, second press within
3198- // ctrlCExitWindow quits.
3199- if i.busy {
3200- if i.ctrlCExitArmed() {
3201- i.markCtrlCExit()
3202- return true
3203- }
3204- i.mu.Lock()
3205- i.statusOK = "press ctrl+c again to exit, esc to cancel the turn"
3206- i.statusErr = ""
3207- i.mu.Unlock()
3208- i.armCtrlCExit()
3209- return false
3210- }
3211- // Idle: first press clears the editor (and any queued
3212- // follow-up messages); a second press within ctrlCExitWindow
3213- // exits. With both an empty editor and no queue the first
3214- // press still just arms — require a deliberate double-tap.
3215- ag := i.agent
3216- pending := 0
3217- if ag != nil {
3218- pending = ag.QueuedMessageCount()
3219- }
3220- hadInput := !i.ed.IsEmpty() || len(i.queued) > 0 || pending > 0
3221- if hadInput {
3222- i.ed.Clear()
3223- i.clipboardImages = nil
3224- i.suggest.Reset()
3225- if ag != nil {
3226- ag.DrainQueuedMessages()
3227- }
3228- i.mu.Lock()
3229- i.queued = nil
3230- i.statusOK = "input cleared"
3231- i.statusErr = ""
3232- i.mu.Unlock()
3233- i.armCtrlCExit()
3234- return false
3235- }
3236- if i.ctrlCExitArmed() {
3237- i.markCtrlCExit()
3238- return true
3239- }
3240- i.mu.Lock()
3241- i.statusOK = "press ctrl+c again to exit"
3242- i.statusErr = ""
3243- i.mu.Unlock()
3244- i.armCtrlCExit()
3245- return false
3246- case tui.KeyEsc:
3247- // Esc interrupts a running turn — but only when nothing
3248- // else on screen wants to consume the key first. The slash
3249- // popup has its own Esc behaviour (close + clear editor),
3250- // and transient overlays like the /help block and extension
3251- // notes should dismiss on Esc before we even consider the
3252- // turn. Without these guards, a casual Esc press after
3253- // running /help on a busy turn rips the turn away.
3254- if i.suggest.Active(i.ed.Value()) || i.fileSuggest.Active(i.ed.Value()) {
3255- break
3256- }
3257- i.mu.Lock()
3258- hadHelp := len(i.helpBlock) > 0
3259- hadNotes := len(i.extNotes) > 0
3260- if hadHelp {
3261- i.helpBlock = nil
3262- }
3263- if hadNotes {
3264- i.extNotes = nil
3265- }
3266- i.mu.Unlock()
3267- if hadHelp || hadNotes {
3268- i.invalidate()
3269- return false
3270- }
3271- i.mu.Lock()
3272- busyCancel := i.busy && i.cancelTurn != nil
3273- cancelTurn := i.cancelTurn
3274- var restoreQueued string
3275- var handoff json.RawMessage
3276- var persistHandoff bool
3277- if busyCancel {
3278- // Keep the most recently queued follow-up as an editable draft
3279- // instead of losing it with the cancelled turn's stale queue.
3280- if i.agent != nil {
3281- restoreQueued, _ = i.agent.PopQueuedMessage()
3282- }
3283- if restoreQueued == "" && len(i.queued) > 0 {
3284- n := len(i.queued) - 1
3285- restoreQueued = i.queued[n]
3286- i.queued = i.queued[:n]
3287- }
3288- handoff, persistHandoff = i.resetCompactContinuationLocked()
3289- }
3290- i.mu.Unlock()
3291- if busyCancel {
3292- if restoreQueued != "" {
3293- i.ed.SetValue(restoreQueued)
3294- i.inputHistoryIndex = -1
3295- }
3296- i.updateActiveGoal(core.GoalPaused, "interrupted by user")
3297- cancelTurn()
3298- // If a confirm dialog is pending, refuse it so the agent
3299- // goroutine unblocks and the context cancellation can
3300- // actually take effect.
3301- i.confirmDialog.CancelAll("turn cancelled")
3302- if persistHandoff {
3303- i.persistCompactHandoff(handoff)
3304- }
3305- return false
3306- }
3307- case tui.KeyCtrlD:
3308- if i.ed.IsEmpty() && !i.busy {
3309- return true
3310- }
3311- case tui.KeyCtrlB:
3312- i.mu.Lock()
3313- i.rightBarHidden = !i.rightBarHidden
3314- i.mu.Unlock()
3315- i.requestRendererInvalidate()
3316- i.invalidate()
3317- return false
3318- case tui.KeyCtrlL:
3319- i.requestRendererClear()
3320- i.invalidate()
3321- return false
3322- case tui.KeyPasteClipboard:
3323- i.pasteClipboard(ctx)
3324- return false
3325- case tui.KeyCtrlO:
3326- i.toggleToolExpansion()
3327- return false
3328- case tui.KeyPageUp:
3329- i.scrollBy(+i.chatPage())
3330- return false
3331- case tui.KeyPageDown:
3332- i.scrollBy(-i.chatPage())
3333- return false
3334- case tui.KeyUp:
3335- // Alt/Option+Up: pop the most recently queued ("sliding in")
3336- // message back into the editor so the user can edit and
3337- // resend it. Repeated presses keep peeling messages off the
3338- // tail of the queue; each press *replaces* the editor
3339- // contents (we don't append/push). When the queue is empty
3340- // the keypress falls through to the normal scroll behavior.
3341- if k.Alt {
3342- i.mu.Lock()
3343- var text string
3344- if i.agent != nil {
3345- text, _ = i.agent.PopQueuedMessage()
3346- }
3347- if text == "" {
3348- if n := len(i.queued); n > 0 {
3349- text = i.queued[n-1]
3350- i.queued = i.queued[:n-1]
3351- }
3352- }
3353- i.mu.Unlock()
3354- if text != "" {
3355- i.ed.SetValue(text)
3356- i.inputHistoryIndex = -1
3357- i.invalidate()
3358- return false
3359- }
3360- }
3361- // In multi-line / wrapped input, Up first moves inside the editor.
3362- // At the editor's top edge, plain Up can browse input history when
3363- // history browsing is safe/active; otherwise it falls back to chat
3364- // scrolling, preserving the old single-line scroll behavior.
3365- if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3366- if i.ed.MoveVertical(-1) {
3367- i.invalidate()
3368- return false
3369- }
3370- if !k.Alt && i.handleInputHistoryKey(k) {
3371- return false
3372- }
3373- i.scrollBy(+3)
3374- return false
3375- }
3376- case tui.KeyDown:
3377- if !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3378- if i.ed.MoveVertical(+1) {
3379- i.invalidate()
3380- return false
3381- }
3382- if !k.Alt && i.inputHistoryIndex < 0 && i.cfg.ResidentManager != nil {
3383- if _, total := i.cfg.ResidentManager.RecentSnapshotPage(0, 1); total > 0 {
3384- i.runResidentSubagents(ctx, nil)
3385- return false
3386- }
3387- }
3388- if !k.Alt && i.handleInputHistoryKey(k) {
3389- return false
3390- }
3391- if i.scrollOffset > 0 {
3392- i.scrollBy(-3)
3393- }
3394- return false
3395- }
3396- }
3397-
3398- // Note: we intentionally do NOT gate the editor on i.busy here.
3399- // Typing while the agent is working is supported — submitted
3400- // messages are queued and delivered as follow-up turns when the
3401- // current turn ends. See the submit handler below.
3402-
3403- if k.Kind == tui.KeyEnter && (k.Alt || k.Shift) {
3404- i.ed.HandleKey(k)
3405- return false
3406- }
3407-
3408- // Slash suggestions: intercept up/down/tab/enter when the popup is visible.
3409- if i.suggest.Active(i.ed.Value()) {
3410- switch k.Kind {
3411- case tui.KeyUp:
3412- i.suggest.Up()
3413- return false
3414- case tui.KeyDown:
3415- i.suggest.Down()
3416- return false
3417- case tui.KeyPageUp:
3418- i.suggest.PageUp()
3419- return false
3420- case tui.KeyPageDown:
3421- i.suggest.PageDown()
3422- return false
3423- case tui.KeyTab:
3424- if name := i.suggest.Selection(i.ed.Value()); name != "" {
3425- i.ed.SetValue(name)
3426- i.suggest.Reset()
3427- }
3428- return false
3429- case tui.KeyEnter:
3430- // Enter on an ambiguous or partial slash prefix: complete to the
3431- // currently highlighted command and run it. That way typing
3432- // "/lo" + enter picks whichever of /login or /logout is selected
3433- // in the popup instead of submitting "/lo" as unknown. Also
3434- // clear the editor so the command doesn't linger after the
3435- // dialog opens/closes.
3436- if name := i.suggest.Selection(i.ed.Value()); name != "" {
3437- i.ed.Clear()
3438- i.suggest.Reset()
3439- return i.runSlash(ctx, name)
3440- }
3441- case tui.KeyEsc:
3442- i.ed.Clear()
3443- i.suggest.Reset()
3444- return false
3445- }
3446- }
3447-
3448- // File suggestions: intercept up/down/tab/enter when the @-popup is visible.
3449- if i.fileSuggest.Active(i.ed.Value()) {
3450- switch k.Kind {
3451- case tui.KeyUp:
3452- i.fileSuggest.Up()
3453- return false
3454- case tui.KeyDown:
3455- i.fileSuggest.Down()
3456- return false
3457- case tui.KeyRight:
3458- // Open selected directory. The filter the user typed picked
3459- // that directory at the current level; once we descend it no
3460- // longer applies to the directory's contents, so clear it.
3461- // Otherwise typing "@eda" then right would re-filter inside
3462- // eda/ by "eda" and show nothing.
3463- if i.fileSuggest.Right() {
3464- i.clearFileSuggestQuery()
3465- }
3466- return false
3467- case tui.KeyLeft:
3468- // Go back to parent directory. Clear the filter for the same
3469- // reason as Right: it was scoped to the level we just left.
3470- if i.fileSuggest.Left() {
3471- i.clearFileSuggestQuery()
3472- }
3473- return false
3474- case tui.KeyEnter:
3475- if entry, ok := i.fileSuggest.SelectedEntry(i.ed.Value()); ok {
3476- var chip string
3477- if entry.isDir {
3478- chip = "[dir:" + entry.rel + "/]"
3479- } else {
3480- chip = "[file:" + entry.rel + "]"
3481- }
3482- val := i.ed.Value()
3483- if idx := strings.LastIndex(val, "@"); idx >= 0 {
3484- val = val[:idx]
3485- }
3486- i.ed.SetValue(val + chip + " ")
3487- i.fileSuggest.Reset()
3488- }
3489- return false
3490- case tui.KeyEsc:
3491- val := i.ed.Value()
3492- if idx := strings.LastIndex(val, "@"); idx >= 0 {
3493- i.ed.SetValue(val[:idx])
3494- }
3495- i.fileSuggest.Reset()
3496- return false
3497- }
3498- }
3499-
3500- // Tab-complete a path token in the editor when no popup is open.
3501- // Recognises tokens that look like paths (start with ~, /, ./, ../
3502- // or contain a slash); shell-style completion expands ~, lists the
3503- // parent dir, and completes the basename to the longest common
3504- // prefix. Single match: full replace and trailing / for dirs.
3505- // No match: no-op. Plain bare words (no slash, no tilde) fall
3506- // through so Tab keeps its current no-op behaviour outside paths.
3507- if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) {
3508- if i.tryPathTabComplete() {
3509- return false
3510- }
3511- }
3512-
3513- if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
3514- i.inputHistoryIndex = -1
3515- }
3516-
3517- if k.Kind == tui.KeyEsc {
3518- i.clipboardImages = nil
3519- }
3520- if submit := i.ed.HandleKey(k); submit {
3521- // SubmitValue() expands any [pasted text #N +L lines]
3522- // placeholders back into their bodies; the raw Value()
3523- // is only what the user sees on screen.
3524- text := strings.TrimRight(i.ed.SubmitValue(), "\n")
3525- // Expand [file:name] and [dir:name/] chips to full paths.
3526- text = expandFileChips(text, i.cfg.CWD)
3527- text, images := preparePromptWithClipboardImages(text, i.clipboardImages)
3528- if text == "" && len(images) == 0 {
3529- return false
3530- }
3531- clearSubmittedInput := func() {
3532- i.clipboardImages = nil
3533- i.ed.Clear()
3534- i.inputHistoryIndex = -1
3535- i.suggest.Reset()
3536- i.fileSuggest.Reset()
3537- }
3538-
3539- // Shell escapes and slash commands are text-only. An image-bearing
3540- // draft remains a normal provider prompt instead of silently dropping
3541- // its attachments.
3542- if len(images) == 0 {
3543- if cmd, ok := shellEscapeCommand(text); ok {
3544- clearSubmittedInput()
3545- i.startShellEscape(ctx, cmd)
3546- return false
3547- }
3548- }
3549-
3550- if len(images) == 0 && looksLikeSlashCommand(text) {
3551- clearSubmittedInput()
3552- head := text
3553- rest := ""
3554- if idx := strings.IndexAny(text, " \t"); idx >= 0 {
3555- head = text[:idx]
3556- rest = strings.TrimSpace(text[idx:])
3557- }
3558- if !isKnownSlashCommand(text) {
3559- // Try extensions before giving up. Extensions register
3560- // commands by bare name (no leading slash); strip it here.
3561- extName := strings.TrimPrefix(head, "/")
3562- if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
3563- go i.invokeExtensionCommand(ctx, extName, rest)
3564- return false
3565- }
3566- i.mu.Lock()
3567- i.statusErr = "unknown command " + head + " — type /help to see the list"
3568- i.statusOK = ""
3569- i.mu.Unlock()
3570- return false
3571- }
3572- // Slash commands run regardless of busy state. Commands that
3573- // would mutate the transcript or replace the agent (/clear,
3574- // /compact, /logout, /login, /model, /fork, /session fork)
3575- // cancel the active turn
3576- // first and wait for the goroutine to wind down so they don't
3577- // race with a streaming response. Safe commands (/help,
3578- // /jump, /sessions, /jail, /unjail, /exit) run immediately
3579- // without disturbing the active turn.
3580- if slashCommandCancelsTurn(text) {
3581- i.cancelAndWaitForIdle()
3582- }
3583- return i.runSlash(ctx, text)
3584- }
3585-
3586- if i.agent == nil {
3587- i.mu.Lock()
3588- i.statusErr = "not logged in. type /login first."
3589- i.mu.Unlock()
3590- return false
3591- }
3592- // Mirror the user's typed prompt into the paired Telegram
3593- // chat (when the bridge is active) so the Telegram thread
3594- // stays a complete record of the session, not just the half
3595- // that originated on the phone. On a goroutine so the
3596- // network write doesn't delay the local turn.
3597- if i.telegramBridge != nil && i.telegramBridge.Active() {
3598- go i.telegramBridge.OnUserTyped(text)
3599- }
3600- i.maybeStartSessionTitle(ctx, text)
3601- // If a turn is already in flight, queue this prompt inside the
3602- // agent loop so it is delivered at the next safe model-call
3603- // boundary instead of waiting for the whole run to finish.
3604- i.mu.Lock()
3605- busy := i.busy
3606- compacting := i.compacting
3607- ag := i.agent
3608- i.mu.Unlock()
3609- if busy {
3610- if len(images) > 0 {
3611- i.mu.Lock()
3612- i.statusErr = "can't queue clipboard images while a turn is running; wait for the current turn to finish"
3613- i.mu.Unlock()
3614- i.invalidate()
3615- return false
3616- }
3617- clearSubmittedInput()
3618- var handoff json.RawMessage
3619- var persistHandoff bool
3620- if ag != nil && !compacting {
3621- i.mu.Lock()
3622- handoff, persistHandoff = i.resetCompactContinuationLocked()
3623- ag.QueueMessage(text)
3624- i.mu.Unlock()
3625- } else {
3626- i.mu.Lock()
3627- handoff, persistHandoff = i.resetCompactContinuationLocked()
3628- i.queued = append(i.queued, text)
3629- i.mu.Unlock()
3630- }
3631- if persistHandoff {
3632- i.persistCompactHandoff(handoff)
3633- }
3634- i.invalidate()
3635- return false
3636- }
3637- clearSubmittedInput()
3638- i.startTurnWithImages(ctx, text, images)
3639- }
3640- return false
3641-}
3642-
3643-func (i *Interactive) pasteClipboard(ctx context.Context) {
3644- image, ok, _ := tui.ReadClipboardImage(ctx)
3645- if ok {
3646- i.mu.Lock()
3647- marker := fmt.Sprintf("[clipboard image #%d]", len(i.clipboardImages)+1)
3648- i.clipboardImages = append(i.clipboardImages, clipboardImageAttachment{
3649- Marker: marker,
3650- Image: provider.ImageBlock{MimeType: image.MimeType, Data: image.Data},
3651- })
3652- i.ed.Insert(marker + " ")
3653- i.statusOK = ""
3654- i.statusErr = ""
3655- i.mu.Unlock()
3656- return
3657- }
3658- key, pastedText, err := resolveClipboardText(tui.Key{Kind: tui.KeyPasteClipboard}, tui.ReadClipboardText)
3659- i.mu.Lock()
3660- defer i.mu.Unlock()
3661- if err != nil {
3662- i.statusErr = "clipboard paste failed: " + err.Error()
3663- i.statusOK = ""
3664- return
3665- }
3666- if pastedText {
3667- i.ed.HandleKey(key)
3668- i.statusErr = ""
3669- i.statusOK = ""
3670- return
3671- }
3672- i.statusErr = "clipboard does not contain text or an image"
3673- i.statusOK = ""
3674-}
3675-
3676-func (i *Interactive) handleInputHistoryKey(k tui.Key) bool {
3677- if k.Kind != tui.KeyUp && k.Kind != tui.KeyDown {
3678- return false
3679- }
3680- // Do not steal normal vertical cursor movement. History browsing can only
3681- // start from an empty editor; once active, Up/Down keep walking
3682- // the ring so repeated presses work even though the editor now
3683- // contains the selected historical prompt.
3684- if i.inputHistoryIndex < 0 && !i.ed.IsEmpty() {
3685- return false
3686- }
3687- hist := i.inputHistory()
3688- if len(hist) == 0 {
3689- return false
3690- }
3691-
3692- if i.inputHistoryIndex < 0 {
3693- // Start just after the newest item so Up lands on the most
3694- // recent user prompt. A lone Down from an empty editor is not
3695- // history navigation; let the caller fall through to normal UI
3696- // behavior instead.
3697- if k.Kind != tui.KeyUp {
3698- return false
3699- }
3700- i.inputHistoryIndex = len(hist)
3701- }
3702-
3703- switch k.Kind {
3704- case tui.KeyUp:
3705- if i.inputHistoryIndex > 0 {
3706- i.inputHistoryIndex--
3707- }
3708- case tui.KeyDown:
3709- if i.inputHistoryIndex < len(hist) {
3710- i.inputHistoryIndex++
3711- }
3712- }
3713-
3714- if i.inputHistoryIndex >= len(hist) {
3715- i.ed.Clear()
3716- } else {
3717- i.ed.SetValue(hist[i.inputHistoryIndex])
3718- }
3719- return true
3720-}
3721-
3722-func (i *Interactive) inputHistory() []string {
3723- if i.agent == nil {
3724- return nil
3725- }
3726- msgs := i.agent.Messages()
3727- hist := make([]string, 0, len(msgs))
3728- for _, m := range msgs {
3729- if !isForkableUserMessage(m) {
3730- continue
3731- }
3732- text := userMessageText(m)
3733- if strings.TrimSpace(text) == "" {
3734- continue
3735- }
3736- hist = append(hist, text)
3737- }
3738- return hist
3739-}
3740-
3741-func userMessageText(m provider.Message) string {
3742- var sb strings.Builder
3743- for _, c := range m.Content {
3744- if tb, ok := c.(provider.TextBlock); ok {
3745- if sb.Len() > 0 {
3746- sb.WriteByte('\n')
3747- }
3748- sb.WriteString(tb.Text)
3749- }
3750- }
3751- return sb.String()
3752-}
3753-
3754-// invokeExtensionCommand fires an extension-registered slash command
3755-// in a background goroutine, awaits the response, and applies the
3756-// requested action (prompt / insert / display / noop). Errors and
3757-// timeouts surface as a status_err line.
3758-
3759-func (i *Interactive) invokeExtensionCommand(ctx context.Context, name, args string) {
3760- resp, err := i.cfg.Extensions.Invoke(ctx, name, args, 30*time.Second)
3761- if err != nil {
3762- i.mu.Lock()
3763- i.statusErr = "extension /" + name + ": " + err.Error()
3764- i.statusOK = ""
3765- i.mu.Unlock()
3766- i.invalidate()
3767- return
3768- }
3769- if resp.Error != "" {
3770- i.mu.Lock()
3771- i.statusErr = "extension /" + name + ": " + resp.Error
3772- i.statusOK = ""
3773- i.mu.Unlock()
3774- i.invalidate()
3775- return
3776- }
3777- switch resp.Action {
3778- case "open_panel":
3779- if resp.OpenPanel != nil {
3780- extName := name
3781- if i.cfg.Extensions != nil {
3782- if owner := i.cfg.Extensions.CommandOwner(name); owner != "" {
3783- extName = owner
3784- }
3785- }
3786- i.OpenPanel(extName, *resp.OpenPanel)
3787- }
3788- case "prompt":
3789- if strings.TrimSpace(resp.Prompt) == "" {
3790- return
3791- }
3792- i.startTurn(i.runCtx, resp.Prompt)
3793- case "insert":
3794- i.ed.Insert(resp.Insert)
3795- i.invalidate()
3796- case "display":
3797- i.appendExtensionNote(name, resp.Display, "info")
3798- case "noop", "":
3799- // nothing
3800- default:
3801- i.mu.Lock()
3802- i.statusErr = "extension /" + name + ": unknown action " + resp.Action
3803- i.mu.Unlock()
3804- i.invalidate()
3805- }
3806-}
3807-
3808-// appendExtensionNote renders an extension-originated note in the
3809-// chat. Levels: "info" (muted), "warn" (warning), "error" (error),
3810-// "success" (tool/ok green).
3811-func (i *Interactive) appendExtensionNote(extName, msg, level string) {
3812- if msg == "" {
3813- return
3814- }
3815- i.mu.Lock()
3816- defer i.mu.Unlock()
3817- color := i.cfg.Theme.Muted
3818- switch level {
3819- case "warn":
3820- color = i.cfg.Theme.Warning
3821- case "error":
3822- color = i.cfg.Theme.Error
3823- case "success":
3824- color = i.cfg.Theme.Tool
3825- }
3826- prefix := i.cfg.Theme.FGColor(i.cfg.Theme.Accent, "["+extName+"] ")
3827- for _, line := range strings.Split(msg, "\n") {
3828- i.statusOK = "" // clear any stale ok
3829- i.statusErr = ""
3830- i.extNotes = append(i.extNotes, prefix+i.cfg.Theme.FGColor(color, line))
3831- }
3832- // Extension slash commands complete asynchronously. If their result is
3833- // displayed while confirmation is waiting, keep Esc assigned to the note
3834- // until it is dismissed rather than letting it answer the tool call.
3835- i.confirmDialog.Blur()
3836-}
3837-
3838-// HostHooks implementation for the extension manager. The manager
3839-// holds an interface, not a concrete *Interactive, so these methods
3840-// are the only thing the manager sees.
3841-
3842-// ApplySessionAgent swaps the live agent after a validated session resume.
3843-// Unlike ApplyChangedCWD it does not mutate cwd-scoped resources or startup
3844-// context; the session loader is changing the transcript/provider only.
3845-func (i *Interactive) ApplySessionAgent(ag *core.Agent, providerName, model string) {
3846- i.ApplySessionAgentWithCompactHandoff(ag, providerName, model, nil)
3847-}
3848-
3849-func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, providerName, model string, compactHandoff json.RawMessage) {
3850- if ag == nil {
3851- return
3852- }
3853- i.agentMu.Lock()
3854- defer i.agentMu.Unlock()
3855- i.mu.Lock()
3856- i.prepareReplacementAgentLocked(ag)
3857- i.compactContinuation = decodeCompactHandoff(compactHandoff)
3858- i.agent = ag
3859- i.cfg.Provider = providerName
3860- i.cfg.Model = model
3861- i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
3862- i.cumUsage = ag.Cost()
3863- last := ag.LastTurnUsage()
3864- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
3865- if len(i.view.Messages) > initialResumeTailLimit {
3866- i.view.TailLimit = initialResumeTailLimit
3867- } else {
3868- i.view.TailLimit = 0
3869- }
3870- i.view.InvalidateRenderCache()
3871- i.toolCalls = map[string]*tui.ToolCallView{}
3872- i.toolOrder = nil
3873- i.toolGate = map[string]int{}
3874- i.helpBlock = nil
3875- i.extNotes = nil
3876- i.parkedTurn = 0
3877- i.parkedTotal = 0
3878- i.mu.Unlock()
3879- i.invalidate()
3880-}
3881-
3882-// SetSubagentSessionScope redraws after a host session transition. Resident
3883-// children retain their explicit parent session in their durable journal.
3884-func (i *Interactive) SetSubagentSessionScope(_ string) {
3885- i.invalidate()
3886-}
3887-
3888-// ApplyChangedCWD is called by hosts after a successful /cd hook that do
3889-// not provide startup context metadata.
3890-func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
3891- i.applyChangedCWD(ag, provider, model, cwd, nil)
3892-}
3893-
3894-// ApplyChangedCWDWithStartupContext swaps a rebuilt agent and its display-only
3895-// startup context into the running TUI after a successful /cd hook.
3896-func (i *Interactive) ApplyChangedCWDWithStartupContext(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3897- i.applyChangedCWD(ag, provider, model, cwd, startupContextPaths)
3898-}
3899-
3900-func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd string, startupContextPaths []string) {
3901- home, _ := os.UserHomeDir()
3902- profiles, _ := subagents.Discover(cwd, home)
3903- subagentsAddendum := subagents.SystemPromptAddendum(profiles)
3904-
3905- i.agentMu.Lock()
3906- defer i.agentMu.Unlock()
3907- i.mu.Lock()
3908- i.prepareReplacementAgentLocked(ag)
3909- i.resetCompactContinuationLocked()
3910- i.agent = ag
3911- i.cfg.CWD = cwd
3912- i.cfg.SubagentsSystemAddendum = subagentsAddendum
3913- i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
3914- i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
3915- i.view.StartupContextPaths = nil
3916- i.view.InvalidateRenderCache()
3917- if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup {
3918- i.view.StartupContextPaths = append(i.view.StartupContextPaths, startupContextPaths...)
3919- }
3920- // Re-report the working directory to the terminal so "new tab here"
3921- // tracks the /cd change (OSC 7, see issue #38).
3922- if i.cfg.Terminal != nil {
3923- if seq := tui.ReportCWD(cwd); seq != "" {
3924- _, _ = i.cfg.Terminal.Write([]byte(seq))
3925- }
3926- }
3927- i.cfg.Provider = provider
3928- i.cfg.Model = model
3929- titleCancel := i.titleCancel
3930- i.titleCancel = nil
3931- i.titleVersion++
3932- i.sessionTitle = ""
3933- i.titleRealPromptSeen = false
3934- i.titleGenerationStarted = false
3935- i.writeTerminalTitleLocked("")
3936- i.toolCalls = map[string]*tui.ToolCallView{}
3937- i.toolOrder = nil
3938- i.toolGate = map[string]int{}
3939- i.helpBlock = nil
3940- i.parkedTurn = 0
3941- i.statusErr = ""
3942- i.mu.Unlock()
3943- if titleCancel != nil {
3944- titleCancel()
3945- }
3946- i.fileSuggest.Reset()
3947- i.fileSuggest.SetCWD(cwd)
3948- i.invalidate()
3949-}
3950-
3951-// SubmitSlash runs text as a slash command in the TUI as if the user
3952-// had typed it. text must start with '/' — callers that hand it
3953-// plain prose silently get a no-op so a misbehaving extension can't
3954-// run a stray prompt through this path. Read-only commands run in
3955-// place; commands that would mutate the transcript or replace the
3956-// agent cancel the active turn first via the same path the editor
3957-// uses for typed slash commands.
3958-func (i *Interactive) SubmitSlash(text string) {
3959- i.mu.Lock()
3960- i.doubleEscape.Reset()
3961- i.mu.Unlock()
3962- text = strings.TrimSpace(text)
3963- if !strings.HasPrefix(text, "/") {
3964- return
3965- }
3966- if slashCommandCancelsTurn(text) {
3967- i.cancelAndWaitForIdle()
3968- }
3969- i.runSlash(i.runCtx, text)
3970- i.invalidate()
3971-}
3972-
3973-// SubmitOrQueue runs text immediately if the agent is idle, or
3974-// appends it to the pending queue if a turn is already in flight.
3975-// Used by the telegram bridge (and by the editor submit path) so
3976-// both input sources share the same "queue behind an active turn"
3977-// semantics. Images are ignored for now — only the text prompt is
3978-// forwarded — because the queued-prompt path is text-only; a
3979-// follow-up can expand the queue entry to carry images.
3980-func (i *Interactive) SubmitOrQueue(text string, images []provider.ImageBlock) {
3981- i.submitOrQueue(text, images, true)
3982-}
3983-
3984-func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, userInput bool) {
3985- if cmd, ok := shellEscapeCommand(text); ok {
3986- i.startShellEscape(i.runCtx, cmd)
3987- return
3988- }
3989- i.mu.Lock()
3990- if i.agent == nil {
3991- i.statusErr = "not logged in. type /login first."
3992- i.mu.Unlock()
3993- i.invalidate()
3994- return
3995- }
3996- i.mu.Unlock()
3997- if userInput && i.cfg.EnsureMission != nil {
3998- if err := i.cfg.EnsureMission(text); err != nil {
3999- i.ReportError(fmt.Errorf("persist mission: %w", err))
4000- return
4001- }
4002- }
4003- // User input is an orchestration event, not merely a queue append. When
4004- // the manager is dormant behind a sealed worker wave, the coordinator makes
4005- // this the next manager turn and carries any completed worker summary with it.
4006- if userInput && i.coordinatorAcceptsUserInput() {
4007- if actions := i.applyCoordinator(orchestration.Event{Kind: orchestration.EventUserInput, Text: text}); len(actions) != 0 {
4008- i.executeCoordinatorActions(actions)
4009- return
4010- }
4011- }
4012- i.maybeStartSessionTitle(i.runCtx, text)
4013- i.mu.Lock()
4014- if i.busy {
4015- // Queue text only; images are dropped for queued prompts. Keep the
4016- // interactive mutex held through Agent.QueueMessage so turn teardown
4017- // cannot inspect the agent queue between the busy check and enqueue.
4018- // Compaction uses the host queue because it has no active agent loop
4019- // to drain Agent.QueueMessage entries.
4020- var handoff json.RawMessage
4021- var persistHandoff bool
4022- if i.agent != nil && !i.compacting {
4023- handoff, persistHandoff = i.resetCompactContinuationLocked()
4024- i.agent.QueueMessage(text)
4025- } else {
4026- handoff, persistHandoff = i.resetCompactContinuationLocked()
4027- i.queued = append(i.queued, text)
4028- }
4029- i.mu.Unlock()
4030- if persistHandoff {
4031- i.persistCompactHandoff(handoff)
4032- }
4033- i.invalidate()
4034- return
4035- }
4036- i.mu.Unlock()
4037- i.startTurnWithImages(i.runCtx, text, images)
4038-}
4039-
4040-// CancelTurn aborts the active turn if one is running. Used by the
4041-// telegram bridge when the paired user sends /stop.
4042-// ChangelogVersion returns the version string of the changelog
4043-// currently shown (or last shown). Used by the dismiss callback
4044-// to store the correct version for dev builds.
4045-func (i *Interactive) ChangelogVersion() string {
4046- if i.changelogDialog != nil {
4047- return i.changelogDialog.version
4048- }
4049- return ""
4050-}
4051-
4052-func (i *Interactive) CancelTurn() {
4053- i.mu.Lock()
4054- cancel := i.cancelTurn
4055- i.mu.Unlock()
4056- if cancel != nil {
4057- i.cancelCoordinator()
4058-
4059- i.mu.Lock()
4060- handoff, persistHandoff := i.resetCompactContinuationLocked()
4061- i.mu.Unlock()
4062- cancel()
4063- i.confirmDialog.CancelAll("turn cancelled")
4064- if persistHandoff {
4065- i.persistCompactHandoff(handoff)
4066- }
4067- }
4068-}
4069-
4070-// Insert places text at the cursor in the editor.
4071-func (i *Interactive) Insert(text string) {
4072- i.ed.Insert(text)
4073- i.invalidate()
4074-}
4075-
4076-// Display appends a styled note from extName to the chat without a
4077-// model call.
4078-func (i *Interactive) Display(extName, text string) {
4079- i.appendExtensionNote(extName, text, "info")
4080- i.invalidate()
4081-}
4082-
4083-// ReportError surfaces a host-side error in the interactive status area.
4084-func (i *Interactive) ReportError(err error) {
4085- if err == nil {
4086- return
4087- }
4088- i.mu.Lock()
4089- i.statusOK = ""
4090- i.statusErr = err.Error()
4091- i.mu.Unlock()
4092- i.invalidate()
4093-}
4094-
4095-// SetStatus replaces one persistent status item owned by an extension.
4096-func (i *Interactive) SetStatus(extName, key, level, text string) {
4097- if strings.TrimSpace(extName) == "" || strings.TrimSpace(key) == "" {
4098- return
4099- }
4100- i.mu.Lock()
4101- if i.extStatuses == nil {
4102- i.extStatuses = map[string]map[string]extensionStatus{}
4103- }
4104- if strings.TrimSpace(text) == "" {
4105- if items := i.extStatuses[extName]; items != nil {
4106- delete(items, key)
4107- if len(items) == 0 {
4108- delete(i.extStatuses, extName)
4109- }
4110- }
4111- } else {
4112- if i.extStatuses[extName] == nil {
4113- i.extStatuses[extName] = map[string]extensionStatus{}
4114- }
4115- i.extStatuses[extName][key] = extensionStatus{Level: level, Text: text}
4116- }
4117- i.mu.Unlock()
4118- i.invalidate()
4119-}
4120-
4121-// SetWidget replaces one persistent widget owned by an extension.
4122-func (i *Interactive) SetWidget(extName, id, position, title string, lines []string) {
4123- if strings.TrimSpace(extName) == "" || strings.TrimSpace(id) == "" {
4124- return
4125- }
4126- position = extproto.NormalizeWidgetPosition(position)
4127- i.mu.Lock()
4128- if i.extWidgets == nil {
4129- i.extWidgets = map[string]map[string]extensionWidget{}
4130- }
4131- if i.extWidgets[extName] == nil {
4132- i.extWidgets[extName] = map[string]extensionWidget{}
4133- }
4134- i.extWidgets[extName][id] = extensionWidget{
4135- Position: position,
4136- Title: title,
4137- Lines: append([]string(nil), lines...),
4138- }
4139- i.mu.Unlock()
4140- i.invalidate()
4141-}
4142-
4143-// ClearWidget removes one persistent widget owned by an extension.
4144-func (i *Interactive) ClearWidget(extName, id string) {
4145- i.mu.Lock()
4146- if items := i.extWidgets[extName]; items != nil {
4147- delete(items, id)
4148- if len(items) == 0 {
4149- delete(i.extWidgets, extName)
4150- }
4151- }
4152- i.mu.Unlock()
4153- i.invalidate()
4154-}
4155-
4156-// ClearExtensionChrome removes every persistent UI item owned by an
4157-// extension that has exited. This keeps crashes, disable/reload operations,
4158-// and startup failures from leaving stale widgets beside the transcript.
4159-func (i *Interactive) ClearExtensionChrome(extName string) {
4160- if strings.TrimSpace(extName) == "" {
4161- return
4162- }
4163- marker := "[" + extName + "] "
4164- i.mu.Lock()
4165- changed := false
4166- if _, ok := i.extStatuses[extName]; ok {
4167- delete(i.extStatuses, extName)
4168- changed = true
4169- }
4170- if _, ok := i.extWidgets[extName]; ok {
4171- delete(i.extWidgets, extName)
4172- changed = true
4173- }
4174- if len(i.extNotes) > 0 {
4175- kept := i.extNotes[:0:0]
4176- for _, line := range i.extNotes {
4177- if strings.Contains(line, marker) {
4178- changed = true
4179- continue
4180- }
4181- kept = append(kept, line)
4182- }
4183- i.extNotes = kept
4184- }
4185- if i.extPanel != nil && i.extPanel.Active() && i.extPanel.ext == extName {
4186- i.extPanel.Close()
4187- changed = true
4188- }
4189- i.mu.Unlock()
4190- if changed {
4191- i.invalidate()
4192- }
4193-}
4194-
4195-func (i *Interactive) OpenPanel(extName string, spec extproto.PanelSpec) {
4196- i.mu.Lock()
4197- defer i.mu.Unlock()
4198- i.extPanel.Open(extName, spec.ID, spec.Title, spec.Lines, spec.Footer)
4199- i.confirmDialog.Blur()
4200- if i.cfg.Extensions != nil {
4201- cols, rows := i.cfg.Terminal.Size()
4202- _ = cols
4203- _ = rows
4204- }
4205- i.invalidate()
4206-}
4207-
4208-func (i *Interactive) UpdatePanel(extName, panelID, title string, lines []string, footer string) {
4209- i.mu.Lock()
4210- defer i.mu.Unlock()
4211- if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
4212- i.extPanel.Update(title, lines, footer)
4213- i.invalidate()
4214- }
4215-}
4216-
4217-func (i *Interactive) ClosePanel(extName, panelID string) {
4218- i.mu.Lock()
4219- defer i.mu.Unlock()
4220- if i.extPanel.Active() && i.extPanel.ext == extName && i.extPanel.id == panelID {
4221- i.extPanel.Close()
4222- i.invalidate()
4223- }
4224-}
4225-
4226-func effectiveImageProtocol(override *bool) tui.ImageProtocol {
4227- detected := tui.DetectImageProtocol()
4228- if override == nil {
4229- return detected
4230- }
4231- if !*override {
4232- return tui.ImageProtocolNone
4233- }
4234- return detected
4235-}
4236-
4237-func imageProtocolName(p tui.ImageProtocol) string {
4238- switch p {
4239- case tui.ImageProtocolKitty:
4240- return "kitty/ghostty"
4241- case tui.ImageProtocolITerm2:
4242- return "iTerm2"
4243- default:
4244- return "none"
4245- }
4246-}
4247-
4248-func onOff(v bool) string {
4249- if v {
4250- return "enabled"
4251- }
4252- return "disabled"
4253-}
4254-
4255-const modalBackdropDimPercent = 50
4256-
4257-func (i *Interactive) applyInputCursorColor() {
4258- if i == nil || i.cfg.Terminal == nil {
4259- return
4260- }
4261- color := tui.Color256(15)
4262- if i.cursorDimmed {
4263- color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
4264- }
4265- _, _ = i.cfg.Terminal.Write([]byte(tui.CursorColor(color) + tui.CursorShapeBlock()))
4266-}
4267-
4268-// setInputCursorDimmed updates the terminal-owned cursor only when its layer
4269-// changes. Reapplying cursor controls on every redraw can reset its blink
4270-// timing before the terminal completes a normal blink cycle.
4271-func (i *Interactive) setInputCursorDimmed(dimmed bool) {
4272- if i == nil || i.cursorDimmed == dimmed {
4273- return
4274- }
4275- i.cursorDimmed = dimmed
4276- i.applyInputCursorColor()
4277-}
4278-
4279-func reasoningSettingOptions(levels []string) []settingsOption {
4280- descriptions := map[string]string{
4281- "": "no reasoning",
4282- "minimum": "very brief (~1k tokens)",
4283- "low": "light (~2k tokens)",
4284- "medium": "moderate (~8k tokens)",
4285- "high": "deep (~16k tokens)",
4286- "xhigh": "extra-high effort",
4287- "max": "unconstrained effort",
4288- }
4289- options := make([]settingsOption, 0, len(levels))
4290- for _, level := range levels {
4291- label := level
4292- if label == "" {
4293- label = "off"
4294- }
4295- options = append(options, settingsOption{value: level, label: label, desc: descriptions[level]})
4296- }
4297- return options
4298-}
4299-
4300-func (i *Interactive) reasoningSettingItem() settingsItem {
4301- levels := []string{"", "minimum", "low", "medium", "high", "xhigh", "max"}
4302- reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
4303- desc := "reasoning depth for reasoning-capable models"
4304- hint := ""
4305- if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
4306- levels = provider.AvailableReasoningLevels(m)
4307- reasoning = provider.ClampReasoningForModel(m, reasoning)
4308- if !m.Reasoning {
4309- hint = "current model does not support reasoning"
4310- desc += "; current model does not support reasoning"
4311- } else if len(levels) == 1 {
4312- hint = "current model has no adjustable reasoning levels"
4313- desc += "; current model has no adjustable reasoning levels"
4314- } else {
4315- desc += "; only levels supported by the current model are shown"
4316- }
4317- }
4318- options := reasoningSettingOptions(levels)
4319- choice := 0
4320- for idx, opt := range options {
4321- if opt.value == reasoning {
4322- choice = idx
4323- break
4324- }
4325- }
4326-
4327- return settingsItem{
4328- key: "reasoning",
4329- label: "reasoning level",
4330- desc: desc,
4331- options: options,
4332- choice: choice,
4333- hint: hint,
4334- }
4335-}
4336-
4337-func (i *Interactive) openReasoningDialog() {
4338- i.settingsDialog.OpenDirectOption(i.reasoningSettingItem())
4339-}
4340-
4341-func (i *Interactive) openSettingsDialog() {
4342- detected := tui.DetectImageProtocol()
4343- imgEnabled := detected != tui.ImageProtocolNone
4344- if i.cfg.InlineImagesEnabled != nil {
4345- imgEnabled = *i.cfg.InlineImagesEnabled
4346- }
4347- imgDisabled := detected == tui.ImageProtocolNone
4348- imgHint := ""
4349- if imgDisabled {
4350- imgEnabled = false
4351- imgHint = "this terminal does not support inline images"
4352- } else {
4353- imgHint = "terminal supports " + imageProtocolName(detected)
4354- }
4355-
4356- terminalAlerts := terminalAlertsEnabled(i.cfg.TerminalAlertsEnabled)
4357- terminalTitles := terminalTitleEnabled(i.cfg.TerminalTitleEnabled)
4358- autoSubagents := false
4359- if i.cfg.AutoSubagentsEnabled != nil {
4360- autoSubagents = *i.cfg.AutoSubagentsEnabled
4361- }
4362- autoSubagentsDisabled := !i.autoSubagentsAvailable()
4363- autoSubagentsHint := i.autoSubagentsUnavailableHint()
4364- if autoSubagentsDisabled {
4365- autoSubagents = false
4366- }
4367-
4368- ponytailEnabled := i.ponytailEnabled()
4369- ponytailDisabled := !i.ponytailSettingsAvailable()
4370- ponytailHint := ""
4371- if ponytailDisabled {
4372- ponytailHint = "requires persistent settings and live prompt refresh support"
4373- }
4374-
4375- webSearchEnabled := i.webSearchEffectiveEnabled()
4376- webSearchDisabled := !i.webSearchSettingsAvailable()
4377- webSearchHint := i.webSearchUnavailableHint()
4378-
4379- fastMode := i.cfg.FastMode != nil && *i.cfg.FastMode
4380- fastModeHint := "OpenAI service tier"
4381- if !provider.SupportsFastMode(i.cfg.Provider) {
4382- fastModeHint = "only supported for OpenAI providers"
4383- }
4384- lspEnabled := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
4385- subagentLSPEnabled := i.cfg.SubagentLSPEnabled == nil || *i.cfg.SubagentLSPEnabled
4386-
4387- jailByDefault := i.cfg.JailByDefault != nil && *i.cfg.JailByDefault
4388- recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
4389- respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
4390- compactMode := i.compactModeEnabled()
4391- showInstructions := i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup
4392- inputStyle := tui.NormalizeInputStyle(i.cfg.TUIInputStyle)
4393- statusPosition := tui.NormalizeStatusPosition(i.cfg.TUIStatusPosition)
4394- workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition)
4395- quickItems := i.quickModelSettingItems()
4396-
4397- autoCompactOptions := []settingsOption{
4398- {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"},
4399- {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"},
4400- {value: "80", label: "80%", desc: "compact with moderate context headroom"},
4401- {value: "85", label: "85%", desc: "default balance between history and headroom"},
4402- {value: "90", label: "90%", desc: "retain more history before compacting"},
4403- {value: "95", label: "95%", desc: "retain most history before compacting"},
4404- }
4405- autoCompactThreshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
4406- autoCompactChoice := 0
4407- for idx, opt := range autoCompactOptions {
4408- if opt.value == strconv.Itoa(autoCompactThreshold) {
4409- autoCompactChoice = idx
4410- break
4411- }
4412- }
4413-
4414- reasoningItem := i.reasoningSettingItem()
4415-
4416- themeName := i.cfg.ThemeName
4417- if themeName == "" {
4418- themeName = "auto"
4419- }
4420- if themeName != "auto" && !tui.ThemeExists(i.cfg.ZutHome, themeName) {
4421- themeName = "auto"
4422- i.cfg.ThemeName = ""
4423- if i.cfg.SettingsStore != nil {
4424- _ = i.cfg.SettingsStore.SetTheme("auto")
4425- }
4426- i.applyThemeNow("auto")
4427- }
4428- themeOptions := []settingsOption{}
4429- themeChoice := 0
4430- availableThemes := tui.AvailableThemes(i.cfg.ZutHome)
4431- if i.cfg.ExtensionThemes != nil {
4432- availableThemes = append(availableThemes, i.cfg.ExtensionThemes()...)
4433- }
4434- for idx, opt := range availableThemes {
4435- themeOptions = append(themeOptions, settingsOption{value: opt.Value, label: opt.Label, desc: opt.Description})
4436- if opt.Value == themeName {
4437- themeChoice = idx
4438- }
4439- }
4440-
4441- inputStyleOptions := []settingsOption{
4442- {value: tui.InputStylePlain, label: "plain", desc: "render the input as the normal prompt line"},
4443- {value: tui.InputStyleLines, label: "lines", desc: "draw separator lines above and below the input"},
4444- {value: tui.InputStyleBlock, label: "block", desc: "render the input as a user-bubble-style block"},
4445- }
4446- inputStyleChoice := 0
4447- for idx, opt := range inputStyleOptions {
4448- if opt.value == inputStyle {
4449- inputStyleChoice = idx
4450- break
4451- }
4452- }
4453- statusPositionOptions := []settingsOption{
4454- {value: tui.StatusPositionAboveInput, label: "above input", desc: "show model, usage, and working directory above the input"},
4455- {value: tui.StatusPositionBelowInput, label: "below input", desc: "show model, usage, and working directory below the input"},
4456- }
4457- statusPositionChoice := 0
4458- for idx, opt := range statusPositionOptions {
4459- if opt.value == statusPosition {
4460- statusPositionChoice = idx
4461- break
4462- }
4463- }
4464-
4465- workingPositionOptions := []settingsOption{
4466- {value: tui.WorkingPositionAboveInput, label: "above input", desc: "show the working spinner above the input"},
4467- {value: tui.WorkingPositionBelowInput, label: "below input", desc: "show the working spinner below the input"},
4468- }
4469- workingPositionChoice := 0
4470- for idx, opt := range workingPositionOptions {
4471- if opt.value == workingPosition {
4472- workingPositionChoice = idx
4473- break
4474- }
4475- }
4476-
4477- items := []settingsItem{
4478- {
4479- key: "inline_images_enabled",
4480- label: "render images when supported",
4481- desc: "draw screenshots inline instead of showing a text placeholder",
4482- value: imgEnabled,
4483- disabled: imgDisabled,
4484- hint: imgHint,
4485- },
4486- {
4487- key: "terminal_alerts_enabled",
4488- label: "terminal alerts",
4489- desc: "ring the terminal when the main session needs attention or an extension raises an alert",
4490- value: terminalAlerts,
4491- },
4492- {
4493- key: "terminal_title_enabled",
4494- label: "AI terminal titles",
4495- desc: "ask the active model for a concise title after the first prompt; uses one extra hidden request",
4496- value: terminalTitles,
4497- },
4498- {
4499- key: "auto_subagents_enabled",
4500- label: "Subagent Orchestrator",
4501- desc: "automatically delegate coding work to sub-agents; tools stay available when disabled",
4502- value: autoSubagents,
4503- disabled: autoSubagentsDisabled,
4504- hint: autoSubagentsHint,
4505- },
4506- {
4507- key: "ponytail_enabled",
4508- label: "ponytail coding mode",
4509- desc: "apply compact coding guidance that favors small, validated, maintainable changes",
4510- value: ponytailEnabled,
4511- disabled: ponytailDisabled,
4512- hint: ponytailHint,
4513- },
4514- {
4515- key: "web_search_enabled",
4516- label: "web search",
4517- desc: "search DuckDuckGo for bounded public-web sources; sends queries to the public backend",
4518- value: webSearchEnabled,
4519- disabled: webSearchDisabled,
4520- hint: webSearchHint,
4521- },
4522- {
4523- key: "fast_mode",
4524- label: "fast mode",
4525- desc: "request the provider's fast tier where supported; unsupported providers return an error",
4526- value: fastMode,
4527- hint: fastModeHint,
4528- },
4529- {
4530- key: "lsp_enabled",
4531- label: "lsp in main session",
4532- desc: "enable the lsp tool and code diagnostics for the main agent",
4533- value: lspEnabled,
4534- },
4535- {
4536- key: "subagent_lsp_enabled",
4537- label: "lsp in sub-agents",
4538- desc: "allow newly spawned background sub-agents to use the lsp tool",
4539- value: subagentLSPEnabled,
4540- },
4541- {
4542- key: "auto_compact_threshold",
4543- label: "auto-compact threshold",
4544- desc: "choose how full the model context can get before zut condenses conversation history",
4545- options: autoCompactOptions,
4546- choice: autoCompactChoice,
4547- },
4548- {
4549- key: "jail_by_default",
4550- label: "jail new sessions by default",
4551- desc: "confine tools to the session working directory unless /unjail is used",
4552- value: jailByDefault,
4553- },
4554- {
4555- key: "recursive_file_suggest",
4556- label: "recursive @-file search",
4557- desc: "fuzzy-search the whole project tree when picking files with @ instead of browsing one directory at a time",
4558- value: recursiveFiles,
4559- },
4560- {
4561- key: "respect_gitignore",
4562- label: "hide gitignored files in @-picker",
4563- desc: "skip files and directories matched by the project's root .gitignore (and .git) when picking files with @",
4564- value: respectGitignore,
4565- },
4566- {
4567- key: "compact_mode",
4568- label: "compact transcript rendering",
4569- desc: "reduce visual chrome by rendering tool calls without boxes and sent messages without padded bubbles",
4570- value: compactMode,
4571- },
4572- {
4573- key: "show_instructions_at_startup",
4574- label: "show loaded resources at startup",
4575- desc: "list loaded context files, extensions, and user-installed skills above the transcript",
4576- value: showInstructions,
4577- },
4578- {
4579- key: "tui_settings",
4580- label: "tui settings",
4581- desc: "choose input chrome in its order around the prompt",
4582- children: []settingsItem{
4583- {
4584- key: "tui_status_position",
4585- label: "status position",
4586- desc: "place model, usage, and working directory above or below the input",
4587- options: statusPositionOptions,
4588- choice: statusPositionChoice,
4589- },
4590- {
4591- key: "tui_working_position",
4592- label: "working spinner position",
4593- desc: "place the working spinner above or below the input",
4594- options: workingPositionOptions,
4595- choice: workingPositionChoice,
4596- },
4597- {
4598- key: "tui_input_style",
4599- label: "input style",
4600- desc: "choose between the plain prompt, lines, and a block input area",
4601- options: inputStyleOptions,
4602- choice: inputStyleChoice,
4603- },
4604- },
4605- },
4606- reasoningItem,
4607- {
4608- key: "theme",
4609- label: "color theme",
4610- desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
4611- options: themeOptions,
4612- choice: themeChoice,
4613- },
4614- }
4615- if len(quickItems) > 0 {
4616- items = append(items, settingsItem{
4617- key: "quick_models",
4618- label: "model shortcuts",
4619- desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches",
4620- children: quickItems,
4621- })
4622- }
4623- i.settingsDialog.Open(items)
4624-}
4625-
4626-func (i *Interactive) applySettingChange(act settingsAction) {
4627- switch {
4628- case strings.HasPrefix(act.Key, "quick_model_"):
4629- i.applyQuickModelSetting(act.Key, act.StringValue)
4630- case act.Key == "reasoning":
4631- i.applyReasoningSetting(act.StringValue)
4632- case act.Key == "theme":
4633- i.applyThemeSetting(act.StringValue)
4634- case act.Key == "auto_compact_threshold":
4635- i.applyAutoCompactThresholdSetting(act.StringValue)
4636- case act.Key == "tui_input_style":
4637- i.applyTUIInputStyleSetting(act.StringValue)
4638- case act.Key == "tui_status_position":
4639- i.applyTUIStatusPositionSetting(act.StringValue)
4640- case act.Key == "tui_working_position":
4641- i.applyTUIWorkingPositionSetting(act.StringValue)
4642- default:
4643- i.applySettingToggle(act.Key, act.Value)
4644- }
4645-}
4646-
4647-func (i *Interactive) applyAutoCompactThresholdSetting(value string) {
4648- threshold, err := strconv.Atoi(value)
4649- if err != nil {
4650- return
4651- }
4652- threshold = NormalizeAutoCompactThreshold(&threshold)
4653- if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok {
4654- if err := store.SetAutoCompactThreshold(threshold); err != nil {
4655- i.mu.Lock()
4656- i.statusErr = "settings: " + err.Error()
4657- i.mu.Unlock()
4658- return
4659- }
4660- }
4661- i.mu.Lock()
4662- i.cfg.AutoCompactThreshold = &threshold
4663- if threshold == 0 {
4664- i.statusOK = "auto-compact threshold off"
4665- } else {
4666- i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold)
4667- }
4668- i.statusErr = ""
4669- i.mu.Unlock()
4670- i.invalidate()
4671-}
4672-
4673-func (i *Interactive) quickModelSettingItems() []settingsItem {
4674- if len(i.cfg.QuickModelShortcuts) < 9 {
4675- next := make([]QuickModelShortcut, 9)
4676- copy(next, i.cfg.QuickModelShortcuts)
4677- i.cfg.QuickModelShortcuts = next
4678- }
4679- items := make([]settingsItem, 0, 9)
4680- for slot := 1; slot <= 9; slot++ {
4681- items = append(items, i.quickModelSettingItem(slot))
4682- }
4683- return items
4684-}
4685-
4686-func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
4687- current := QuickModelShortcut{}
4688- if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot {
4689- current = i.cfg.QuickModelShortcuts[slot-1]
4690- }
4691- hint := "not assigned"
4692- if current.Provider != "" && current.Model != "" {
4693- hint = current.Provider + " / " + current.Model
4694- }
4695- return settingsItem{
4696- key: "quick_model_" + strconv.Itoa(slot),
4697- label: "model " + strconv.Itoa(slot),
4698- desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.",
4699- picker: true,
4700- hint: hint,
4701- }
4702-}
4703-
4704-func (i *Interactive) compactModeEnabled() bool {
4705- return i.cfg.CompactMode != nil && *i.cfg.CompactMode
4706-}
4707-
4708-func quickModelShortcutSlot(k tui.Key) int {
4709- if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' {
4710- return 0
4711- }
4712- if runtime.GOOS == "darwin" {
4713- if !k.Super && !k.Ctrl {
4714- return 0
4715- }
4716- } else if !k.Ctrl {
4717- return 0
4718- }
4719- return int(k.Rune - '0')
4720-}
4721-
4722-func quickModelShortcutPrefix() string {
4723- return "Ctrl"
4724-}
4725-
4726-func quickModelShortcutLabel(slot int) string {
4727- return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot)
4728-}
4729-
4730-func (i *Interactive) openQuickModelPicker(slot int) {
4731- if slot < 1 || slot > 9 {
4732- return
4733- }
4734- i.quickModelAssign = slot
4735- current := i.cfg.Model
4736- if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
4737- current = i.cfg.QuickModelShortcuts[slot-1].Model
4738- }
4739- var loggedIn []string
4740- if i.cfg.LoggedInProviders != nil {
4741- loggedIn = i.cfg.LoggedInProviders()
4742- }
4743- i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
4744-}
4745-
4746-func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) {
4747- i.setQuickModelShortcut(slot, providerName, model)
4748-}
4749-
4750-func (i *Interactive) applyQuickModelShortcut(slot int) {
4751- if slot < 1 || slot > 9 {
4752- return
4753- }
4754- if i.busy {
4755- i.mu.Lock()
4756- i.statusErr = "cannot switch model while a turn is running"
4757- i.statusOK = ""
4758- i.mu.Unlock()
4759- i.invalidate()
4760- return
4761- }
4762- if len(i.cfg.QuickModelShortcuts) < slot {
4763- i.mu.Lock()
4764- i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
4765- i.statusOK = ""
4766- i.mu.Unlock()
4767- i.invalidate()
4768- return
4769- }
4770- shortcut := i.cfg.QuickModelShortcuts[slot-1]
4771- if shortcut.Provider == "" || shortcut.Model == "" {
4772- i.mu.Lock()
4773- i.statusErr = quickModelShortcutLabel(slot) + " is not assigned"
4774- i.statusOK = ""
4775- i.mu.Unlock()
4776- i.invalidate()
4777- return
4778- }
4779- i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
4780- i.invalidate()
4781-}
4782-
4783-func (i *Interactive) applyQuickModelSetting(key, value string) {
4784- slotText := strings.TrimPrefix(key, "quick_model_")
4785- slot, err := strconv.Atoi(slotText)
4786- if err != nil || slot < 1 || slot > 9 {
4787- return
4788- }
4789- providerName, model := "", ""
4790- if value != "" {
4791- parts := strings.SplitN(value, "\t", 2)
4792- if len(parts) == 2 {
4793- providerName, model = parts[0], parts[1]
4794- }
4795- }
4796- i.setQuickModelShortcut(slot, providerName, model)
4797-}
4798-
4799-func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) {
4800- if len(i.cfg.QuickModelShortcuts) < slot {
4801- next := make([]QuickModelShortcut, slot)
4802- copy(next, i.cfg.QuickModelShortcuts)
4803- i.cfg.QuickModelShortcuts = next
4804- }
4805- i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model}
4806- if i.cfg.SettingsStore != nil {
4807- if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil {
4808- i.mu.Lock()
4809- i.statusErr = "settings: " + err.Error()
4810- i.mu.Unlock()
4811- return
4812- }
4813- }
4814- i.mu.Lock()
4815- if model == "" {
4816- i.statusOK = quickModelShortcutLabel(slot) + " cleared"
4817- } else {
4818- i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model
4819- }
4820- i.statusErr = ""
4821- i.mu.Unlock()
4822- i.refreshQuickModelSettingsItem(slot)
4823- i.invalidate()
4824-}
4825-
4826-func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
4827- if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
4828- return
4829- }
4830- key := "quick_model_" + strconv.Itoa(slot)
4831- for idx, it := range i.settingsDialog.items {
4832- if it.key == key {
4833- i.settingsDialog.items[idx] = i.quickModelSettingItem(slot)
4834- return
4835- }
4836- }
4837-}
4838-
4839-func (i *Interactive) resetSettingsToggle(key string, value bool) {
4840- if i.settingsDialog == nil {
4841- return
4842- }
4843- for idx := range i.settingsDialog.items {
4844- if i.settingsDialog.items[idx].key == key {
4845- i.settingsDialog.items[idx].value = value
4846- return
4847- }
4848- }
4849-}
4850-
4851-func (i *Interactive) ponytailEnabled() bool {
4852- return i.cfg.PonytailEnabled == nil || *i.cfg.PonytailEnabled
4853-}
4854-
4855-func (i *Interactive) ponytailSettingsAvailable() bool {
4856- _, persistent := i.cfg.SettingsStore.(ponytailSettingsStore)
4857- return persistent && i.cfg.RefreshPrompt != nil
4858-}
4859-
4860-func (i *Interactive) webSearchEnabled() bool {
4861- return i.cfg.WebSearchEnabled == nil || *i.cfg.WebSearchEnabled
4862-}
4863-
4864-func (i *Interactive) webSearchToolAllowed() bool {
4865- return i.cfg.WebSearchToolAllowed == nil || *i.cfg.WebSearchToolAllowed
4866-}
4867-
4868-func (i *Interactive) webSearchEffectiveEnabled() bool {
4869- return i.webSearchToolAllowed() && (i.cfg.WebSearchInvocationOverride || i.webSearchEnabled())
4870-}
4871-
4872-func (i *Interactive) webSearchSettingsAvailable() bool {
4873- _, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
4874- return i.webSearchToolAllowed() && !i.cfg.WebSearchInvocationOverride && persistent && i.cfg.RefreshTools != nil
4875-}
4876-
4877-func (i *Interactive) webSearchUnavailableHint() string {
4878- if !i.webSearchToolAllowed() {
4879- return "unavailable in this session due to the launch-time tool capability ceiling"
4880- }
4881- if i.cfg.WebSearchInvocationOverride {
4882- return "enabled for this session by explicit --tools web_search; the persisted setting applies to invocations without --tools"
4883- }
4884- if !i.webSearchSettingsAvailable() {
4885- return "requires persistent settings and live tool refresh support"
4886- }
4887- return ""
4888-}
4889-
4890-func (i *Interactive) setWebSearchAvailable(available bool) {
4891- // Advance before publishing the new gate/child ceiling. A resolver that
4892- // captured the prior generation can no longer commit after this transition.
4893- // Callers may hold agentMu and i.mu; the callback must complete without
4894- // calling back into Interactive or requiring either lock.
4895- i.webSearchPolicyGeneration.Add(1)
4896- if i.cfg.SetWebSearchAvailable != nil {
4897- i.cfg.SetWebSearchAvailable(available)
4898- }
4899-}
4900-
4901-func (i *Interactive) applyWebSearchSettingToggle(key string, value bool) {
4902- previous := i.webSearchEnabled()
4903- store, persistent := i.cfg.SettingsStore.(webSearchSettingsStore)
4904- if !i.webSearchSettingsAvailable() || !persistent {
4905- i.resetSettingsToggle(key, i.webSearchEffectiveEnabled())
4906- i.mu.Lock()
4907- i.statusOK = ""
4908- i.statusErr = "web search unavailable: " + i.webSearchUnavailableHint()
4909- i.mu.Unlock()
4910- return
4911- }
4912- if err := store.SetWebSearchEnabled(value); err != nil {
4913- i.resetSettingsToggle(key, previous)
4914- i.mu.Lock()
4915- i.statusOK = ""
4916- i.statusErr = "settings: " + err.Error()
4917- i.mu.Unlock()
4918- return
4919- }
4920- val := value
4921- i.cfg.WebSearchEnabled = &val
4922- // Revoke and advance the policy generation for both directions. Enabling
4923- // remains fail-closed until the freshly resolved registry commits, while a
4924- // stale resolve from before this persisted transition is rejected.
4925- i.stripWebSearchTool()
4926- if err := i.cfg.RefreshTools(); err != nil {
4927- // Refresh failure leaves the old registry's state unknown. Revoke both
4928- // live execution and generic-child inheritance before rollback.
4929- i.stripWebSearchTool()
4930- errMsg := "settings: web search tool refresh: " + err.Error()
4931- if rollbackErr := store.SetWebSearchEnabled(previous); rollbackErr != nil {
4932- failClosed := false
4933- i.cfg.WebSearchEnabled = &failClosed
4934- i.resetSettingsToggle(key, false)
4935- errMsg += "; rollback persistence: " + rollbackErr.Error()
4936- } else {
4937- previousVal := previous
4938- i.cfg.WebSearchEnabled = &previousVal
4939- i.resetSettingsToggle(key, previous)
4940- if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
4941- errMsg += "; rollback refresh: " + refreshErr.Error()
4942- i.stripWebSearchTool()
4943- }
4944- }
4945- i.mu.Lock()
4946- i.statusOK = ""
4947- i.statusErr = errMsg
4948- i.mu.Unlock()
4949- return
4950- }
4951- i.mu.Lock()
4952- i.statusOK = "web search " + onOff(value)
4953- i.statusErr = ""
4954- i.mu.Unlock()
4955-}
4956-
4957-// stripWebSearchTool removes the callable web_search entry from the live
4958-// registry without asking the resolver to run again. It is the fail-closed
4959-// fallback when persistence rollback fails after a refresh failure.
4960-func (i *Interactive) stripWebSearchTool() {
4961- // Revoke first so a stale tool snapshotted by core cannot execute while
4962- // the advertised registry is being replaced.
4963- i.setWebSearchAvailable(false)
4964- i.agentMu.Lock()
4965- defer i.agentMu.Unlock()
4966- i.mu.Lock()
4967- defer i.mu.Unlock()
4968- if i.agent == nil {
4969- return
4970- }
4971- tools := i.agent.ToolsSnapshot()
4972- if _, ok := tools["web_search"]; !ok {
4973- return
4974- }
4975- delete(tools, "web_search")
4976- i.agent.SetTools(tools)
4977-}
4978-
4979-func (i *Interactive) applySettingToggle(key string, value bool) {
4980- // Every setting toggle forces a full repaint at the end — same
4981- // effect as the user pressing Ctrl+L — so any per-setting visual
4982- // change (image rendering, status copy, future toggles) lands
4983- // immediately instead of waiting for the next diff frame.
4984- defer func() {
4985- i.requestRendererClear()
4986- i.invalidate()
4987- }()
4988- switch key {
4989- case "inline_images_enabled":
4990- val := value
4991- i.cfg.InlineImagesEnabled = &val
4992- if i.cfg.SettingsStore != nil {
4993- if err := i.cfg.SettingsStore.SetInlineImages(value); err != nil {
4994- i.mu.Lock()
4995- i.statusErr = "settings: " + err.Error()
4996- i.mu.Unlock()
4997- return
4998- }
4999- }
5000- i.mu.Lock()
5001- i.view.ImageProto = effectiveImageProtocol(i.cfg.InlineImagesEnabled)
5002- i.view.InvalidateRenderCache()
5003- i.statusOK = "inline image rendering " + onOff(value)
5004- i.statusErr = ""
5005- i.mu.Unlock()
5006- case "terminal_alerts_enabled":
5007- val := value
5008- i.mu.Lock()
5009- i.cfg.TerminalAlertsEnabled = &val
5010- i.mu.Unlock()
5011- if store, ok := i.cfg.SettingsStore.(terminalAlertsSettingsStore); ok {
5012- if err := store.SetTerminalAlertsEnabled(value); err != nil {
5013- i.mu.Lock()
5014- i.statusErr = "settings: " + err.Error()
5015- i.mu.Unlock()
5016- return
5017- }
5018- }
5019- i.mu.Lock()
5020- i.statusOK = "terminal alerts " + onOff(value)
5021- i.statusErr = ""
5022- i.mu.Unlock()
5023- case "terminal_title_enabled":
5024- val := value
5025- var cancel context.CancelFunc
5026- i.mu.Lock()
5027- i.cfg.TerminalTitleEnabled = &val
5028- if value {
5029- i.writeTerminalTitleLocked(i.sessionTitle)
5030- } else {
5031- cancel = i.titleCancel
5032- i.titleCancel = nil
5033- i.titleVersion++
5034- i.writeTerminalTitleLocked("")
5035- }
5036- i.mu.Unlock()
5037- if cancel != nil {
5038- cancel()
5039- }
5040- if store, ok := i.cfg.SettingsStore.(terminalTitleSettingsStore); ok {
5041- if err := store.SetTerminalTitleEnabled(value); err != nil {
5042- i.mu.Lock()
5043- i.statusErr = "settings: " + err.Error()
5044- i.mu.Unlock()
5045- return
5046- }
5047- }
5048- i.mu.Lock()
5049- i.statusOK = "AI terminal titles " + onOff(value)
5050- i.statusErr = ""
5051- i.mu.Unlock()
5052- case "web_search_enabled":
5053- apply := func() { i.applyWebSearchSettingToggle(key, value) }
5054- if i.cfg.SessionTransition != nil {
5055- i.cfg.SessionTransition(apply)
5056- } else {
5057- apply()
5058- }
5059- case "ponytail_enabled":
5060- previous := i.ponytailEnabled()
5061- store, available := i.cfg.SettingsStore.(ponytailSettingsStore)
5062- if !available || i.cfg.RefreshPrompt == nil {
5063- i.resetSettingsToggle(key, previous)
5064- i.mu.Lock()
5065- i.statusOK = ""
5066- i.statusErr = "ponytail coding mode unavailable: persistent settings and live prompt refresh are required"
5067- i.mu.Unlock()
5068- return
5069- }
5070- if err := store.SetPonytailEnabled(value); err != nil {
5071- i.resetSettingsToggle(key, previous)
5072- i.mu.Lock()
5073- i.statusOK = ""
5074- i.statusErr = "settings: " + err.Error()
5075- i.mu.Unlock()
5076- return
5077- }
5078- val := value
5079- i.cfg.PonytailEnabled = &val
5080- if err := i.cfg.RefreshPrompt(); err != nil {
5081- errMsg := "settings: ponytail prompt refresh: " + err.Error()
5082- if rollbackErr := store.SetPonytailEnabled(previous); rollbackErr != nil {
5083- // The first write succeeded, so keep the in-memory setting at
5084- // the value that remains durable instead of claiming that the
5085- // rollback took effect.
5086- i.resetSettingsToggle(key, value)
5087- errMsg += "; rollback persistence: " + rollbackErr.Error()
5088- if reconcileErr := i.cfg.RefreshPrompt(); reconcileErr != nil {
5089- errMsg += "; durable-state refresh: " + reconcileErr.Error()
5090- }
5091- } else {
5092- previousVal := previous
5093- i.cfg.PonytailEnabled = &previousVal
5094- i.resetSettingsToggle(key, previous)
5095- if refreshErr := i.cfg.RefreshPrompt(); refreshErr != nil {
5096- errMsg += "; rollback refresh: " + refreshErr.Error()
5097- }
5098- }
5099- i.mu.Lock()
5100- i.statusOK = ""
5101- i.statusErr = errMsg
5102- i.mu.Unlock()
5103- return
5104- }
5105- i.mu.Lock()
5106- i.statusOK = "ponytail coding mode " + onOff(value)
5107- i.statusErr = ""
5108- i.mu.Unlock()
5109- case "auto_subagents_enabled":
5110- if value && !i.autoSubagentsAvailable() {
5111- i.mu.Lock()
5112- i.statusOK = ""
5113- i.statusErr = "subagent orchestrator unavailable: " + i.autoSubagentsUnavailableHint()
5114- i.mu.Unlock()
5115- return
5116- }
5117- i.mu.Lock()
5118- previousFlag := i.cfg.AutoSubagentsEnabled
5119- previous := previousFlag != nil && *previousFlag
5120- val := value
5121- i.cfg.AutoSubagentsEnabled = &val
5122- i.mu.Unlock()
5123- if i.cfg.SettingsStore != nil {
5124- if err := i.cfg.SettingsStore.SetAutoSubagents(value); err != nil {
5125- i.mu.Lock()
5126- i.cfg.AutoSubagentsEnabled = previousFlag
5127- i.statusOK = ""
5128- i.statusErr = "settings: " + err.Error()
5129- i.mu.Unlock()
5130- i.resetSettingsToggle(key, previous)
5131- return
5132- }
5133- }
5134- // The subagent tools remain available for explicit user requests.
5135- // Swap only the system-prompt guidance between strict orchestration and
5136- // on-demand delegation.
5137- i.applyAutoSubagentsTool()
5138- // Also swap the system-prompt addendum so the model knows whether it
5139- // should orchestrate proactively or delegate only on user request.
5140- i.applyAutoSubagentsSystemPrompt(value)
5141- i.mu.Lock()
5142- i.statusOK = "subagent orchestrator " + onOff(value)
5143- i.statusErr = ""
5144- i.mu.Unlock()
5145- case "fast_mode":
5146- previous := i.cfg.FastMode != nil && *i.cfg.FastMode
5147- if err := provider.ValidateFastMode(i.cfg.Provider, value); err != nil {
5148- i.resetSettingsToggle(key, previous)
5149- i.mu.Lock()
5150- i.statusErr = "settings: " + err.Error()
5151- i.mu.Unlock()
5152- return
5153- }
5154- if store, ok := i.cfg.SettingsStore.(fastModeSettingsStore); ok {
5155- if err := store.SetFastMode(value); err != nil {
5156- i.resetSettingsToggle(key, previous)
5157- i.mu.Lock()
5158- i.statusErr = "settings: " + err.Error()
5159- i.mu.Unlock()
5160- return
5161- }
5162- }
5163- val := value
5164- i.cfg.FastMode = &val
5165- if i.agent != nil {
5166- i.agent.SetFastMode(value)
5167- }
5168- i.mu.Lock()
5169- i.statusOK = "fast mode " + onOff(value)
5170- i.statusErr = ""
5171- i.mu.Unlock()
5172- case "lsp_enabled":
5173- previous := i.cfg.LSPEnabled == nil || *i.cfg.LSPEnabled
5174- store, hasStore := i.cfg.SettingsStore.(lspSettingsStore)
5175- if hasStore {
5176- if err := store.SetLSPEnabled(value); err != nil {
5177- i.mu.Lock()
5178- i.statusErr = "settings: " + err.Error()
5179- i.mu.Unlock()
5180- return
5181- }
5182- }
5183- val := value
5184- i.cfg.LSPEnabled = &val
5185- if i.cfg.RefreshTools != nil {
5186- if err := i.cfg.RefreshTools(); err != nil {
5187- errMsg := "settings: refresh tools: " + err.Error()
5188- rollbackErr := error(nil)
5189- if hasStore {
5190- rollbackErr = store.SetLSPEnabled(previous)
5191- }
5192- if rollbackErr != nil {
5193- // The first write succeeded, so keep the in-memory setting at
5194- // the value that remains durable instead of claiming that the
5195- // rollback took effect.
5196- errMsg += "; rollback persistence: " + rollbackErr.Error()
5197- if reconcileErr := i.cfg.RefreshTools(); reconcileErr != nil {
5198- errMsg += "; durable-state refresh: " + reconcileErr.Error()
5199- }
5200- } else {
5201- previousVal := previous
5202- i.cfg.LSPEnabled = &previousVal
5203- if refreshErr := i.cfg.RefreshTools(); refreshErr != nil {
5204- errMsg += "; rollback refresh: " + refreshErr.Error()
5205- }
5206- }
5207- i.mu.Lock()
5208- i.statusOK = ""
5209- i.statusErr = errMsg
5210- i.mu.Unlock()
5211- return
5212- }
5213- }
5214- i.mu.Lock()
5215- i.statusOK = "lsp in main session " + onOff(value)
5216- i.statusErr = ""
5217- i.mu.Unlock()
5218- case "subagent_lsp_enabled":
5219- if store, ok := i.cfg.SettingsStore.(lspSettingsStore); ok {
5220- if err := store.SetSubagentLSPEnabled(value); err != nil {
5221- i.mu.Lock()
5222- i.statusErr = "settings: " + err.Error()
5223- i.mu.Unlock()
5224- return
5225- }
5226- }
5227- val := value
5228- i.cfg.SubagentLSPEnabled = &val
5229- i.mu.Lock()
5230- i.statusOK = "lsp in sub-agents " + onOff(value)
5231- i.statusErr = ""
5232- i.mu.Unlock()
5233- case "jail_by_default":
5234- val := value
5235- i.cfg.JailByDefault = &val
5236- if i.cfg.SettingsStore != nil {
5237- if err := i.cfg.SettingsStore.SetJailByDefault(value); err != nil {
5238- i.mu.Lock()
5239- i.statusErr = "settings: " + err.Error()
5240- i.mu.Unlock()
5241- return
5242- }
5243- }
5244- if i.cfg.Sandbox != nil {
5245- if value {
5246- i.cfg.Sandbox.Lock()
5247- } else {
5248- i.cfg.Sandbox.Unlock()
5249- }
5250- }
5251- i.mu.Lock()
5252- i.statusOK = "jail by default " + onOff(value)
5253- i.statusErr = ""
5254- i.mu.Unlock()
5255- case "recursive_file_suggest":
5256- val := value
5257- i.cfg.RecursiveFileSuggest = &val
5258- if i.cfg.SettingsStore != nil {
5259- if err := i.cfg.SettingsStore.SetRecursiveFileSuggest(value); err != nil {
5260- i.mu.Lock()
5261- i.statusErr = "settings: " + err.Error()
5262- i.mu.Unlock()
5263- return
5264- }
5265- }
5266- // Flip the live picker so the next @ reflects the new mode
5267- // without restarting zut. SetRecursive drops its cache.
5268- i.fileSuggest.SetRecursive(value)
5269- i.mu.Lock()
5270- i.statusOK = "recursive @-file search " + onOff(value)
5271- i.statusErr = ""
5272- i.mu.Unlock()
5273- case "respect_gitignore":
5274- val := value
5275- i.cfg.RespectGitignore = &val
5276- if i.cfg.SettingsStore != nil {
5277- if err := i.cfg.SettingsStore.SetRespectGitignore(value); err != nil {
5278- i.mu.Lock()
5279- i.statusErr = "settings: " + err.Error()
5280- i.mu.Unlock()
5281- return
5282- }
5283- }
5284- i.fileSuggest.SetRespectGitignore(value)
5285- i.mu.Lock()
5286- i.statusOK = "hide gitignored files in @-picker " + onOff(value)
5287- i.statusErr = ""
5288- i.mu.Unlock()
5289- case "compact_mode":
5290- val := value
5291- i.cfg.CompactMode = &val
5292- if i.cfg.SettingsStore != nil {
5293- if err := i.cfg.SettingsStore.SetCompactMode(value); err != nil {
5294- i.mu.Lock()
5295- i.statusErr = "settings: " + err.Error()
5296- i.mu.Unlock()
5297- return
5298- }
5299- }
5300- i.mu.Lock()
5301- i.view.CompactMode = value
5302- i.view.InvalidateRenderCache()
5303- i.statusOK = "compact transcript rendering " + onOff(value)
5304- i.statusErr = ""
5305- i.mu.Unlock()
5306- case "show_instructions_at_startup":
5307- val := value
5308- i.cfg.ShowInstructionsAtStartup = &val
5309- if store, ok := i.cfg.SettingsStore.(showInstructionsSettingsStore); ok {
5310- if err := store.SetShowInstructionsAtStartup(value); err != nil {
5311- i.mu.Lock()
5312- i.statusErr = "settings: " + err.Error()
5313- i.mu.Unlock()
5314- return
5315- }
5316- }
5317- i.mu.Lock()
5318- i.view.StartupAgentName = ""
5319- i.view.StartupContextPaths = nil
5320- i.view.StartupExtensionNames = nil
5321- i.view.StartupSkillNames = nil
5322- i.view.InvalidateRenderCache()
5323- if value {
5324- i.view.StartupAgentName = i.cfg.StartupAgentName
5325- i.view.StartupContextPaths = append(i.view.StartupContextPaths, i.cfg.StartupContextPaths...)
5326- i.view.StartupExtensionNames = append(i.view.StartupExtensionNames, i.cfg.StartupExtensionNames...)
5327- i.view.StartupSkillNames = append(i.view.StartupSkillNames, i.cfg.StartupSkillNames...)
5328- }
5329- i.statusOK = "show loaded resources at startup " + onOff(value)
5330- i.statusErr = ""
5331- i.mu.Unlock()
5332- }
5333-}
1411+ hiddenOpenAIImageMirrorPrefix = "Tool output included the following image content:"
1412+ autoCompactContinueMetaKey = "auto_compact_continue"
1413+ shellEscapeMetaKey = "shell_escape"
1414+)
53341415
5335-func (i *Interactive) applyTUIInputStyleSetting(style string) {
5336- defer func() {
5337- i.requestRendererClear()
5338- i.invalidate()
5339- }()
5340- style = tui.NormalizeInputStyle(style)
5341- i.cfg.TUIInputStyle = style
5342- i.applyInputCursorColor()
5343- if i.cfg.SettingsStore != nil {
5344- if err := i.cfg.SettingsStore.SetTUIInputStyle(style); err != nil {
5345- i.mu.Lock()
5346- i.statusErr = "settings: " + err.Error()
5347- i.mu.Unlock()
5348- return
5349- }
5350- }
5351- i.mu.Lock()
5352- i.statusOK = "input style " + style
5353- i.statusErr = ""
5354- i.mu.Unlock()
5355-}
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.
53561421
5357-func (i *Interactive) applyTUIStatusPositionSetting(position string) {
5358- defer func() {
5359- i.requestRendererClear()
5360- i.invalidate()
5361- }()
5362- position = tui.NormalizeStatusPosition(position)
5363- i.cfg.TUIStatusPosition = position
5364- if i.cfg.SettingsStore != nil {
5365- if err := i.cfg.SettingsStore.SetTUIStatusPosition(position); err != nil {
5366- i.mu.Lock()
5367- i.statusErr = "settings: " + err.Error()
5368- i.mu.Unlock()
5369- return
5370- }
5371- }
5372- i.mu.Lock()
5373- label := "above input"
5374- if position == tui.StatusPositionBelowInput {
5375- label = "below input"
5376- }
5377- i.statusOK = "status position " + label
5378- i.statusErr = ""
5379- i.mu.Unlock()
5380-}
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.
53811426
5382-func (i *Interactive) applyTUIWorkingPositionSetting(position string) {
5383- defer func() {
5384- i.requestRendererClear()
5385- i.invalidate()
5386- }()
5387- position = tui.NormalizeWorkingPosition(position)
5388- i.cfg.TUIWorkingPosition = position
5389- if i.cfg.SettingsStore != nil {
5390- if err := i.cfg.SettingsStore.SetTUIWorkingPosition(position); err != nil {
5391- i.mu.Lock()
5392- i.statusErr = "settings: " + err.Error()
5393- i.mu.Unlock()
5394- return
5395- }
5396- }
5397- i.mu.Lock()
5398- label := "above input"
5399- if position == tui.WorkingPositionBelowInput {
5400- label = "below input"
5401- }
5402- i.statusOK = "working spinner position " + label
5403- i.statusErr = ""
5404- i.mu.Unlock()
5405-}
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.
54061430
5407-func (i *Interactive) applyThemeSetting(name string) {
5408- if i.cfg.SettingsStore != nil {
5409- if err := i.cfg.SettingsStore.SetTheme(name); err != nil {
5410- i.mu.Lock()
5411- i.statusErr = "settings: " + err.Error()
5412- i.mu.Unlock()
5413- return
5414- }
5415- }
5416- i.cfg.ThemeName = name
5417- if name == "auto" {
5418- i.cfg.ThemeName = ""
5419- }
5420- i.applyThemeNow(name)
5421-}
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
54221436
5423-func (i *Interactive) applyThemeNow(name string) {
5424- if name == "" {
5425- name = "auto"
5426- }
5427- detected := tui.Dark
5428- if tui.IsLightTheme(i.cfg.Theme) {
5429- detected = tui.Light
5430- }
5431- // Keep the startup terminal snapshot available when a user switches
5432- // themes live; inherited mode must not issue OSC queries while the TUI
5433- // already owns raw stdin.
5434- detected.Terminal = i.cfg.Theme.Terminal
5435- th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
5436- if err != nil {
5437- if i.cfg.SettingsStore != nil {
5438- _ = i.cfg.SettingsStore.SetTheme("auto")
5439- }
5440- i.cfg.ThemeName = ""
5441- th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)
5442- i.mu.Lock()
5443- i.statusErr = "theme missing; reset to default"
5444- i.mu.Unlock()
5445- } else {
5446- i.mu.Lock()
5447- label := applied
5448- if label == "" {
5449- label = "auto"
5450- }
5451- i.statusOK = "theme " + label
5452- i.statusErr = ""
5453- i.mu.Unlock()
5454- }
5455- i.mu.Lock()
5456- i.cfg.Theme = th
5457- i.view.Theme = th
5458- i.view.InvalidateRenderCache()
5459- i.mu.Unlock()
5460- i.ed.Prompt = th.AccentBar(th.Accent)
5461- i.applyInputCursorColor()
5462- i.spin.Configure(th)
5463- i.requestRendererTheme(th)
5464- i.requestRendererClear()
5465- i.invalidate()
5466-}
1437+// armCtrlCExit records the timestamp of the current ctrl+c so the next
1438+// one within ctrlCExitWindow exits.
54671439
5468-func (i *Interactive) applyReasoningSetting(level string) {
5469- defer func() {
5470- i.requestRendererClear()
5471- i.invalidate()
5472- }()
5473- level = provider.NormalizeReasoning(level)
5474- i.cfg.Reasoning = level
5475- if i.cfg.SettingsStore != nil {
5476- if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
5477- i.mu.Lock()
5478- i.statusErr = "settings: " + err.Error()
5479- i.mu.Unlock()
5480- return
5481- }
5482- }
5483- if i.cfg.OnReasoningChanged != nil {
5484- i.cfg.OnReasoningChanged(level)
5485- }
5486- i.mu.Lock()
5487- if i.agent != nil {
5488- i.agent.Reasoning = level
5489- }
5490- label := level
5491- if label == "" {
5492- label = "off"
5493- }
5494- i.statusOK = "reasoning level " + label
5495- i.statusErr = ""
5496- i.mu.Unlock()
5497-}
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.
54981533
54991534 // buildStudyPrompt returns the canned prompt the /study command
55001535 // submits to the agent.
@@ -5514,24 +1549,6 @@ func (i *Interactive) applyReasoningSetting(level string) {
55141549 // prompt rather than erroring — the agent will surface the
55151550 // missing-file failure itself when it tries to read it, which is
55161551 // more useful than a refusal here.
5517-func buildStudyPrompt(arg, cwd string) string {
5518- arg = strings.TrimSpace(arg)
5519- if arg == "" {
5520- return "Read and understand everything in the current directory."
5521- }
5522- abs := arg
5523- if !filepath.IsAbs(abs) {
5524- abs = filepath.Join(cwd, abs)
5525- }
5526- display := arg
5527- if rel, err := filepath.Rel(cwd, abs); err == nil && !strings.HasPrefix(rel, "..") {
5528- display = rel
5529- }
5530- if info, err := os.Stat(abs); err == nil && !info.IsDir() {
5531- return "Read and understand the file " + display + "."
5532- }
5533- return "Read and understand everything in the directory " + display + "."
5534-}
55351552
55361553 // tryPathTabCompleteEditor looks at ed's current value, finds the
55371554 // path-like token immediately before the cursor (the cursor is always
@@ -5556,548 +1573,30 @@ func buildStudyPrompt(arg, cwd string) string {
55561573 // Free function (not a method) so the same logic runs against the
55571574 // editor instances owned by local dialogs without each
55581575 // dialog needing its own copy.
5559-func tryPathTabCompleteEditor(ed *tui.Editor, cwd string) bool {
5560- if ed == nil {
5561- return false
5562- }
5563- val := ed.Value()
5564- // Find the trailing run of non-whitespace.
5565- start := len(val)
5566- for start > 0 {
5567- r := val[start-1]
5568- if r == ' ' || r == '\t' || r == '\n' {
5569- break
5570- }
5571- start--
5572- }
5573- token := val[start:]
5574- if token == "" {
5575- return false
5576- }
5577- if !looksLikePathToken(token) {
5578- return false
5579- }
5580-
5581- // Resolve the absolute parent directory + base prefix to match.
5582- parentAbs, basePrefix, displayParent, ok := resolvePathTabToken(token, cwd)
5583- if !ok {
5584- return true
5585- }
5586- entries, err := os.ReadDir(parentAbs)
5587- if err != nil {
5588- return true
5589- }
5590- var names []string
5591- var isDir []bool
5592- for _, e := range entries {
5593- name := e.Name()
5594- if !strings.HasPrefix(name, basePrefix) {
5595- continue
5596- }
5597- // Hide dotfiles unless the user explicitly typed a leading dot,
5598- // mirroring bash's default behaviour.
5599- if strings.HasPrefix(name, ".") && !strings.HasPrefix(basePrefix, ".") {
5600- continue
5601- }
5602- names = append(names, name)
5603- isDir = append(isDir, e.IsDir())
5604- }
5605- if len(names) == 0 {
5606- return true
5607- }
5608-
5609- var completed string
5610- var completedIsDir bool
5611- if len(names) == 1 {
5612- completed = names[0]
5613- completedIsDir = isDir[0]
5614- } else {
5615- completed = longestCommonPrefix(names)
5616- if completed == basePrefix {
5617- // Already at the deepest unambiguous prefix; nothing to add.
5618- return true
5619- }
5620- }
5621-
5622- // Build the replacement token in the same display form the user
5623- // typed (preserve ~ vs absolute vs relative).
5624- newToken := displayParent + completed
5625- if len(names) == 1 && completedIsDir {
5626- newToken += "/"
5627- }
5628-
5629- ed.SetValue(val[:start] + newToken)
5630- return true
5631-}
56321576
56331577 // tryPathTabComplete is the Interactive-bound convenience wrapper.
5634-// It calls the free helper against the main editor and invalidates
5635-// the frame on a successful rewrite.
5636-func (i *Interactive) tryPathTabComplete() bool {
5637- if tryPathTabCompleteEditor(i.ed, i.cfg.CWD) {
5638- i.invalidate()
5639- return true
5640- }
5641- return false
5642-}
5643-
5644-// looksLikePathToken reports whether tok is shaped like a filesystem
5645-// path. Paths must either start with ~, /, ./, ../ or contain a /.
5646-// Plain words are excluded so Tab on "hello" stays a no-op.
5647-func looksLikePathToken(tok string) bool {
5648- if tok == "" {
5649- return false
5650- }
5651- if tok[0] == '~' || tok[0] == '/' {
5652- return true
5653- }
5654- if strings.HasPrefix(tok, "./") || strings.HasPrefix(tok, "../") {
5655- return true
5656- }
5657- return strings.Contains(tok, "/")
5658-}
5659-
5660-// resolvePathTabToken splits tok into (absolute parent dir, basename
5661-// prefix to match, display-form parent the user typed). ok is false
5662-// when the parent dir can't be resolved (e.g. ~ with no $HOME).
5663-func resolvePathTabToken(tok, cwd string) (parentAbs, basePrefix, displayParent string, ok bool) {
5664- // Detect ~ expansion.
5665- expanded := tok
5666- homePrefix := ""
5667- if tok == "~" {
5668- home, err := os.UserHomeDir()
5669- if err != nil || home == "" {
5670- return "", "", "", false
5671- }
5672- // "~" alone: complete in $HOME. parent = home, base = "".
5673- return home, "", "~/", true
5674- }
5675- if strings.HasPrefix(tok, "~/") {
5676- home, err := os.UserHomeDir()
5677- if err != nil || home == "" {
5678- return "", "", "", false
5679- }
5680- expanded = home + tok[1:]
5681- homePrefix = "~"
5682- }
5683-
5684- dir, base := splitDirBase(expanded)
5685- if !filepath.IsAbs(dir) {
5686- dir = filepath.Join(cwd, dir)
5687- }
5688-
5689- // Reconstruct the display form the user typed for the parent,
5690- // keeping ~ when they used it. The base is dropped — the caller
5691- // substitutes the completed name.
5692- display := tok[:len(tok)-len(base)]
5693- if homePrefix != "" && !strings.HasPrefix(display, "~") {
5694- display = homePrefix + display[len(homePrefix):]
5695- }
5696- return dir, base, display, true
5697-}
5698-
5699-// splitDirBase is like filepath.Split but preserves the trailing
5700-// slash convention: "foo" => (".", "foo"); "foo/" => ("foo", "");
5701-// "a/b" => ("a/", "b"); "/" => ("/", ""). Returned dir always has
5702-// the trailing separator when non-empty so callers can rebuild paths
5703-// by concatenation.
5704-func splitDirBase(p string) (dir, base string) {
5705- if p == "" {
5706- return ".", ""
5707- }
5708- i := strings.LastIndex(p, "/")
5709- if i < 0 {
5710- return ".", p
5711- }
5712- return p[:i+1], p[i+1:]
5713-}
5714-
5715-func longestCommonPrefix(ss []string) string {
5716- if len(ss) == 0 {
5717- return ""
5718- }
5719- prefix := ss[0]
5720- for _, s := range ss[1:] {
5721- n := 0
5722- for n < len(prefix) && n < len(s) && prefix[n] == s[n] {
5723- n++
5724- }
5725- prefix = prefix[:n]
5726- if prefix == "" {
5727- return ""
5728- }
5729- }
5730- return prefix
5731-}
5732-
5733-func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5734- parts := strings.Fields(cmd)
5735- if len(parts) == 0 {
5736- return false
5737- }
5738-
5739- if isSkillCommand(cmd) {
5740- var available []*skills.Skill
5741- if i.cfg.SkillSnapshot != nil {
5742- available = i.cfg.SkillSnapshot()
5743- }
5744- prompt, _, err := expandSkillCommand(cmd, available)
5745- if err != nil {
5746- i.mu.Lock()
5747- i.statusErr = err.Error()
5748- i.statusOK = ""
5749- i.mu.Unlock()
5750- return false
5751- }
5752- i.submitOrQueuePrompt(ctx, prompt)
5753- return false
5754- }
1578+// It calls the free helper against the main editor and invalidates
1579+// the frame on a successful rewrite.
57551580
5756- switch strings.ToLower(parts[0]) {
5757- case "/goal":
5758- i.runGoalCommand(ctx, cmd, parts)
5759- case "/exit":
5760- return true
5761- case "/clear":
5762- if i.agent != nil {
5763- i.agent.SetMessages(nil)
5764- }
5765- i.mu.Lock()
5766- i.toolCalls = map[string]*tui.ToolCallView{}
5767- i.toolOrder = nil
5768- i.toolGate = map[string]int{}
5769- i.statusErr = ""
5770- i.statusOK = ""
5771- i.helpBlock = nil
5772- i.parkedTurn = 0
5773- i.parkedTotal = 0
5774- i.scrollOffset = 0
5775- i.extNotes = nil
5776- i.reloadErrors = nil
5777- handoff, persistHandoff := i.resetCompactContinuationLocked()
5778- i.view.InvalidateRenderCache()
5779- i.mu.Unlock()
5780- if persistHandoff {
5781- i.persistCompactHandoff(handoff)
5782- }
5783- case "/help":
5784- i.mu.Lock()
5785- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
5786- i.statusErr = ""
5787- i.statusOK = ""
5788- // Pin the viewport to the newest content so the help block,
5789- // which we just appended to the end of the transcript, is
5790- // what the user actually sees.
5791- i.scrollOffset = 0
5792- i.mu.Unlock()
5793- case "/login":
5794- i.dialog.Open(i.cfg.ZutHome)
5795- case "/logout":
5796- if len(parts) >= 2 {
5797- // Explicit target: /logout anthropic | openai | all
5798- i.doLogout(parts[1])
5799- break
5800- }
5801- // No arg: open the picker over whichever providers are
5802- // currently logged in. If nothing's logged in, bail with a
5803- // status line.
5804- i.openLogoutDialog()
5805- case "/model":
5806- if len(parts) >= 2 {
5807- i.applyModelSelection("", parts[1])
5808- } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil {
5809- i.mu.Lock()
5810- i.modelRefreshing = true
5811- i.statusOK = "Refreshing models"
5812- i.statusErr = ""
5813- i.mu.Unlock()
5814- go func() {
5815- refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
5816- defer cancel()
5817- i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)}
5818- }()
5819- } else {
5820- i.openModelPickerAfterRefresh(nil)
5821- }
5822- case "/llama":
5823- if i.cfg.LlamaCPPConfig == nil {
5824- i.mu.Lock()
5825- i.statusErr = "llama.cpp configuration is unavailable"
5826- i.statusOK = ""
5827- i.mu.Unlock()
5828- break
5829- }
5830- baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig()
5831- if configErr == nil {
5832- configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate)
5833- }
5834- if configErr != nil {
5835- i.mu.Lock()
5836- i.statusErr = configErr.Error()
5837- i.statusOK = ""
5838- i.mu.Unlock()
5839- }
5840- case "/reasoning":
5841- i.openReasoningDialog()
5842- case "/fast":
5843- enabled := i.cfg.FastMode == nil || !*i.cfg.FastMode
5844- i.applySettingToggle("fast_mode", enabled)
5845- case "/orchestrator":
5846- enabled := i.cfg.AutoSubagentsEnabled == nil || !*i.cfg.AutoSubagentsEnabled
5847- i.applySettingToggle("auto_subagents_enabled", enabled)
5848- case "/settings":
5849- i.openSettingsDialog()
5850- case "/sessions":
5851- i.mu.Lock()
5852- i.sessionLoads = i.sessionDialog.Open(ctx, i.sessionsRoot(), i.cfg.CWD)
5853- i.mu.Unlock()
5854- case "/fork":
5855- i.doSessionFork()
5856- case "/jump":
5857- i.openJumpDialog(parts[1:])
5858- case "/btw":
5859- i.openBtwDialog(parts[1:])
5860- case "/skills":
5861- i.openSkillsDialog()
5862- case "/compact":
5863- i.runCompact(ctx, compactContinuationRequest{origin: compactOriginManual})
5864- case "/study":
5865- // Canned prompt that tells the agent to read every file
5866- // in some target so its later turns have the whole thing
5867- // in context. With no argument, the target is the current
5868- // directory. With an argument, the target is whatever the
5869- // user passed — typed by hand, drag-dropped, or selected
5870- // via the @ file picker (which is why we accept both files
5871- // and directories; the @-picker chips for both have already
5872- // been expanded to absolute paths by expandFileChips above).
5873- // Dispatched through the normal queue-or-start path so it
5874- // behaves identically to typing the prompt by hand.
5875- studyPrompt := buildStudyPrompt(strings.TrimSpace(strings.TrimPrefix(cmd, parts[0])), i.cfg.CWD)
5876- i.mu.Lock()
5877- busy := i.busy
5878- compacting := i.compacting
5879- ag := i.agent
5880- i.mu.Unlock()
5881- if busy {
5882- var handoff json.RawMessage
5883- var persistHandoff bool
5884- if ag != nil && !compacting {
5885- i.mu.Lock()
5886- handoff, persistHandoff = i.resetCompactContinuationLocked()
5887- ag.QueueMessage(studyPrompt)
5888- i.mu.Unlock()
5889- } else {
5890- i.mu.Lock()
5891- handoff, persistHandoff = i.resetCompactContinuationLocked()
5892- i.queued = append(i.queued, studyPrompt)
5893- i.mu.Unlock()
5894- }
5895- if persistHandoff {
5896- i.persistCompactHandoff(handoff)
5897- }
5898- i.invalidate()
5899- break
5900- }
5901- i.startTurn(ctx, studyPrompt)
5902- case "/cd":
5903- // Hidden command: switch the running session's cwd. Not in
5904- // slash_suggest, not in /help. Used by the workspaces
5905- // extension's panel-key Enter handler so picking a row
5906- // jumps zut into that directory without relaunching.
5907- //
5908- // Recovers the raw argument (path) from the original cmd
5909- // string rather than parts, so paths with spaces survive.
5910- // The host's ChangeCWD hook handles validation, session
5911- // close + reopen, agent rebuild, sandbox re-rooting, and
5912- // re-jail-if-jailed semantics.
5913- if i.cfg.ChangeCWD == nil {
5914- i.mu.Lock()
5915- i.statusErr = "/cd unavailable: host did not wire ChangeCWD"
5916- i.mu.Unlock()
5917- break
5918- }
5919- path := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
5920- if path == "" {
5921- i.mu.Lock()
5922- i.statusErr = "/cd: missing path"
5923- i.mu.Unlock()
5924- break
5925- }
5926- if err := i.cfg.ChangeCWD(path); err != nil {
5927- i.mu.Lock()
5928- i.statusErr = "/cd: " + err.Error()
5929- i.statusOK = ""
5930- i.mu.Unlock()
5931- break
5932- }
5933- // ChangeCWD has already updated i.cfg.CWD and swapped the
5934- // agent + session. Reset transient TUI state so the new
5935- // session opens clean.
5936- i.mu.Lock()
5937- i.toolCalls = map[string]*tui.ToolCallView{}
5938- i.toolOrder = nil
5939- i.toolGate = map[string]int{}
5940- i.helpBlock = nil
5941- i.parkedTurn = 0
5942- i.statusOK = "cwd " + i.cfg.CWD
5943- i.statusErr = ""
5944- i.mu.Unlock()
5945- i.fileSuggest.Reset()
5946- i.fileSuggest.SetCWD(i.cfg.CWD)
5947- i.invalidate()
5948- case "/jail":
5949- if i.cfg.Sandbox == nil {
5950- i.mu.Lock()
5951- i.statusErr = "sandbox not available in this build"
5952- i.mu.Unlock()
5953- break
5954- }
5955- i.cfg.Sandbox.Lock()
5956- i.mu.Lock()
5957- i.statusOK = "jailed to " + i.cfg.CWD + " (tools cannot touch paths outside this directory)"
5958- i.statusErr = ""
5959- i.mu.Unlock()
5960- case "/unjail":
5961- if i.cfg.Sandbox == nil {
5962- i.mu.Lock()
5963- i.statusErr = "sandbox not available in this build"
5964- i.mu.Unlock()
5965- break
5966- }
5967- i.cfg.Sandbox.Unlock()
5968- i.mu.Lock()
5969- i.statusOK = "unjailed"
5970- i.statusErr = ""
5971- i.mu.Unlock()
5972- case "/reload-ext":
5973- i.runReloadExt(ctx)
5974- case "/telegram", "/tg":
5975- if len(parts) >= 2 {
5976- i.doTelegram(parts[1])
5977- break
5978- }
5979- i.openTelegramDialog()
5980- case "/session":
5981- if len(parts) >= 2 {
5982- action := parts[1]
5983- arg := ""
5984- if len(parts) >= 3 {
5985- arg = strings.Join(parts[2:], " ")
5986- }
5987- i.doSessionOp(action, arg)
5988- break
5989- }
5990- i.openSessionOpsDialog()
5991- case "/subagents":
5992- i.runSubagents(ctx, parts[1:])
5993- default:
5994- // Last-resort fallback: try the extension manager. Built-in
5995- // cases above always win; this branch only fires for slash
5996- // commands the extension manager registered. Same routing as
5997- // the editor's submit-handler dispatch path so the autocomplete
5998- // "enter on highlighted suggestion" flow also works.
5999- extName := strings.TrimPrefix(parts[0], "/")
6000- if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
6001- rest := ""
6002- if len(parts) > 1 {
6003- rest = strings.Join(parts[1:], " ")
6004- }
6005- go i.invokeExtensionCommand(ctx, extName, rest)
6006- return false
6007- }
6008- i.mu.Lock()
6009- i.statusErr = "unknown command: " + parts[0]
6010- i.mu.Unlock()
6011- }
6012- return false
6013-}
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.
60141584
6015-func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) {
6016- var loggedIn []string
6017- if i.cfg.LoggedInProviders != nil {
6018- loggedIn = i.cfg.LoggedInProviders()
6019- }
6020- i.modelDialog.Open(i.cfg.Model, loggedIn, i.cfg.Reasoning)
6021- i.mu.Lock()
6022- if refreshErr != nil {
6023- i.statusErr = "llama.cpp model refresh: " + refreshErr.Error()
6024- i.statusOK = ""
6025- } else if i.statusOK == "Refreshing models" {
6026- i.statusOK = ""
6027- }
6028- i.mu.Unlock()
6029-}
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.
60301594
60311595 // openLogoutDialog shows the provider picker for `/logout` with no
60321596 // argument. Only providers the user is currently logged into are
60331597 // listed, plus an "all" entry when more than one is present. If
60341598 // nothing's logged in, writes a status line instead of opening an
60351599 // empty dialog.
6036-func (i *Interactive) openLogoutDialog() {
6037- if i.cfg.AuthManager == nil {
6038- i.mu.Lock()
6039- i.statusErr = "no auth manager configured"
6040- i.mu.Unlock()
6041- i.invalidate()
6042- return
6043- }
6044- store := i.cfg.AuthManager.Store()
6045- if store == nil {
6046- i.mu.Lock()
6047- i.statusErr = "auth store is not available"
6048- i.mu.Unlock()
6049- i.invalidate()
6050- return
6051- }
6052- creds, err := store.Load()
6053- if err != nil {
6054- i.mu.Lock()
6055- i.statusErr = "read auth store: " + err.Error()
6056- i.mu.Unlock()
6057- i.invalidate()
6058- return
6059- }
6060-
6061- var items []logoutItem
6062- for _, p := range []string{"anthropic", "kimi", "google", "github-copilot"} {
6063- if creds.Has(p) {
6064- method := creds.Method(p)
6065- if method == "oauth" {
6066- method = "subscription"
6067- }
6068- items = append(items, logoutItem{
6069- label: providerLabel(p),
6070- target: p,
6071- method: method,
6072- })
6073- }
6074- }
6075- if creds.OpenAI.APIKey != "" || creds.OpenAI.APIKeyCommand != nil {
6076- items = append(items, logoutItem{label: providerLabel("openai"), target: "openai", method: "api key"})
6077- }
6078- if creds.OpenAI.OAuth != nil {
6079- items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
6080- }
6081- for p, c := range creds.AdditionalAPIKeyCreds {
6082- if c.APIKey != "" || c.APIKeyCommand != nil || c.BaseURL != "" {
6083- items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
6084- }
6085- }
6086- if len(items) == 0 {
6087- i.mu.Lock()
6088- i.statusOK = "no credentials stored; already logged out"
6089- i.statusErr = ""
6090- i.mu.Unlock()
6091- i.invalidate()
6092- return
6093- }
6094- if len(items) > 1 {
6095- items = append(items, logoutItem{label: "all", target: "all"})
6096- }
6097-
6098- i.logoutDialog.Open(items)
6099- i.invalidate()
6100-}
61011600
61021601 // doLogout clears credentials for the given provider (or all providers)
61031602 // from auth.json. If the active agent was using those credentials, it
@@ -6105,263 +1604,6 @@ func (i *Interactive) openLogoutDialog() {
61051604 // prompt.
61061605 //
61071606 // target: "anthropic" | "openai" | "kimi" | "github-copilot" | "all"
6108-func (i *Interactive) doLogout(target string) {
6109- if i.cfg.AuthManager == nil {
6110- i.mu.Lock()
6111- i.statusErr = "no auth manager configured"
6112- i.mu.Unlock()
6113- return
6114- }
6115- store := i.cfg.AuthManager.Store()
6116- if store == nil {
6117- i.mu.Lock()
6118- i.statusErr = "auth store is not available"
6119- i.mu.Unlock()
6120- return
6121- }
6122-
6123- var providers []string
6124- switch target {
6125- case "", "all":
6126- providers = append([]string{"anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot"}, auth.APIKeyProviders()...)
6127- case "anthropic", "openai", "openai-codex", "kimi", "google", "github-copilot":
6128- providers = []string{target}
6129- default:
6130- known := false
6131- for _, p := range auth.APIKeyProviders() {
6132- if target == p {
6133- known = true
6134- break
6135- }
6136- }
6137- if !known {
6138- i.mu.Lock()
6139- i.statusErr = "unknown provider: " + target
6140- i.mu.Unlock()
6141- return
6142- }
6143- providers = []string{target}
6144- }
6145-
6146- var errs []string
6147- clearedCurrent := false
6148- for _, p := range providers {
6149- var err error
6150- switch p {
6151- case "openai":
6152- err = store.ClearAPIKey("openai")
6153- case "openai-codex":
6154- err = store.ClearOAuth("openai")
6155- default:
6156- err = store.Clear(p)
6157- }
6158- if err != nil {
6159- errs = append(errs, p+": "+err.Error())
6160- continue
6161- }
6162- if p == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6163- if err := i.cfg.SetKimiCLIFallbackDisabled(true); err != nil {
6164- errs = append(errs, p+": "+err.Error())
6165- continue
6166- }
6167- }
6168- if p == i.cfg.Provider {
6169- clearedCurrent = true
6170- }
6171- }
6172-
6173- llamaConfigured := i.llamaConfigured
6174- if i.cfg.LlamaCPPConfig != nil {
6175- baseURL, _, err := i.cfg.LlamaCPPConfig()
6176- llamaConfigured = err == nil && baseURL != ""
6177- }
6178-
6179- i.mu.Lock()
6180- i.llamaConfigured = llamaConfigured
6181- if len(errs) > 0 {
6182- i.statusErr = "logout errors: " + strings.Join(errs, "; ")
6183- i.mu.Unlock()
6184- return
6185- }
6186- i.statusErr = ""
6187- var handoff json.RawMessage
6188- var persistHandoff bool
6189- if clearedCurrent {
6190- // The running agent was using a credential we just wiped. Drop
6191- // it so prompts can't go out with the stale client, and hint at
6192- // /login. Close any LSP processes owned by its tools first.
6193- if i.agent != nil {
6194- _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6195- }
6196- i.agent = nil
6197- handoff, persistHandoff = i.resetCompactContinuationLocked()
6198- i.statusOK = "logged out of " + strings.Join(providers, ", ") + ". type /login to sign back in."
6199- } else {
6200- i.statusOK = "logged out of " + strings.Join(providers, ", ")
6201- }
6202- i.mu.Unlock()
6203- if persistHandoff {
6204- i.persistCompactHandoff(handoff)
6205- }
6206-}
6207-
6208-func providerSetupInfo(provider string) (string, []string, bool) {
6209- const docsURL = "https://raw.githubusercontent.com/bnema/zut/main/docs/providers.md"
6210- switch provider {
6211- case "amazon-bedrock":
6212- return "Amazon Bedrock setup", []string{
6213- "Amazon Bedrock uses AWS credentials instead of a generic zut API-key entry.",
6214- "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
6215- "",
6216- "For Bedrock API keys, set:",
6217- " AWS_BEARER_TOKEN_BEDROCK=...",
6218- " AWS_REGION=us-east-1",
6219- "",
6220- "Docs:",
6221- " " + docsURL,
6222- }, true
6223- case "google-vertex":
6224- return "Google Vertex AI setup", []string{
6225- "Google Vertex AI usually uses Google Cloud credentials and project settings.",
6226- "Set a Google API key, application-default credentials, or a service account.",
6227- "",
6228- "Common environment:",
6229- " GOOGLE_CLOUD_API_KEY=...",
6230- " GOOGLE_CLOUD_PROJECT=...",
6231- " GOOGLE_CLOUD_LOCATION=us-central1",
6232- "",
6233- "Docs:",
6234- " " + docsURL,
6235- }, true
6236- case "cloudflare-workers-ai":
6237- return "Cloudflare Workers AI setup", []string{
6238- "Cloudflare Workers AI needs both an API token and an account ID.",
6239- "",
6240- "Set:",
6241- " CLOUDFLARE_API_KEY=...",
6242- " CLOUDFLARE_ACCOUNT_ID=...",
6243- "",
6244- "Docs:",
6245- " " + docsURL,
6246- }, true
6247- case "cloudflare-ai-gateway":
6248- return "Cloudflare AI Gateway setup", []string{
6249- "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
6250- "",
6251- "Set:",
6252- " CLOUDFLARE_API_KEY=...",
6253- " CLOUDFLARE_ACCOUNT_ID=...",
6254- " CLOUDFLARE_GATEWAY_ID=...",
6255- "",
6256- "Docs:",
6257- " " + docsURL,
6258- }, true
6259- case "azure-openai-responses":
6260- return "Azure OpenAI Responses setup", []string{
6261- "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
6262- "",
6263- "Set:",
6264- " AZURE_OPENAI_API_KEY=...",
6265- " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
6266- " AZURE_OPENAI_API_VERSION=2024-02-01",
6267- "",
6268- "Docs:",
6269- " " + docsURL,
6270- }, true
6271- default:
6272- return "", nil, false
6273- }
6274-}
6275-
6276-func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) {
6277- if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil {
6278- i.dialog.ShowResult(false, "auth store is unavailable")
6279- return
6280- }
6281- if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil {
6282- i.dialog.ShowResult(false, err.Error())
6283- return
6284- }
6285- i.mu.Lock()
6286- i.llamaConfigured = true
6287- i.statusErr = ""
6288- i.statusOK = "configured llama.cpp router"
6289- i.mu.Unlock()
6290- i.dialog.ShowResult(true, "")
6291-}
6292-
6293-func (i *Interactive) startAPIKeyFlow(provider string) {
6294- if title, lines, ok := providerSetupInfo(provider); ok {
6295- i.dialog.ShowInfo(title, lines)
6296- return
6297- }
6298- if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6299- _ = i.cfg.SetKimiCLIFallbackDisabled(false)
6300- }
6301- url, err := i.cfg.AuthManager.StartAPIKey(provider)
6302- if err != nil {
6303- i.dialog.ShowResult(false, err.Error())
6304- return
6305- }
6306- i.dialog.ShowWaiting(url)
6307-}
6308-
6309-func (i *Interactive) startOAuthFlow(provider string) {
6310- if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
6311- _ = i.cfg.SetKimiCLIFallbackDisabled(false)
6312- }
6313- // Device-code providers already support headless login and must only
6314- // start one polling flow.
6315- if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
6316- loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
6317- if err != nil {
6318- i.dialog.ShowResult(false, err.Error())
6319- return
6320- }
6321- i.dialog.ShowWaiting(loginURL)
6322- return
6323- }
6324- // Always run the manual/copy-code flow in parallel with the local
6325- // callback server so headless environments (docker, SSH) can paste
6326- // the authorization code directly without first pressing 'p'.
6327- _, err := i.cfg.AuthManager.StartOAuth(provider)
6328- if err != nil {
6329- i.dialog.ShowResult(false, err.Error())
6330- return
6331- }
6332- manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
6333- if mErr == nil {
6334- i.dialog.ShowWaiting(manualURL)
6335- } else {
6336- i.dialog.ShowResult(false, mErr.Error())
6337- }
6338-}
6339-
6340-func (i *Interactive) startManualOAuthFlow(provider string) {
6341- if i.cfg.AuthManager == nil {
6342- return
6343- }
6344- i.cfg.AuthManager.CancelOAuth()
6345- url, err := i.cfg.AuthManager.StartManualOAuth(provider)
6346- if err != nil {
6347- i.dialog.ShowResult(false, err.Error())
6348- return
6349- }
6350- i.dialog.url = url
6351- i.invalidate()
6352-}
6353-
6354-func (i *Interactive) submitManualOAuthCode(code string) {
6355- if i.cfg.AuthManager == nil {
6356- return
6357- }
6358- go func() {
6359- if err := i.cfg.AuthManager.CompleteManualOAuth(i.runCtx, code); err != nil {
6360- i.dialog.ShowResult(false, err.Error())
6361- i.invalidate()
6362- }
6363- }()
6364-}
63651607
63661608 // applyModelSelection switches the active model (and provider, if the
63671609 // new model belongs to a different one). It rebuilds the underlying
@@ -6375,541 +1617,59 @@ func (i *Interactive) submitManualOAuthCode(code string) {
63751617 // The wait is bounded; if the turn doesn't release within the timeout
63761618 // we proceed anyway. Worst case is a brief overlap that the agent's
63771619 // own mutex protects against.
6378-func (i *Interactive) cancelAndWaitForIdle() {
6379- i.mu.Lock()
6380- busy := i.busy
6381- cancel := i.cancelTurn
6382- i.mu.Unlock()
6383- if !busy {
6384- return
6385- }
6386- if cancel != nil {
6387- i.mu.Lock()
6388- handoff, persistHandoff := i.resetCompactContinuationLocked()
6389- i.mu.Unlock()
6390- cancel()
6391- if persistHandoff {
6392- i.persistCompactHandoff(handoff)
6393- }
6394- }
6395- // ConfirmToolCall waits on its response channel rather than the turn
6396- // context, so cancellation must also resolve every pending prompt.
6397- i.confirmDialog.CancelAll("turn cancelled")
6398- deadline := time.Now().Add(2 * time.Second)
6399- for time.Now().Before(deadline) {
6400- i.mu.Lock()
6401- done := !i.busy
6402- i.mu.Unlock()
6403- if done {
6404- return
6405- }
6406- time.Sleep(10 * time.Millisecond)
6407- }
6408-}
64091620
64101621 // openBtwDialog opens the side-chat overlay with a frozen snapshot
64111622 // of the current main session. The optional argument is auto-
64121623 // submitted as the first question, so '/btw does X work?' fires the
64131624 // model call immediately instead of just opening an empty dialog.
6414-func (i *Interactive) openBtwDialog(args []string) {
6415- i.agentMu.Lock()
6416- defer i.agentMu.Unlock()
6417- i.mu.Lock()
6418- ag := i.agent
6419- system := ""
6420- if ag != nil {
6421- system, _ = ag.PromptConfig()
6422- }
6423- theme := i.cfg.Theme
6424- model := i.cfg.Model
6425- cwd := i.cfg.CWD
6426- compact := i.compactModeEnabled()
6427- flatTools := i.cfg.FlatTools
6428- lineInput := tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines
6429- i.mu.Unlock()
6430- if ag == nil {
6431- i.mu.Lock()
6432- i.statusErr = "not logged in. type /login first."
6433- i.mu.Unlock()
6434- return
6435- }
6436- seed := strings.TrimSpace(strings.Join(args, " "))
6437- i.btwDialog.Open(theme, ag, system, model, cwd, seed, compact, flatTools, lineInput, i.invalidate)
6438- i.invalidate()
6439-}
64401625
64411626 // submitOrQueuePrompt submits a slash command's expanded prompt immediately,
64421627 // or queues it behind the active turn using the normal text-only queue.
6443-func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
6444- i.mu.Lock()
6445- if i.agent == nil {
6446- i.statusErr = "not logged in. type /login first."
6447- i.statusOK = ""
6448- i.mu.Unlock()
6449- i.invalidate()
6450- return
6451- }
6452- if i.busy {
6453- // Keep the mutex held while enqueueing so the turn-completion
6454- // goroutine cannot publish idle and inspect an empty queue between
6455- // this check and the append.
6456- var handoff json.RawMessage
6457- var persistHandoff bool
6458- if i.compacting {
6459- handoff, persistHandoff = i.resetCompactContinuationLocked()
6460- i.queued = append(i.queued, prompt)
6461- } else {
6462- handoff, persistHandoff = i.resetCompactContinuationLocked()
6463- i.agent.QueueMessage(prompt)
6464- }
6465- i.mu.Unlock()
6466- if persistHandoff {
6467- i.persistCompactHandoff(handoff)
6468- }
6469- i.invalidate()
6470- return
6471- }
6472- i.mu.Unlock()
6473- i.startTurn(ctx, prompt)
6474-}
64751628
64761629 // openSkillsDialog opens the skill inspector. The picker reflects
64771630 // whatever SkillSnapshot returns at call time, so edits to a
64781631 // SKILL.md made during a session show up on the next /skills.
6479-func (i *Interactive) openSkillsDialog() {
6480- var list []*skills.Skill
6481- if i.cfg.SkillSnapshot != nil {
6482- list = i.cfg.SkillSnapshot()
6483- }
6484- i.skillsDialog.Open(list)
6485- i.invalidate()
6486-}
64871632
64881633 // openJumpDialog builds a /jump picker from the current transcript.
64891634 // If the user typed "/jump foo" with a filter and it matches exactly
64901635 // one turn, jump there directly without showing the dialog.
6491-func (i *Interactive) openJumpDialog(args []string) {
6492- if i.view == nil || len(i.view.Messages) == 0 {
6493- i.mu.Lock()
6494- i.statusErr = "nothing to jump to \u2014 the session is empty"
6495- i.mu.Unlock()
6496- return
6497- }
6498- filter := strings.TrimSpace(strings.Join(args, " "))
6499- i.jumpDialog.Open(i.view.Messages, filter)
6500- // Shortcut: with a filter argument that matches exactly one turn,
6501- // jump immediately and skip the picker.
6502- if filter != "" {
6503- if tgts := i.jumpDialog.Targets(); len(tgts) == 1 {
6504- t := tgts[0]
6505- i.jumpDialog.Close()
6506- i.applyJumpSelection(t.MessageIdx, t.TurnNo)
6507- }
6508- }
6509-}
6510-
6511-// applyJumpSelection scrolls the chat viewport so the user message at
6512-// msgIdx is visible at (or near) the top of the chat area. Uses the
6513-// anchor slice returned by view.BuildWithAnchors so the mapping from
6514-// message index to row is exact, regardless of variable-height tool
6515-// blocks above the target.
6516-func (i *Interactive) applyJumpSelection(msgIdx, turnNo int) {
6517- cols := i.lastCols()
6518- chat, anchors := i.view.BuildWithAnchors(cols)
6519- var row int
6520- found := false
6521- for _, a := range anchors {
6522- if a.MessageIdx == msgIdx {
6523- row = a.Row
6524- found = true
6525- break
6526- }
6527- }
6528- if !found {
6529- i.mu.Lock()
6530- i.statusErr = "could not resolve jump target"
6531- i.mu.Unlock()
6532- return
6533- }
65341636
6535- chatLen := len(chat)
6536- page := i.chatPage()
6537- if page < 1 {
6538- page = 1
6539- }
6540- // scrollOffset is measured from the bottom of the chat slice, so
6541- // to place `row` at the top of the viewport we want:
6542- // chatLen - scrollOffset - page == row
6543- // Solve for scrollOffset and clamp to [0, chatLen-page].
6544- offset := chatLen - (row + page)
6545- if offset < 0 {
6546- offset = 0
6547- }
6548- maxOffset := chatLen - page
6549- if maxOffset < 0 {
6550- maxOffset = 0
6551- }
6552- if offset > maxOffset {
6553- offset = maxOffset
6554- }
6555-
6556- i.mu.Lock()
6557- i.scrollOffset = offset
6558- i.parkedTurn = turnNo
6559- i.parkedTotal = totalTurnsLocked(i.view.Messages)
6560- i.statusOK = fmt.Sprintf("jumped to turn %d", turnNo)
6561- i.statusErr = ""
6562- i.mu.Unlock()
6563-}
6564-
6565-// totalTurnsLocked counts user messages in the transcript. Caller is
6566-// assumed to hold i.mu (the name is a mild reminder; this function
6567-// itself doesn't touch shared state beyond the slice it's handed).
6568-func totalTurnsLocked(msgs []provider.Message) int {
6569- n := 0
6570- for _, m := range msgs {
6571- if m.Role == provider.RoleUser {
6572- n++
6573- }
6574- }
6575- return n
6576-}
6577-
6578-// applySessionSelection loads the given session via the cli-provided
6579-// callback and snaps the viewport to the bottom (the latest message)
6580-// so the user lands at the live tail of the resumed conversation.
6581-func (i *Interactive) applySessionSelection(path string) {
6582- if i.cfg.LoadSession == nil {
6583- i.mu.Lock()
6584- i.statusErr = "session loading is not wired in this build"
6585- i.mu.Unlock()
6586- return
6587- }
6588- i.mu.Lock()
6589- if i.sessionLoading {
6590- i.statusErr = "already resuming a session"
6591- i.mu.Unlock()
6592- i.invalidate()
6593- return
6594- }
6595- i.sessionLoading = true
6596- i.statusOK = "resuming session: " + path
6597- i.statusErr = ""
6598- i.mu.Unlock()
6599- i.invalidate()
6600- i.markSessionTitleSwitching()
6601-
6602- go func() {
6603- err := i.cfg.LoadSession(path)
6604- if err != nil {
6605- i.restoreFailedSessionTitle()
6606- i.mu.Lock()
6607- i.sessionLoading = false
6608- i.statusErr = err.Error()
6609- i.statusOK = ""
6610- i.mu.Unlock()
6611- i.invalidate()
6612- return
6613- }
6614- i.restoreLoadedSessionTitle()
6615- state := decodeCompactHandoff(i.currentCompactHandoff())
6616- i.mu.Lock()
6617- i.compactContinuation = state
6618- i.sessionLoading = false
6619- i.statusOK = "resumed session: " + path
6620- i.statusErr = ""
6621- i.parkedTurn = 0
6622- i.parkedTotal = 0
6623- i.scrollOffset = 0
6624- // Fresh transcript swapped in: drop the auto-follow baseline so
6625- // the next render's follow guard doesn't see the wholesale
6626- // length change as a delta and jump the viewport.
6627- i.prevChatLen = 0
6628- i.prevChatCols = 0
6629- i.extNotes = nil
6630- i.view.InvalidateRenderCache()
6631- if i.agent != nil {
6632- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
6633- i.cumUsage = i.agent.Cost()
6634- if last := i.agent.LastTurnUsage(); last.InputTokens > 0 || last.CacheReadTokens > 0 || last.CacheWriteTokens > 0 {
6635- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
6636- } else {
6637- i.lastCtxInput = 0
6638- }
6639- // Snap to the tail again — the swap brought in a fresh
6640- // transcript whose markdown / chroma cost we don't want
6641- // blocking the redraw.
6642- if len(i.view.Messages) > initialResumeTailLimit {
6643- i.view.TailLimit = initialResumeTailLimit
6644- } else {
6645- i.view.TailLimit = 0
6646- }
6647- }
6648- i.mu.Unlock()
6649- i.invalidate()
6650- if state.reason != compactContinuationNone {
6651- i.startRestoredCompactHandoff(i.runCtx)
6652- }
6653- }()
6654-}
6655-
6656-func (i *Interactive) applyModelSelection(prov, model string) {
6657- i.swapModel(prov, model, i.cfg.BuildAgentFor, false)
6658-}
6659-
6660-// applyRescueModelSelection is like applyModelSelection but routes
6661-// through BuildAgentForRescue so launch-time --api-key / --base-url
6662-// overrides are dropped before the new agent is built. Falls back to
6663-// the regular builder when the host doesn't wire a rescue builder.
6664-func (i *Interactive) applyRescueModelSelection(prov, model string) {
6665- builder := i.cfg.BuildAgentForRescue
6666- if builder == nil {
6667- builder = i.cfg.BuildAgentFor
6668- }
6669- i.swapModel(prov, model, builder, true)
6670-}
6671-
6672-// swapModel applies a /model selection (or a rescue selection) using
6673-// the supplied builder. rescue=true tags the success message so the
6674-// user can see that launch-time overrides were ignored.
6675-func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
6676- var replaced bool
6677- swap := func() {
6678- replaced = i.swapModelUnserialized(prov, model, builder, rescue)
6679- }
6680- if i.cfg.SessionTransition != nil {
6681- i.cfg.SessionTransition(swap)
6682- } else {
6683- swap()
6684- }
6685- if replaced {
6686- i.resetCompactHandoff()
6687- }
6688-}
6689-
6690-func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool {
6691- if model == "" {
6692- return false
6693- }
6694- m, err := provider.FindModel(prov, model)
6695- if err != nil {
6696- i.mu.Lock()
6697- i.statusErr = err.Error()
6698- i.mu.Unlock()
6699- return false
6700- }
6701- // Same provider AND not a rescue retry: just swap the model on
6702- // the existing agent. Mixed-API providers dispatch from model metadata,
6703- // so the client remains reusable. Rescue retries always rebuild so a stale
6704- // auth header / base URL can't carry over.
6705- if !rescue && i.agent != nil && m.Provider == i.cfg.Provider {
6706- i.mu.Lock()
6707- i.cfg.Model = m.ID
6708- i.agent.Model = m.ID
6709- i.statusOK = "model: " + m.ID
6710- i.statusErr = ""
6711- i.mu.Unlock()
6712- if i.cfg.PersistModel != nil {
6713- i.cfg.PersistModel(i.cfg.Provider, m.ID)
6714- }
6715- return false
6716- }
6717- if builder == nil {
6718- i.mu.Lock()
6719- i.statusErr = "cannot switch provider: no builder configured"
6720- i.mu.Unlock()
6721- return false
6722- }
6723- // Snapshot the current transcript and cumulative usage BEFORE we
6724- // build the replacement agent so we can hand them off. Without
6725- // this the user perceives the entire session as wiped on a
6726- // cross-provider /model swap.
6727- var carryMsgs []provider.Message
6728- var carryCost provider.Usage
6729- if i.agent != nil {
6730- carryMsgs = i.agent.Messages()
6731- carryCost = i.agent.Cost()
6732- }
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.
67331642
6734- ag, p, md, err := builder(m.Provider, m.ID)
6735- if err != nil {
6736- i.mu.Lock()
6737- i.statusErr = err.Error()
6738- i.mu.Unlock()
6739- return false
6740- }
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).
67411646
6742- // Replay the transcript and seed the cost on the freshly-built
6743- // agent. Messages travel cleanly between providers because they
6744- // use the same provider.Message shape; tool-call ids are local
6745- // to a turn so cross-provider continuation never confuses the
6746- // new model (it just sees the assistant's reply, no orphan
6747- // tool_use blocks because /model swaps are gated to idle state).
6748- if len(carryMsgs) > 0 {
6749- ag.SetMessages(carryMsgs)
6750- }
6751- 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.
67521650
6753- if i.agent != nil && i.agent != ag {
6754- _ = tools.CloseLSPManagers(i.agent.ToolsSnapshot())
6755- }
6756- i.agentMu.Lock()
6757- i.mu.Lock()
6758- i.prepareReplacementAgentLocked(ag)
6759- i.agent = ag
6760- i.cfg.Provider = p
6761- i.cfg.Model = md
6762- if rescue {
6763- i.statusOK = "rescue retry: switched to " + p + " / " + md + " (ignored --api-key / --base-url)"
6764- } else {
6765- i.statusOK = "switched to " + p + " / " + md
6766- }
6767- i.statusErr = ""
6768- // Render cache keys are width+content based, so the new agent's
6769- // identical messages will reuse the existing entries. Nothing
6770- // to invalidate.
6771- i.mu.Unlock()
6772- i.agentMu.Unlock()
6773- // The new agent was built off the base tool registry, so any
6774- // dynamically-registered tools need to be reattached. The apply
6775- // helpers are no-ops when their feature is inactive, so the
6776- // cross-provider path still works on a vanilla setup.
6777- i.applyAutoSubagentsTool()
6778- i.applyTelegramTools(i.telegramBridge != nil)
6779- if i.cfg.PersistModel != nil {
6780- i.cfg.PersistModel(p, md)
6781- }
6782- return true
6783-}
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.
67841655
6785-func (i *Interactive) handleAuthEvent(ev auth.Event) {
6786- switch ev.Kind {
6787- case "started":
6788- i.dialog.ShowWaiting(ev.URL)
6789- case "browser_open":
6790- // no-op
6791- case "error":
6792- i.dialog.ShowResult(false, ev.Message)
6793- case "success":
6794- // Keep the credential-driven resolve and its final replacement in the
6795- // same transition as settings/session changes. Otherwise a build that
6796- // started under the old web-search policy could commit afterward.
6797- var buildErr error
6798- var clearHandoff bool
6799- buildAndCommitLogin := func() {
6800- ag, prov, model, err := i.cfg.BuildAgent()
6801- if err != nil {
6802- buildErr = err
6803- return
6804- }
6805- i.agentMu.Lock()
6806- i.mu.Lock()
6807- oldAgent := i.agent
6808- i.prepareReplacementAgentLocked(ag)
6809- _, clearHandoff = i.resetCompactContinuationLocked()
6810- i.agent = ag
6811- i.cfg.Provider = prov
6812- i.cfg.Model = model
6813- i.statusErr = ""
6814- i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
6815- i.mu.Unlock()
6816- i.agentMu.Unlock()
6817- if oldAgent != nil {
6818- _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
6819- }
6820- i.applyAutoSubagentsTool()
6821- i.applyTelegramTools(i.telegramBridge != nil)
6822- // Authentication can change the provider used by the live
6823- // agent. Persist it on the active session just like /model.
6824- if i.cfg.PersistModel != nil {
6825- i.cfg.PersistModel(prov, model)
6826- }
6827- }
6828- if i.cfg.SessionTransition != nil {
6829- i.cfg.SessionTransition(buildAndCommitLogin)
6830- } else {
6831- buildAndCommitLogin()
6832- }
6833- if buildErr != nil {
6834- i.dialog.ShowResult(false, buildErr.Error())
6835- return
6836- }
6837- if clearHandoff {
6838- i.persistCompactHandoff(nil)
6839- }
6840- i.dialog.ShowResult(true, "")
6841- }
6842-}
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.
68431659
68441660 // clearPendingCompactTurnLocked drops work that only makes sense after the
68451661 // current compaction. The caller must hold i.mu.
6846-func (i *Interactive) clearPendingCompactTurnLocked() {
6847- i.pendingCompactPrompt = ""
6848- i.pendingCompactImages = nil
6849- i.hasPendingCompactPrompt = false
6850- i.continueAfterCompact = false
6851- i.pendingPostCompactNote = ""
6852-}
68531662
68541663 // compactHandoffLocked returns a self-contained persistence snapshot. The
68551664 // caller must hold i.mu.
6856-func (i *Interactive) compactHandoffLocked() json.RawMessage {
6857- return encodeCompactHandoff(i.compactContinuation)
6858-}
68591665
68601666 // setCompactContinuationLocked updates the private handoff state and returns
68611667 // its persistence snapshot plus whether persistence is needed. The caller must
68621668 // hold i.mu.
6863-func (i *Interactive) setCompactContinuationLocked(state compactContinuationState) (json.RawMessage, bool) {
6864- if i.compactContinuation == state {
6865- return i.compactHandoffLocked(), false
6866- }
6867- i.compactContinuation = state
6868- return i.compactHandoffLocked(), true
6869-}
68701669
68711670 // resetCompactContinuationLocked clears the private handoff state and returns
68721671 // its persistence snapshot plus whether persistence is needed. The caller must
68731672 // hold i.mu.
6874-func (i *Interactive) resetCompactContinuationLocked() (json.RawMessage, bool) {
6875- return i.setCompactContinuationLocked(compactContinuationState{})
6876-}
6877-
6878-func (i *Interactive) persistCompactHandoff(state json.RawMessage) {
6879- if persist := i.cfg.PersistCompactHandoff; persist != nil {
6880- if err := persist(state); err != nil {
6881- i.ReportError(fmt.Errorf("persist compact handoff: %w", err))
6882- }
6883- }
6884-}
6885-
6886-func (i *Interactive) resetCompactHandoff() {
6887- i.mu.Lock()
6888- state, persist := i.resetCompactContinuationLocked()
6889- i.mu.Unlock()
6890- if persist {
6891- i.persistCompactHandoff(state)
6892- }
6893-}
6894-
6895-func (i *Interactive) currentCompactHandoff() json.RawMessage {
6896- if current := i.cfg.CurrentCompactHandoff; current != nil {
6897- return current()
6898- }
6899- return nil
6900-}
6901-
6902-func (i *Interactive) restoreCompactHandoff(state compactContinuationState) {
6903- i.mu.Lock()
6904- i.compactContinuation = state
6905- i.mu.Unlock()
6906-}
6907-
6908-func (i *Interactive) restoreCurrentCompactHandoff() compactContinuationState {
6909- state := decodeCompactHandoff(i.currentCompactHandoff())
6910- i.restoreCompactHandoff(state)
6911- return state
6912-}
69131673
69141674 // autoCompactContinuationPrompt is an interactive-only compact-handoff
69151675 // instruction. Its completion criteria must stay separate from Ponytail so
@@ -6929,147 +1689,16 @@ const (
69291689 // explicit user message supersedes the checkpoint; tool activity remains an
69301690 // unfinished existing turn. No hidden prompt means the process stopped after
69311691 // recording the handoff but before appending its context message.
6932-func classifyCompactHandoffResume(messages []provider.Message) compactHandoffResume {
6933- lastHandoff := -1
6934- for idx := len(messages) - 1; idx >= 0; idx-- {
6935- message := messages[idx]
6936- if message.Role == provider.RoleUser && message.Meta[autoCompactContinueMetaKey] == "true" {
6937- lastHandoff = idx
6938- break
6939- }
6940- }
6941- if lastHandoff < 0 {
6942- return compactHandoffAppendPrompt
6943- }
6944- for _, message := range messages[lastHandoff+1:] {
6945- switch message.Role {
6946- case provider.RoleTool:
6947- continue
6948- case provider.RoleAssistant:
6949- hasToolCall := false
6950- for _, content := range message.Content {
6951- if _, ok := content.(provider.ToolCallBlock); ok {
6952- hasToolCall = true
6953- break
6954- }
6955- }
6956- if !hasToolCall {
6957- return compactHandoffDiscard
6958- }
6959- default:
6960- return compactHandoffDiscard
6961- }
6962- }
6963- return compactHandoffContinueExisting
6964-}
69651692
69661693 // startRestoredCompactHandoff resumes a handoff checkpoint restored from the
69671694 // active session. When its hidden context message was already persisted, the
69681695 // agent continues that existing turn rather than appending it again.
6969-func (i *Interactive) startRestoredCompactHandoff(parent context.Context) {
6970- if parent == nil {
6971- parent = i.runCtx
6972- }
6973- if parent == nil {
6974- parent = context.Background()
6975- }
6976- i.mu.Lock()
6977- state := i.compactContinuation
6978- ag := i.agent
6979- busy := i.busy || i.compacting || i.autoCompacting || i.shellRunning
6980- if state.reason == compactContinuationNone || ag == nil || busy {
6981- i.mu.Unlock()
6982- return
6983- }
6984-
6985- var next string
6986- continueQueued := false
6987- var resume compactHandoffResume
6988- var handoff json.RawMessage
6989- var persistHandoff bool
6990- switch {
6991- case state.reason != compactContinuationForcedLength && len(i.queued) > 0:
6992- next, i.queued = i.queued[0], i.queued[1:]
6993- handoff, persistHandoff = i.resetCompactContinuationLocked()
6994- case state.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0:
6995- continueQueued = true
6996- handoff, persistHandoff = i.resetCompactContinuationLocked()
6997- default:
6998- resume = classifyCompactHandoffResume(ag.Messages())
6999- if resume == compactHandoffDiscard {
7000- handoff, persistHandoff = i.resetCompactContinuationLocked()
7001- }
7002- }
7003- // Reserve the turn slot before persisting or dispatching so concurrent input
7004- // can only queue behind the restored handoff, never start a competing turn.
7005- starting := next != "" || continueQueued || resume == compactHandoffContinueExisting || resume == compactHandoffAppendPrompt
7006- i.busy = starting
7007- i.mu.Unlock()
7008- if persistHandoff {
7009- i.persistCompactHandoff(handoff)
7010- }
7011- if !starting {
7012- i.invalidate()
7013- return
7014- }
7015- switch {
7016- case next != "":
7017- i.startTurn(parent, next)
7018- case continueQueued:
7019- i.startTurnRequest(parent, "", nil, true, false)
7020- case resume == compactHandoffContinueExisting:
7021- i.startTurnRequest(parent, "", nil, true, false)
7022- case resume == compactHandoffAppendPrompt:
7023- i.startAutoCompactContinuation(parent)
7024- }
7025-}
70261696
70271697 // startAutoCompactContinuation adds an internal user turn that tells the
70281698 // model to resume the task after threshold compaction. Provider requests
70291699 // cannot continue from an assistant tail on every supported API, so the
70301700 // continuation must be represented as a user message. It is hidden from
70311701 // the rendered transcript but remains in the persisted/model context.
7032-func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
7033- i.mu.Lock()
7034- ag := i.agent
7035- if ag == nil {
7036- i.busy = false
7037- pendingIdleWork := i.takePendingIdleWorkLocked()
7038- i.mu.Unlock()
7039- runPendingIdleWork(pendingIdleWork)
7040- i.invalidate()
7041- return
7042- }
7043- // The caller selected the continuation while holding i.mu. Keep that
7044- // ordering through the hidden append so an explicitly queued user prompt
7045- // cannot be overtaken by an old-task handoff. Forced truncation is the
7046- // existing exception: it keeps priority and the queued prompt runs after it.
7047- if i.compactContinuation.reason != compactContinuationForcedLength && len(i.queued) > 0 {
7048- next := i.queued[0]
7049- i.queued = i.queued[1:]
7050- handoff, persistHandoff := i.resetCompactContinuationLocked()
7051- i.mu.Unlock()
7052- if persistHandoff {
7053- i.persistCompactHandoff(handoff)
7054- }
7055- i.startTurn(parent, next)
7056- return
7057- }
7058- if i.compactContinuation.reason != compactContinuationForcedLength && ag.QueuedMessageCount() > 0 {
7059- handoff, persistHandoff := i.resetCompactContinuationLocked()
7060- i.mu.Unlock()
7061- if persistHandoff {
7062- i.persistCompactHandoff(handoff)
7063- }
7064- i.startTurnRequest(parent, "", nil, true, false)
7065- return
7066- }
7067- ag.AppendUserContext(autoCompactContinuationPrompt, map[string]string{
7068- autoCompactContinueMetaKey: "true",
7069- })
7070- i.mu.Unlock()
7071- i.startTurnRequest(parent, "", nil, true, false)
7072-}
70731702
70741703 // runCompact invokes core.Agent.Compact and reflects the progress in
70751704 // the tui. It runs in a goroutine so the ui stays responsive; esc/ctrl+c
@@ -7080,222 +1709,12 @@ func (i *Interactive) startAutoCompactContinuation(parent context.Context) {
70801709 // percentage so it's obvious the system triggered this, not the user.
70811710 // request.force tells the post-compact hand-off to resume even if the
70821711 // transcript tail has visible assistant text, for example after StopLength.
7083-func (i *Interactive) runCompact(parent context.Context, request compactContinuationRequest) {
7084- auto := request.origin != compactOriginManual
7085- if i.agent == nil {
7086- i.mu.Lock()
7087- i.statusErr = "not logged in. type /login first."
7088- i.mu.Unlock()
7089- return
7090- }
7091- ctx, cancel := context.WithCancel(parent)
7092- i.mu.Lock()
7093- i.busy = true
7094- i.compacting = true
7095- i.spin.Start()
7096- i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
7097- if auto {
7098- i.activity.kind = activityCondensingHistory
7099- i.autoCompacting = true
7100- } else {
7101- i.activity.kind = activityCompactingHistory
7102- }
7103- i.cancelTurn = cancel
7104- var initialHandoff json.RawMessage
7105- persistInitialHandoff := false
7106- if request.origin == compactOriginManual || request.origin == compactOriginRecovery {
7107- initialHandoff, persistInitialHandoff = i.resetCompactContinuationLocked()
7108- }
7109- i.statusErr = ""
7110- i.statusOK = ""
7111- // Do NOT set streamOn: the summary text should not be visible
7112- // in the chat while compacting. The user just sees the spinner
7113- // and can keep typing / queue prompts.
7114- i.scrollOffset = 0
7115- i.helpBlock = nil
7116- i.mu.Unlock()
7117- if persistInitialHandoff {
7118- i.persistCompactHandoff(initialHandoff)
7119- }
7120- i.invalidate()
7121-
7122- go func() {
7123- // Sink discards deltas — we don't stream the summary to the UI.
7124- sink := func(delta string) {}
7125- msgsBefore := i.agent.Messages()
7126- i.mu.Lock()
7127- statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7128- i.mu.Unlock()
7129- continuationReason := classifyCompactionContinuation(request.origin, statusRescueActive, request.lastStop, request.turnError, msgsBefore)
7130- if request.force {
7131- continuationReason = compactContinuationForcedLength
7132- }
7133- // Keep the usual recent tail when possible, but never let it cover
7134- // the whole transcript. A short session can still be at 70–90% of
7135- // a model's window because one prompt or tool result is large; the
7136- // automatic path must summarize that session instead of failing
7137- // before it reaches the compaction provider.
7138- keepTail := 4
7139- if n := len(msgsBefore); n > 0 && keepTail >= n {
7140- keepTail = n - 1
7141- }
7142- summary, err := i.agent.Compact(ctx, keepTail, sink)
7143- _ = summary
7144- goalMessage, goalActive := i.goalContinuationMessage()
7145- i.mu.Lock()
7146- // Keep busy/compacting asserted while cleanup and queue selection run.
7147- // Completion updates can arrive in this window; clearing busy before
7148- // inspecting the queues lets them race a new turn or be stranded.
7149- i.resetStreamingStateLocked()
7150- i.cancelTurn = nil
7151- i.autoCompacting = false
7152- pendingIdleWork := i.takePendingIdleWorkLocked()
7153-
7154- // Drain pending work after the transcript is clean. Overflow recovery
7155- // continues the user message already in the transcript. Pre-turn
7156- // compaction preserves its complete text-and-images request. Regular
7157- // prompts typed during compaction remain in the host queue.
7158- var next string
7159- var nextImages []provider.ImageBlock
7160- var hasNext bool
7161- var continueExisting bool
7162- var continueAutomatically bool
7163- var continueGoal bool
7164- var handoff json.RawMessage
7165- var persistHandoff bool
7166-
7167- switch {
7168- case err != nil && ctx.Err() != nil:
7169- i.statusErr = ""
7170- if auto {
7171- i.statusOK = "auto-condense cancelled"
7172- } else {
7173- i.statusOK = "compaction cancelled"
7174- }
7175- i.queued = nil // drop queue on cancel
7176- i.clearPendingCompactTurnLocked()
7177- handoff, persistHandoff = i.resetCompactContinuationLocked()
7178- if i.agent != nil {
7179- i.agent.DrainQueuedMessages()
7180- }
7181- case err != nil:
7182- i.statusErr = "compaction failed: " + err.Error()
7183- i.statusOK = ""
7184- i.queued = nil // drop queue on error
7185- i.clearPendingCompactTurnLocked()
7186- handoff, persistHandoff = i.resetCompactContinuationLocked()
7187- if i.agent != nil {
7188- i.agent.DrainQueuedMessages()
7189- }
7190- default:
7191- i.statusErr = ""
7192- // Read token count from the compaction message meta.
7193- tokens := ""
7194- msgs := i.agent.Messages()
7195- if len(msgs) > 0 && msgs[0].Meta["compaction"] == "true" {
7196- tokens = msgs[0].Meta["tokens_before"]
7197- }
7198- switch {
7199- case i.pendingPostCompactNote != "":
7200- i.statusOK = i.pendingPostCompactNote
7201- case tokens != "":
7202- i.statusOK = fmt.Sprintf("compacted from ~%s tokens (ctrl+o to expand)", tokens)
7203- default:
7204- i.statusOK = "compacted (ctrl+o to expand)"
7205- }
7206- i.pendingPostCompactNote = ""
7207- i.extNotes = stripAutoCompactNotes(i.extNotes)
7208- i.lastCtxInput = 0
7209- i.toolCalls = map[string]*tui.ToolCallView{}
7210- i.toolOrder = nil
7211- i.toolGate = map[string]int{}
7212- i.resetTranscriptRenderLocked()
7213- switch {
7214- case i.continueAfterCompact:
7215- continueExisting = true
7216- i.continueAfterCompact = false
7217- case i.hasPendingCompactPrompt:
7218- next = i.pendingCompactPrompt
7219- nextImages = i.pendingCompactImages
7220- i.pendingCompactPrompt = ""
7221- i.pendingCompactImages = nil
7222- i.hasPendingCompactPrompt = false
7223- hasNext = true
7224- case continuationReason == compactContinuationForcedLength:
7225- // Forced truncated-output continuation keeps its existing
7226- // priority over an explicitly queued prompt.
7227- handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7228- continueAutomatically = true
7229- case len(i.queued) > 0:
7230- next, i.queued = i.queued[0], i.queued[1:]
7231- hasNext = true
7232- handoff, persistHandoff = i.resetCompactContinuationLocked()
7233- case continuationReason == compactContinuationStructuralTail || continuationReason == compactContinuationStatusRescue:
7234- if continuationReason == compactContinuationStatusRescue {
7235- attempts := 1
7236- if i.compactContinuation.reason == compactContinuationStatusRescue {
7237- attempts = i.compactContinuation.rescueAttempts + 1
7238- }
7239- if attempts > maxStatusRescueContinuations {
7240- handoff, persistHandoff = i.resetCompactContinuationLocked()
7241- break
7242- }
7243- handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7244- reason: continuationReason,
7245- rescueAttempts: attempts,
7246- })
7247- } else {
7248- handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7249- }
7250- continueAutomatically = true
7251- case auto && goalActive && !i.coordinatorHasPendingWorkers():
7252- continueGoal = true
7253- handoff, persistHandoff = i.resetCompactContinuationLocked()
7254- default:
7255- handoff, persistHandoff = i.resetCompactContinuationLocked()
7256- }
7257- }
7258- // Keep the host busy until the hand-off decision is committed under
7259- // the mutex. A completion update arriving after the compaction result
7260- // but before this assignment is then queued for the selected follow-up
7261- // instead of starting a competing turn.
7262- i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
7263- i.compacting = false
7264- i.autoCompacting = false
7265- i.mu.Unlock()
7266- if persistHandoff {
7267- i.persistCompactHandoff(handoff)
7268- }
7269- runPendingIdleWork(pendingIdleWork)
7270- i.invalidate()
7271-
7272- if hasNext || continueExisting || continueAutomatically || continueGoal {
7273- p := i.runCtx
7274- if p == nil {
7275- p = context.Background()
7276- }
7277- switch {
7278- case continueExisting:
7279- i.startTurnRequest(p, "", nil, true, true)
7280- case hasNext:
7281- i.startTurnWithImages(p, next, nextImages)
7282- case continueAutomatically:
7283- i.startAutoCompactContinuation(p)
7284- case continueGoal:
7285- i.startGoalContinuation(p, goalMessage)
7286- }
7287- }
7288- }()
7289-}
72901712
72911713 // shellEscapeCommand reports whether text is a "!command" shell
72921714 // escape and, if so, returns the command with the leading '!' (and
72931715 // surrounding whitespace) stripped. A bare "!" with no command is
72941716 // treated as not an escape so it falls through to the normal prompt
72951717 // path rather than running an empty shell.
7296-func shellEscapeCommand(text string) (string, bool) {
7297- return ShellEscapeCommand(text)
7298-}
72991718
73001719 // ShellEscapeCommand reports whether text is a "!command" shell
73011720 // escape and, if so, returns the command with the leading '!' (and
@@ -7304,129 +1723,12 @@ func shellEscapeCommand(text string) (string, bool) {
73041723 // path rather than running an empty shell.
73051724 const ShellEscapeTimeoutSeconds = 600
73061725
7307-func ShellEscapeCommand(text string) (string, bool) {
7308- trimmed := strings.TrimLeft(text, " \t")
7309- if !strings.HasPrefix(trimmed, "!") {
7310- return "", false
7311- }
7312- cmd := strings.TrimSpace(strings.TrimPrefix(trimmed, "!"))
7313- if cmd == "" {
7314- return "", false
7315- }
7316- return cmd, true
7317-}
7318-
73191726 // startShellEscape runs a "!command" in the same shell the bash tool
73201727 // uses, in the session working directory, honoring the /jail sandbox.
73211728 // It shares the busy/cancel state with the agent: esc cancels it, and
73221729 // it refuses to start while a turn or another shell escape is already
73231730 // in flight. Its terminal-log output is appended to the transcript as
73241731 // user context without automatically starting a model turn.
7325-func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
7326- i.mu.Lock()
7327- if i.busy || i.shellRunning {
7328- i.statusErr = "busy — wait for the current turn to finish before running a shell command"
7329- i.statusOK = ""
7330- i.mu.Unlock()
7331- i.invalidate()
7332- return
7333- }
7334- if parent == nil {
7335- parent = i.runCtx
7336- }
7337- if parent == nil {
7338- parent = context.Background()
7339- }
7340- ctx, cancel := context.WithCancel(parent)
7341- i.busy = true
7342- i.shellRunning = true
7343- i.shellLive = "$ " + cmd + "\n\n"
7344- i.cancelTurn = cancel
7345- i.statusErr = ""
7346- i.statusOK = ""
7347- i.spin.Start()
7348- i.activity = agentActivity{activity: activity{kind: activityRunningShellCommand}}
7349- // Clear stale extension notes the same way a new turn would so the
7350- // screen doesn't accumulate transient state.
7351- i.extNotes = nil
7352- i.scrollOffset = 0
7353- i.parkedTurn = 0
7354- i.parkedTotal = 0
7355- i.helpBlock = nil
7356- sandbox := i.cfg.Sandbox
7357- cwd := i.cfg.CWD
7358- i.mu.Unlock()
7359- i.invalidate()
7360-
7361- go func() {
7362- defer cancel()
7363- raw, _ := json.Marshal(map[string]any{
7364- "command": cmd,
7365- "timeout": ShellEscapeTimeoutSeconds,
7366- })
7367- bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
7368- progress := func(chunk string) {
7369- i.mu.Lock()
7370- i.shellLive += chunk
7371- i.mu.Unlock()
7372- i.invalidate()
7373- }
7374- res, err := bash.Execute(ctx, raw, progress)
7375-
7376- var out string
7377- if err != nil {
7378- out = "$ " + cmd + "\n\n" + err.Error() + "\n\n[error]"
7379- } else {
7380- for _, c := range res.Content {
7381- if tb, ok := c.(provider.TextBlock); ok {
7382- out += tb.Text
7383- }
7384- }
7385- }
7386- cancelled := ctx.Err() != nil
7387- failed := err != nil || res.IsError || cancelled
7388- if cancelled {
7389- out += "\n\n[cancelled]"
7390- }
7391-
7392- if i.agent != nil {
7393- i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
7394- }
7395-
7396- i.mu.Lock()
7397- i.shellRunning = false
7398- i.shellLive = ""
7399- i.busy = false
7400- i.cancelTurn = nil
7401- pendingIdleWork := i.takePendingIdleWorkLocked()
7402- awaitingPre := i.awaitingStartupPre
7403- if failed {
7404- if cancelled {
7405- i.statusErr = "shell command cancelled"
7406- } else {
7407- i.statusErr = "shell command failed"
7408- }
7409- i.statusOK = ""
7410- } else {
7411- i.statusOK = "shell command finished"
7412- i.statusErr = ""
7413- }
7414- i.mu.Unlock()
7415- runPendingIdleWork(pendingIdleWork)
7416- i.invalidate()
7417- if awaitingPre {
7418- i.completeStartupPre()
7419- }
7420- }()
7421-}
7422-
7423-func (i *Interactive) startTurn(parent context.Context, prompt string) {
7424- i.startTurnWithImages(parent, prompt, nil)
7425-}
7426-
7427-func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, images []provider.ImageBlock) {
7428- i.startTurnRequest(parent, prompt, images, false, false)
7429-}
74301732
74311733 // startTurnRequest starts a new prompt or continues an existing user turn.
74321734 // continueExisting selects Agent.Continue so callers can retry a transcript
@@ -7434,313 +1736,6 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
74341736 // the pre-turn threshold guard and the one-shot overflow recovery path; a
74351737 // normal post-compaction continuation leaves it false so a still-too-large
74361738 // rebuilt context can recover once more.
7437-func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, continueExisting, overflowRecoveryAttempted bool) {
7438- if i.agent == nil {
7439- // Text startup pre cannot run without credentials; continue so
7440- // deferred InitialInput (pre-fill or auto-submit) still applies.
7441- i.mu.Lock()
7442- awaitingPre := i.awaitingStartupPre
7443- i.mu.Unlock()
7444- if awaitingPre {
7445- i.completeStartupPre()
7446- }
7447- return
7448- }
7449- // Pre-turn safety: if the most recent context measurement is
7450- // already past the auto-compact threshold, condense before
7451- // sending so the next outbound request stays under the limit.
7452- // The condense flow re-fires the user's queued prompt for us, so
7453- // we just hand it off and exit.
7454- i.mu.Lock()
7455- var resetHandoff json.RawMessage
7456- var persistResetHandoff bool
7457- if !continueExisting {
7458- resetHandoff, persistResetHandoff = i.resetCompactContinuationLocked()
7459- }
7460- needsPreCompact := !overflowRecoveryAttempted && !i.autoCompacting && i.shouldAutoCompactLocked()
7461- if needsPreCompact {
7462- // Reserve the compaction hand-off before releasing i.mu. A completion
7463- // update arriving in the small gap before runCompact acquires the
7464- // mutex must join the host queue rather than starting a competing turn.
7465- i.busy = true
7466- i.compacting = true
7467- i.autoCompacting = true
7468- if continueExisting {
7469- i.continueAfterCompact = true
7470- } else {
7471- i.pendingCompactPrompt = prompt
7472- i.pendingCompactImages = append([]provider.ImageBlock(nil), images...)
7473- i.hasPendingCompactPrompt = true
7474- }
7475- i.statusErr = ""
7476- i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "context near limit — condensing history before sending..."))
7477- i.pendingPostCompactNote = "context auto-compacted; sending your last message"
7478- i.mu.Unlock()
7479- if persistResetHandoff {
7480- i.persistCompactHandoff(resetHandoff)
7481- }
7482- i.invalidate()
7483- i.runCompact(parent, compactContinuationRequest{origin: compactOriginPreTurnThreshold})
7484- return
7485- }
7486- i.mu.Unlock()
7487- if persistResetHandoff {
7488- i.persistCompactHandoff(resetHandoff)
7489- }
7490-
7491- ctx, cancel := context.WithCancel(parent)
7492- i.mu.Lock()
7493- i.busy = true
7494- i.spin.Start()
7495- i.activity = newAgentActivity(i.cfg.Provider, i.cfg.Model)
7496- i.cancelTurn = cancel
7497- i.statusErr = ""
7498- i.statusOK = ""
7499- i.streaming.Reset()
7500- i.streamOn = true
7501- i.pendingAlert = nil
7502- i.toolCalls = map[string]*tui.ToolCallView{}
7503- i.toolOrder = nil
7504- i.toolGate = map[string]int{}
7505- i.extNotes = nil // ext notes are one-shot; a new prompt clears them
7506- i.scrollOffset = 0 // jump back to the bottom on new turn
7507- // Lift the resume tail cap once the user starts interacting. The
7508- // cap is purely a first-paint optimization (don't markdown the
7509- // whole history before showing anything). Keeping it active during
7510- // a turn makes the rendered chat a sliding window: appended
7511- // messages push older ones off the TOP of the buffer, which the
7512- // renderer must treat as a change above the viewport and repaint
7513- // fully, snapping the terminal's native scrollback to the bottom on
7514- // every streamed chunk. A fresh session has no cap (append-only),
7515- // which is why the jump only shows up in resumed sessions. Dropping
7516- // the cap here makes resumed turns append-only too.
7517- i.view.TailLimit = 0
7518- // Reset the auto-follow baseline so the very next render at
7519- // interactive.go:1053 doesn't see a synthetic shrink between
7520- // "last frame had the previous turn's tool overlay" and
7521- // "this frame had it cleared above". Without this, the guard
7522- // reads delta = -(rows in cleared overlay) and decrements
7523- // scrollOffset, which on terminals that mirror zut's pane
7524- // scroll into the host scrollbar visibly yanks the viewport.
7525- // See autofollow_shrink_test.go for the exact arithmetic.
7526- i.prevChatLen = 0
7527- i.prevChatCols = 0
7528- i.parkedTurn = 0 // starting a turn clears the /jump parked state
7529- i.parkedTotal = 0
7530- i.helpBlock = nil // hide the help block once the user asks something
7531- i.mu.Unlock()
7532- i.invalidate()
7533-
7534- var (
7535- lastStop provider.StopReason
7536- lastTurnErr error
7537- )
7538- sink := func(ev core.AgentEvent) {
7539- if e, ok := ev.(core.EvTurnEnd); ok {
7540- lastStop = e.Stop
7541- lastTurnErr = e.Err
7542- }
7543- i.handleEventForPresentation(ev)
7544- }
7545-
7546- releaseCompletionHold := i.beginCompletionDeliveryHold()
7547- go func() {
7548- defer releaseCompletionHold()
7549- var err error
7550- if continueExisting {
7551- err = i.agent.Continue(ctx, sink)
7552- } else {
7553- err = i.agent.Prompt(ctx, prompt, images, sink)
7554- }
7555- i.mu.Lock()
7556- // Keep busy asserted through final cleanup and queue selection. A
7557- // watcher may enqueue a completion summary concurrently with the
7558- // provider's final event; publishing idle before inspecting queues can
7559- // strand that summary in the core agent queue or start an overlapping
7560- // turn.
7561- // Don't touch streamPending / streamFlushPending here — the
7562- // pacer may still be draining the final deltas and needs to
7563- // paint them even though Prompt has returned. It will reset
7564- // streamOn on its own once the buffer empties.
7565- if len(i.streamPending) == 0 {
7566- i.streamOn = false
7567- }
7568- i.cancelTurn = nil
7569- pendingIdleWork := i.takePendingIdleWorkLocked()
7570- if err != nil && ctx.Err() == nil {
7571- i.statusErr = err.Error()
7572- }
7573- // Decide whether to offer a model rescue picker for recoverable
7574- // provider failures (auth/rate/temporary). The picker opens after
7575- // the mutex is released so it can take its own locks freely.
7576- var (
7577- offer bool
7578- rescueWhy string
7579- rescueImgs []provider.ImageBlock
7580- rescueModel string
7581- rescueProv string
7582- rescueFprov string
7583- )
7584- if err != nil && ctx.Err() == nil {
7585- if ok, reason := classifyRescueError(err); ok {
7586- offer = true
7587- rescueWhy = reason
7588- rescueImgs = images
7589- rescueModel = i.cfg.Model
7590- rescueProv = i.cfg.Provider
7591- rescueFprov = extractFailedProvider(err)
7592- if rescueFprov == "" {
7593- rescueFprov = i.cfg.Provider
7594- }
7595- // Suppress the red banner — the rescue dialog already
7596- // surfaces the failure.
7597- i.statusErr = ""
7598- }
7599- }
7600- // Detect responses that reject the current context, either as an
7601- // HTTP 413 payload limit or a model context-window error. Token-
7602- // based auto-compact can miss both when metadata is stale or the
7603- // limit is measured in raw bytes. Compact once, then continue the
7604- // user message already present in the transcript.
7605- contextOverflow := err != nil && ctx.Err() == nil && provider.IsContextOverflowError(err)
7606- recoverContextOverflow := contextOverflow && !overflowRecoveryAttempted
7607- if recoverContextOverflow {
7608- i.statusErr = ""
7609- i.continueAfterCompact = true
7610- i.extNotes = append(i.extNotes, autoCompactNoteLine(i.cfg.Theme, "request was too large. condensing history before retrying ..."))
7611- i.pendingPostCompactNote = "context auto-compacted; retrying your last message"
7612- }
7613- // Persist the assistant's reply (and every tool row before
7614- // it) to the session file while the turn memory is hot.
7615- // Without this, WriteNewTranscript only fires at zut exit,
7616- // meaning a crash or ungraceful kill drops the whole
7617- // conversation. FlushSession is idempotent (it advances the
7618- // baseline so subsequent flushes only write new rows).
7619- flush := i.cfg.FlushSession
7620- i.mu.Unlock()
7621- runPendingIdleWork(pendingIdleWork)
7622- if flush != nil {
7623- flush()
7624- }
7625- terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
7626- if terminalGoalError {
7627- i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
7628- }
7629- i.mu.Lock()
7630- awaitingPre := i.awaitingStartupPre
7631- // A newer explicit prompt may have cleared the handoff while the
7632- // completed turn was being flushed. Re-read it under the mutex before
7633- // deciding whether this continuation may spend another rescue attempt.
7634- statusRescueActive := i.compactContinuation.reason == compactContinuationStatusRescue
7635- // Pop the next queued message, if any, and relaunch.
7636- var next string
7637- var hasNext bool
7638- if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
7639- next, i.queued = i.queued[0], i.queued[1:]
7640- hasNext = true
7641- }
7642- // If the turn was cancelled or errored, drop the queue so the
7643- // user isn't bombarded with stale messages after an interrupt.
7644- if ctx.Err() != nil || (err != nil && !recoverContextOverflow) {
7645- i.queued = nil
7646- if i.agent != nil {
7647- i.agent.DrainQueuedMessages()
7648- }
7649- }
7650- // Decide whether the next thing to do is an auto-compaction.
7651- // Only fires when the turn completed cleanly AND no host-side
7652- // or agent-side queued messages are waiting (otherwise a queued
7653- // message would race the condense).
7654- agentQueued := 0
7655- if i.agent != nil {
7656- agentQueued = i.agent.QueuedMessageCount()
7657- }
7658- continueQueued := !awaitingPre && !hasNext && agentQueued > 0 && err == nil && ctx.Err() == nil
7659- shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
7660- continueStatusRescue := false
7661- var handoff json.RawMessage
7662- var persistHandoff bool
7663- if statusRescueActive && i.agent != nil && !awaitingPre && !hasNext && !continueQueued && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil {
7664- followUpMessages := i.agent.Messages()
7665- reason := classifyCompactionContinuation(compactOriginManual, true, lastStop, lastTurnErr, followUpMessages)
7666- if reason == compactContinuationStatusRescue && i.compactContinuation.rescueAttempts < maxStatusRescueContinuations {
7667- handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{
7668- reason: compactContinuationStatusRescue,
7669- rescueAttempts: i.compactContinuation.rescueAttempts + 1,
7670- })
7671- continueStatusRescue = true
7672- } else {
7673- handoff, persistHandoff = i.resetCompactContinuationLocked()
7674- }
7675- }
7676- if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7677- handoff, persistHandoff = i.resetCompactContinuationLocked()
7678- }
7679- // The agent run can finish before the paced final text reaches the
7680- // transcript. A compaction replaces that transcript, so it must never
7681- // race the still-live stream frame; otherwise stale deltas can repaint
7682- // after the replacement and corrupt the scrollback renderer's model.
7683- if recoverContextOverflow || shouldAutoCompact {
7684- i.resetStreamingStateLocked()
7685- }
7686- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7687- i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7688- i.mu.Unlock()
7689- if persistHandoff {
7690- i.persistCompactHandoff(handoff)
7691- }
7692- if alertReason != "" {
7693- i.scheduleMainAlert(alertReason)
7694- }
7695- i.invalidate()
7696- parent := i.runCtx
7697- if parent == nil {
7698- parent = context.Background()
7699- }
7700- if awaitingPre {
7701- i.completeStartupPre()
7702- return
7703- }
7704- switch {
7705- case hasNext:
7706- i.startTurn(parent, next)
7707- case continueQueued:
7708- i.startTurnRequest(parent, "", nil, true, false)
7709- case continueStatusRescue:
7710- i.startAutoCompactContinuation(parent)
7711- case offer:
7712- i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7713- case recoverContextOverflow:
7714- i.runCompact(parent, compactContinuationRequest{origin: compactOriginRecovery})
7715- case shouldAutoCompact:
7716- i.runCompact(parent, compactContinuationRequest{
7717- origin: compactOriginAfterTurnThreshold,
7718- force: lastStop == provider.StopLength,
7719- lastStop: lastStop,
7720- turnError: lastTurnErr,
7721- })
7722- }
7723- }()
7724-}
7725-
7726-func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.StopReason, awaitingPre, hasNext, rescue, recovering, autoCompacting bool) string {
7727- if awaitingPre || ctx.Err() != nil || hasNext || recovering || autoCompacting {
7728- return ""
7729- }
7730- if rescue {
7731- return "rescue_required"
7732- }
7733- if stop == provider.StopAborted {
7734- return ""
7735- }
7736- if err != nil || turnErr != nil || stop == provider.StopError {
7737- return "agent_error"
7738- }
7739- if stop == provider.StopLength {
7740- return "response_truncated"
7741- }
7742- return "agent_done"
7743-}
77441739
77451740 // openRescueDialog surfaces the rescue model picker after a recoverable
77461741 // provider failure. The pending prompt + images are stashed on the
@@ -7748,546 +1743,61 @@ func mainAlertReason(ctx context.Context, err, turnErr error, stop provider.Stop
77481743 // against the freshly-picked model. activeProvider/failedProvider are
77491744 // usually the same, but some clients embed different prefixes in their
77501745 // errors than the configured provider id, so we accept both.
7751-func (i *Interactive) openRescueDialog(activeProvider, failedProvider, failedModel, reason, prompt string, images []provider.ImageBlock) {
7752- if i.rescueDialog == nil {
7753- return
7754- }
7755- loggedIn := []string{}
7756- if i.cfg.LoggedInProviders != nil {
7757- loggedIn = i.cfg.LoggedInProviders()
7758- }
7759- fprov := failedProvider
7760- if fprov == "" {
7761- fprov = activeProvider
7762- }
7763- i.mu.Lock()
7764- i.pendingRescuePrompt = prompt
7765- i.pendingRescueImages = images
7766- i.mu.Unlock()
7767- i.rescueDialog.Open(failedModel, loggedIn, fprov, failedModel, reason, prompt)
7768- i.invalidate()
7769-}
77701746
77711747 // applyRescueSelection switches model (cross-provider if needed) and
77721748 // re-runs the same prompt+images that just failed. Mirrors
77731749 // applyModelSelection's transcript-carry logic so the user keeps full
77741750 // session continuity across the swap.
7775-func (i *Interactive) applyRescueSelection(prov, model, prompt string) {
7776- if model == "" {
7777- return
7778- }
7779- i.applyRescueModelSelection(prov, model)
7780- i.mu.Lock()
7781- images := i.pendingRescueImages
7782- if prompt == "" {
7783- prompt = i.pendingRescuePrompt
7784- }
7785- i.pendingRescuePrompt = ""
7786- i.pendingRescueImages = nil
7787- i.mu.Unlock()
7788- parent := i.runCtx
7789- if parent == nil {
7790- parent = context.Background()
7791- }
7792- i.startTurnWithImages(parent, prompt, images)
7793-}
7794-
7795-func stripAutoCompactNotes(notes []string) []string {
7796- if len(notes) == 0 {
7797- return notes
7798- }
7799- out := notes[:0]
7800- for _, n := range notes {
7801- if strings.Contains(n, "condensing history") {
7802- continue
7803- }
7804- out = append(out, n)
7805- }
7806- return out
7807-}
78081751
78091752 // autoCompactNoteLine returns a styled chat-area note for the
78101753 // inline auto-compact heads-up. Lives in extNotes so it survives
78111754 // the busy-spinner overwrite of the status row.
7812-func autoCompactNoteLine(th tui.Theme, msg string) string {
7813- return " " + th.FGColor(th.Warning, "⚠ "+msg)
7814-}
78151755
78161756 // NormalizeAutoCompactThreshold applies the persisted auto-compaction
78171757 // setting's supported values and default. It is exported so non-interactive
78181758 // hosts can share the interactive mode's exact configuration semantics.
7819-func NormalizeAutoCompactThreshold(threshold *int) int {
7820- const defaultAutoCompactThreshold = 85
7821- if threshold == nil {
7822- return defaultAutoCompactThreshold
7823- }
7824- switch *threshold {
7825- case 0, 70, 80, 85, 90, 95:
7826- return *threshold
7827- default:
7828- return defaultAutoCompactThreshold
7829- }
7830-}
78311759
78321760 // ShouldAutoCompact reports whether the latest request context reached the
78331761 // configured percentage of the model's advertised context window.
7834-func ShouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool {
7835- if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 {
7836- return false
7837- }
7838- return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100
7839-}
78401762
78411763 // classifyCompactionContinuation chooses a private handoff reason from the
78421764 // successful turn tail. Structural unfinished-tail handling is independent of
78431765 // the status matcher. A status rescue is admitted only for an after-turn
78441766 // threshold compaction or an already-active status handoff, and only after a
78451767 // normal successful end.
7846-func classifyCompactionContinuation(origin compactContinuationOrigin, statusRescueActive bool, stop provider.StopReason, turnErr error, msgs []provider.Message) compactContinuationReason {
7847- if origin != compactOriginManual && structuralCompactionContinuation(msgs) {
7848- return compactContinuationStructuralTail
7849- }
7850- if (origin != compactOriginAfterTurnThreshold && !statusRescueActive) || stop != provider.StopEnd || turnErr != nil {
7851- return compactContinuationNone
7852- }
7853- if likelyForwardWorkStatus(msgs) {
7854- return compactContinuationStatusRescue
7855- }
7856- return compactContinuationNone
7857-}
78581768
7859-// structuralCompactionContinuation preserves the pre-existing unfinished-tail
7860-// behavior without consulting the status matcher.
7861-func structuralCompactionContinuation(msgs []provider.Message) bool {
7862- if len(msgs) == 0 {
7863- return false
7864- }
7865- last := msgs[len(msgs)-1]
7866- if last.Role != provider.RoleAssistant {
7867- return true
7868- }
7869- hasText := false
7870- for _, content := range last.Content {
7871- switch block := content.(type) {
7872- case provider.ToolCallBlock:
7873- return true
7874- case provider.TextBlock:
7875- if strings.TrimSpace(block.Text) != "" {
7876- hasText = true
7877- }
7878- case provider.ReasoningBlock:
7879- // Reasoning without a visible answer is not terminal.
7880- default:
7881- return true
7882- }
7883- }
7884- return !hasText
7885-}
1769+// structuralCompactionContinuation preserves the pre-existing unfinished-tail
1770+// behavior without consulting the status matcher.
78861771
78871772 // likelyForwardWorkStatus is deliberately narrow. It recognizes only clear
78881773 // first-person future-work commitments and never treats generic plan prose as
78891774 // a host control signal.
7890-func likelyForwardWorkStatus(msgs []provider.Message) bool {
7891- if len(msgs) == 0 {
7892- return false
7893- }
7894- last := msgs[len(msgs)-1]
7895- if last.Role != provider.RoleAssistant {
7896- return false
7897- }
7898- var text strings.Builder
7899- for _, content := range last.Content {
7900- block, ok := content.(provider.TextBlock)
7901- if !ok {
7902- if _, hasTool := content.(provider.ToolCallBlock); hasTool {
7903- return false
7904- }
7905- continue
7906- }
7907- if strings.TrimSpace(block.Text) == "" {
7908- continue
7909- }
7910- if text.Len() > 0 {
7911- text.WriteByte(' ')
7912- }
7913- text.WriteString(block.Text)
7914- }
7915- if text.Len() == 0 {
7916- return false
7917- }
7918- normalized := strings.ToLower(strings.Join(strings.Fields(text.String()), " "))
7919- normalized = strings.NewReplacer("’", "'", "‘", "'", "`", "'").Replace(normalized)
7920- for _, phrase := range []string{
7921- "next i will",
7922- "next, i will",
7923- "i'll now",
7924- "i will now",
7925- "i am going to",
7926- "i need to continue",
7927- "then i will",
7928- "then, i will",
7929- "i will proceed",
7930- } {
7931- if containsFutureWorkPhrase(normalized, phrase) {
7932- return true
7933- }
7934- }
7935- return false
7936-}
7937-
7938-func containsFutureWorkPhrase(text, phrase string) bool {
7939- for offset := 0; offset < len(text); {
7940- index := strings.Index(text[offset:], phrase)
7941- if index < 0 {
7942- return false
7943- }
7944- index += offset
7945- end := index + len(phrase)
7946- if !wordRuneBefore(text, index) && !wordRuneAfter(text, end) {
7947- return true
7948- }
7949- offset = end
7950- }
7951- return false
7952-}
7953-
7954-func wordRuneBefore(text string, index int) bool {
7955- if index == 0 {
7956- return false
7957- }
7958- rune_, _ := utf8.DecodeLastRuneInString(text[:index])
7959- return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
7960-}
7961-
7962-func wordRuneAfter(text string, index int) bool {
7963- if index == len(text) {
7964- return false
7965- }
7966- rune_, _ := utf8.DecodeRuneInString(text[index:])
7967- return unicode.IsLetter(rune_) || unicode.IsDigit(rune_) || rune_ == '_'
7968-}
79691775
79701776 // shouldAutoCompactLocked reports whether the last turn pushed context
79711777 // usage past the auto-compact threshold. Must be called with i.mu
79721778 // held; it reads lastCtxInput and the current model's context window.
7973-func (i *Interactive) shouldAutoCompactLocked() bool {
7974- if i.agent == nil {
7975- return false
7976- }
7977- if i.autoCompacting {
7978- return false
7979- }
7980- m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model)
7981- if err != nil || m.ContextWindow <= 0 {
7982- return false
7983- }
7984- threshold := NormalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold)
7985- return ShouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold)
7986-}
7987-
7988-func eventAffectsPresentation(ev core.AgentEvent) bool {
7989- switch ev.(type) {
7990- case core.EvToolProgress:
7991- return false
7992- default:
7993- return true
7994- }
7995-}
7996-
7997-func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
7998- i.toolRenderRevision++
7999- if i.toolRenderRevision == 0 {
8000- // Overflow is practically unreachable, but resetting the sequence must
8001- // also drop old revisions before they can alias new frames.
8002- if i.view != nil {
8003- i.view.InvalidateRenderCache()
8004- }
8005- i.toolRenderRevision = 1
8006- }
8007- tc.Revision = i.toolRenderRevision
8008-}
8009-
8010-func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
8011- i.handleEvent(ev)
8012- // Progress payloads are still delivered to the core sink and applied
8013- // in order, but they have no interactive-visible representation.
8014- if eventAffectsPresentation(ev) {
8015- i.invalidate()
8016- }
8017-}
8018-
8019-func (i *Interactive) handleEvent(ev core.AgentEvent) {
8020- i.mu.Lock()
8021- defer i.mu.Unlock()
8022- i.activity.apply(ev)
8023- switch e := ev.(type) {
8024- case core.EvAssistantStart:
8025- // Fires at the top of every oneTurn, including follow-up
8026- // turns after tool use. Without this, the streaming buffer
8027- // is still marked off from the previous assistant message
8028- // and the final summary text pops in all at once instead
8029- // of typewriter-streaming delta by delta.
8030- i.streaming.Reset()
8031- i.streamPending = i.streamPending[:0]
8032- i.streamFlushPending = false
8033- i.streamOn = true
8034- // Clear the live tool-call overlay. Any tools from the
8035- // previous round are now fully folded into the transcript
8036- // (assistant tool_use block + tool role message with the
8037- // result), so keeping them in the overlay would duplicate
8038- // them in the view — once inside the finalised transcript
8039- // and once below the streaming block, with the streaming
8040- // summary sandwiched in between. The next EvToolUseStart
8041- // will populate fresh entries for this turn's tools.
8042- i.toolCalls = map[string]*tui.ToolCallView{}
8043- i.toolOrder = nil
8044- i.toolGate = map[string]int{}
8045- case core.EvTextDelta:
8046- // Buffer into streamPending; the paintPace ticker drains
8047- // it into i.streaming a few runes at a time for a smooth
8048- // typewriter effect independent of upstream chunk size.
8049- i.streamPending = append(i.streamPending, []rune(e.Delta)...)
8050- i.streamOn = true
8051- case core.EvAssistantMessage:
8052- // OnAssistant + telegram mirroring always fire on message
8053- // arrival — they read the FINAL message content, which is
8054- // complete regardless of what's still in the pacer.
8055- i.assistantMessageSideEffects(e.Message)
8056- // If the pacer still has characters to drain, keep streamOn
8057- // true and mark flush pending; the paintPace ticker will
8058- // drain the remainder and reset streaming state when done.
8059- // Otherwise (rare: full-replay sessions, abort paths) clear
8060- // synchronously so a later render doesn't show stale text.
8061- if len(i.streamPending) > 0 {
8062- i.streamFlushPending = true
8063- return
8064- }
8065- i.resetStreamingStateLocked()
8066- case core.EvToolUseStart:
8067- // Live streaming: pre-create the view so the user sees the
8068- // tool call being composed in real time. Any subsequent
8069- // EvToolCall for the same ID updates the same struct (the
8070- // final parsed args + name are already known here).
8071- if _, exists := i.toolCalls[e.ID]; !exists {
8072- tc := &tui.ToolCallView{
8073- ID: e.ID,
8074- Name: e.Name,
8075- Streaming: true,
8076- }
8077- i.bumpToolRevisionLocked(tc)
8078- i.toolCalls[e.ID] = tc
8079- i.toolOrder = append(i.toolOrder, e.ID)
8080- i.gateToolLocked(e.ID)
8081- }
8082- case core.EvToolUseArgs:
8083- if tc, ok := i.toolCalls[e.ID]; ok {
8084- tc.RawJSONBuf += e.Delta
8085- i.bumpToolRevisionLocked(tc)
8086- // Refresh the live path as soon as it parses; used in
8087- // the header (write /Users/example/Desktop/demo.ts)
8088- // while the content is still streaming.
8089- if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
8090- tc.LivePath = p
8091- } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
8092- tc.LivePath = p
8093- }
8094- }
8095- case core.EvToolUseEnd:
8096- if tc, ok := i.toolCalls[e.ID]; ok {
8097- tc.Streaming = false
8098- i.bumpToolRevisionLocked(tc)
8099- }
8100- case core.EvToolCall:
8101- // If we already pre-created the view during streaming, just
8102- // refresh the final Args summary. Otherwise create a new one
8103- // (non-streaming providers or legacy paths).
8104- if tc, ok := i.toolCalls[e.ID]; ok {
8105- tc.Args = tui.ShortArgs(e.Name, e.Args)
8106- i.bumpToolRevisionLocked(tc)
8107- if tc.RawJSONBuf == "" {
8108- tc.RawJSONBuf = string(e.Args)
8109- }
8110- tc.Streaming = false
8111- } else {
8112- tc := &tui.ToolCallView{
8113- ID: e.ID,
8114- Name: e.Name,
8115- Args: tui.ShortArgs(e.Name, e.Args),
8116- RawJSONBuf: string(e.Args),
8117- }
8118- i.bumpToolRevisionLocked(tc)
8119- i.toolCalls[e.ID] = tc
8120- i.toolOrder = append(i.toolOrder, e.ID)
8121- i.gateToolLocked(e.ID)
8122- }
8123- case core.EvToolResult:
8124- if tc, ok := i.toolCalls[e.ID]; ok {
8125- tc.Done = true
8126- tc.Error = e.Result.IsError
8127- tc.Preview = ""
8128- var text strings.Builder
8129- for _, c := range e.Result.Content {
8130- if tb, ok := c.(provider.TextBlock); ok {
8131- if text.Len() > 0 {
8132- text.WriteString("\n")
8133- }
8134- text.WriteString(tb.Text)
8135- }
8136- }
8137- tc.Result = text.String()
8138- i.bumpToolRevisionLocked(tc)
8139- }
8140- if update, ok := tools.GoalUpdateFromResult(e.Result); ok {
8141- if update.Status == core.GoalActive {
8142- // A manager may advance a terminal goal to the next persisted
8143- // goal in the same mission. Do not let a late tool result resume
8144- // a goal explicitly paused by the user.
8145- if i.goalStatus != core.GoalPaused {
8146- i.goalStatus = core.GoalActive
8147- }
8148- } else if i.goalStatus == core.GoalActive {
8149- i.goalStatus = update.Status
8150- }
8151- }
8152- case core.EvUsage:
8153- i.cumUsage = e.Cumulative
8154- if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
8155- i.lastCtxInput = contextUsed
8156- }
8157- case core.EvTurnEnd:
8158- if e.Stop == provider.StopAborted {
8159- i.resetStreamingStateLocked()
8160- i.statusErr = ""
8161- i.statusOK = "cancelled"
8162- return
8163- }
8164- if e.Stop == provider.StopLength {
8165- // The model hit its output-token cap mid-response, so the
8166- // reply (often a long write/edit) is truncated. Surface it
8167- // explicitly, otherwise the turn just ends and reads like
8168- // the UI gave up. The agent already requests the model's
8169- // full MaxOutput budget, so this means the response genuinely
8170- // exceeded that ceiling; ask the user to continue.
8171- i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
8172- i.statusOK = ""
8173- return
8174- }
8175- // Don't surface mid-loop stream errors as a red banner here.
8176- // EvTurnEnd fires after every step in a multi-step tool loop,
8177- // so a transient 503 / network blip would briefly paint a red
8178- // banner over the still-streaming chat before the agent loop
8179- // either retries or exits. The final error (if any) is set by
8180- // startTurnWithImages once Prompt() returns, and recoverable
8181- // failures are routed to the rescue picker instead — which
8182- // keeps the chat clean while the agent is working.
8183- _ = e.Err
8184- }
8185-}
81861779
81871780 // WebSearchPolicyGeneration returns the generation a resolver must present at
81881781 // its final interactive commit. Policy transitions advance it before changing
81891782 // the gate or generic-child ceiling.
8190-func (i *Interactive) WebSearchPolicyGeneration() uint64 {
8191- return i.webSearchPolicyGeneration.Load()
8192-}
81931783
81941784 // ApplyAgentPromptConfig commits a resolved prompt and tool registry only if
81951785 // ag is still the live interactive agent. Callers without a long-running
81961786 // resolve use the current policy generation.
8197-func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
8198- return i.applyAgentPromptConfig(ag, system, tools, 0, false)
8199-}
82001787
82011788 // ApplyAgentPromptConfigAtWebSearchGeneration additionally rejects a registry
82021789 // resolved before a web-search policy transition. This final check is made in
82031790 // the same critical section as live-agent replacement and the core prompt swap.
8204-func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
8205- return i.applyAgentPromptConfig(ag, system, tools, generation, true)
8206-}
8207-
8208-func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
8209- if ag == nil {
8210- return nil, false
8211- }
8212- // Match the lock order used by dynamic tool mutations so a refresh
8213- // cannot race a snapshot-and-replace operation that would reintroduce
8214- // the old registry after the atomic prompt swap.
8215- i.agentMu.Lock()
8216- defer i.agentMu.Unlock()
8217- i.mu.Lock()
8218- defer i.mu.Unlock()
8219- if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
8220- return nil, false
8221- }
8222- if i.telegramBridge != nil {
8223- // Telegram prompts arrive over an external messaging channel without a
8224- // per-request confirmation surface. Keep the normal interactive
8225- // registry from reintroducing web_search from the moment the bridge is
8226- // attached, including while its startup handshake is in flight.
8227- delete(tools, "web_search")
8228- }
8229- oldTools := ag.SetPromptConfig(system, tools)
8230- _, webSearchAvailable := tools["web_search"]
8231- i.setWebSearchAvailable(webSearchAvailable)
8232- return oldTools, true
8233-}
82341791
82351792 // prepareReplacementAgentLocked applies session-wide capability policy before
82361793 // ag becomes visible to prompt submission. The caller holds agentMu and i.mu.
8237-func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
8238- if ag == nil {
8239- i.setWebSearchAvailable(false)
8240- return
8241- }
8242- registry := ag.ToolsSnapshot()
8243- if i.telegramBridge != nil {
8244- // External Telegram prompts may arrive as soon as the replacement is
8245- // published, so post-swap cleanup is too late.
8246- delete(registry, "web_search")
8247- ag.SetTools(registry)
8248- }
8249- _, webSearchAvailable := registry["web_search"]
8250- i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
8251-}
82521794
82531795 // DeferUntilIdle runs work immediately when the interactive agent is idle,
82541796 // or after the current turn/compaction/shell operation releases the busy
82551797 // state. Callbacks run outside i.mu.
8256-func (i *Interactive) DeferUntilIdle(fn func()) {
8257- if fn == nil {
8258- return
8259- }
8260- i.mu.Lock()
8261- if i.busy {
8262- i.pendingIdleWork = append(i.pendingIdleWork, fn)
8263- i.mu.Unlock()
8264- return
8265- }
8266- i.mu.Unlock()
8267- fn()
8268-}
8269-
8270-func (i *Interactive) takePendingIdleWorkLocked() []func() {
8271- work := i.pendingIdleWork
8272- i.pendingIdleWork = nil
8273- return work
8274-}
8275-
8276-func runPendingIdleWork(work []func()) {
8277- for _, fn := range work {
8278- if fn != nil {
8279- fn()
8280- }
8281- }
8282-}
82831798
82841799 // Agent returns the current agent, if any. Used by cli.go to flush the
82851800 // final transcript to the session file.
8286-func (i *Interactive) Agent() *core.Agent {
8287- i.mu.Lock()
8288- defer i.mu.Unlock()
8289- return i.agent
8290-}
82911801
82921802 // silence unused import in some build configs
82931803 var _ = fmt.Sprintf
@@ -8296,28 +1806,6 @@ var _ = fmt.Sprintf
82961806 // + explicit). Runs on a goroutine so the TUI stays responsive; the
82971807 // Manager.Reload takes a couple of hundred ms to shut down subprocs
82981808 // and respawn them. Shows a temporary status line throughout.
8299-func (i *Interactive) runReloadExt(ctx context.Context) {
8300- if i.cfg.Extensions == nil {
8301- i.mu.Lock()
8302- i.statusErr = "no extension manager in this build"
8303- i.mu.Unlock()
8304- i.invalidate()
8305- return
8306- }
8307- i.mu.Lock()
8308- i.statusOK = "Reloading extensions..."
8309- i.statusErr = ""
8310- i.mu.Unlock()
8311- i.invalidate()
8312-
8313- go func() {
8314- stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
8315- msg, failed := formatReloadStatus(stats)
8316- seq := i.setReloadStatus(msg, failed)
8317- i.invalidate()
8318- go i.dismissReloadStatus(ctx, seq, msg, failed)
8319- }()
8320-}
83211809
83221810 // Confirm implements core.Confirmer. The agent goroutine calls
83231811 // this synchronously before every tool invocation when --no-yolo is
@@ -8327,239 +1815,29 @@ func (i *Interactive) runReloadExt(ctx context.Context) {
83271815 // If the session is cancelled or the TUI exits mid-prompt, any
83281816 // pending request is refused via CancelAll so the agent doesn't
83291817 // deadlock.
8330-func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
8331- return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
8332-}
83331818
83341819 // ConfirmToolCall attaches a side-effect-free preview to the matching live
83351820 // tool panel, then blocks until the user approves or refuses the call.
8336-func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
8337- resp := make(chan core.ConfirmDecision, 1)
8338- if isBtwOrigin(call.Origin) {
8339- req := &confirmRequest{
8340- toolName: call.Name,
8341- preview: call.Summary,
8342- resp: resp,
8343- returnToChild: true,
8344- }
8345- if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
8346- i.confirmDialog.Enqueue(req)
8347- }) {
8348- return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
8349- }
8350- i.invalidate()
8351- return <-resp
8352- }
8353- if call.ID != "" {
8354- i.mu.Lock()
8355- if tc, ok := i.toolCalls[call.ID]; ok {
8356- tc.Args = call.Summary
8357- tc.Preview = call.Content
8358- i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
8359- }
8360- i.mu.Unlock()
8361- }
8362- i.confirmDialog.Enqueue(&confirmRequest{
8363- toolName: call.Name,
8364- preview: call.Summary,
8365- resp: resp,
8366- })
8367- i.invalidate()
8368- return <-resp
8369-}
83701821
83711822 // openTelegramDialog shows the picker for `/telegram` with no arg.
83721823 // Items depend on current state: disconnect + status when running,
83731824 // connect + status when stopped.
8374-func (i *Interactive) openTelegramDialog() {
8375- items := i.telegramMenuItems()
8376- if len(items) == 0 {
8377- i.mu.Lock()
8378- i.statusErr = "telegram not configured. run `zut telegram-bot setup` first."
8379- i.mu.Unlock()
8380- i.invalidate()
8381- return
8382- }
8383- i.telegramDialog.Open(items)
8384- i.invalidate()
8385-}
83861825
83871826 // telegramMenuItems builds the dialog entries for the current
83881827 // bridge state. Returns empty when no bot.json exists so the
83891828 // caller can show a helpful status line instead of an empty menu.
8390-func (i *Interactive) telegramMenuItems() []telegramItem {
8391- cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
8392- if err != nil || cfg.BotToken == "" {
8393- return nil
8394- }
8395- var items []telegramItem
8396- if i.telegramBridge != nil && i.telegramBridge.Active() {
8397- items = append(items, telegramItem{label: "disconnect", action: "disconnect", hint: "stop mirroring"})
8398- st := i.telegramBridge.State()
8399- hint := "active"
8400- if st.Username != "" {
8401- hint += " as @" + st.Username
8402- }
8403- items = append(items, telegramItem{label: "status", action: "status", hint: hint})
8404- } else {
8405- label := "connect"
8406- hint := "start mirroring dms into this session"
8407- if cfg.AllowedUserID == 0 {
8408- hint = "awaiting pairing (send /start to the bot once connected)"
8409- }
8410- items = append(items, telegramItem{label: label, action: "connect", hint: hint})
8411- items = append(items, telegramItem{label: "status", action: "status", hint: "disconnected"})
8412- }
8413- return items
8414-}
84151829
84161830 // doTelegram dispatches one of the three explicit actions. Called
84171831 // from /telegram <action> or after the picker selects a row.
8418-func (i *Interactive) doTelegram(action string) {
8419- switch action {
8420- case "connect":
8421- i.telegramConnect()
8422- case "disconnect":
8423- i.telegramDisconnect()
8424- case "status":
8425- i.telegramStatus()
8426- default:
8427- i.mu.Lock()
8428- i.statusErr = "unknown telegram action: " + action + " (use connect, disconnect, or status)"
8429- i.mu.Unlock()
8430- i.invalidate()
8431- }
8432-}
84331832
84341833 // telegramConnect starts the bridge. Refuses if it's already
84351834 // running or if the on-disk bot.json is missing a token.
8436-func (i *Interactive) telegramConnect() {
8437- if i.telegramBridge != nil && i.telegramBridge.Active() {
8438- i.mu.Lock()
8439- i.statusOK = "telegram already connected"
8440- i.statusErr = ""
8441- i.mu.Unlock()
8442- i.invalidate()
8443- return
8444- }
8445- cfg, err := telegram.LoadConfig(i.cfg.ZutHome)
8446- if err != nil {
8447- i.mu.Lock()
8448- i.statusErr = "telegram: " + err.Error()
8449- i.mu.Unlock()
8450- i.invalidate()
8451- return
8452- }
8453- if cfg.BotToken == "" {
8454- i.mu.Lock()
8455- i.statusErr = "telegram: no bot token configured. run `zut telegram-bot setup` first."
8456- i.mu.Unlock()
8457- i.invalidate()
8458- return
8459- }
8460- // Refuse to start when a background daemon is already polling
8461- // the same bot. Two concurrent long-poll consumers race each
8462- // update and one always loses, so DMs get half-delivered. The
8463- // user can `zut telegram-bot stop` first, then /telegram connect.
8464- if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
8465- i.mu.Lock()
8466- i.statusErr = fmt.Sprintf("telegram: bot daemon already running (pid %d). stop it with `zut telegram-bot stop` first.", pid)
8467- i.mu.Unlock()
8468- i.invalidate()
8469- return
8470- }
8471- bridge := &telegram.Bridge{
8472- Client: telegram.NewClient(cfg.BotToken),
8473- Config: cfg,
8474- Save: func(next telegram.Config) error {
8475- return telegram.SaveConfig(i.cfg.ZutHome, next)
8476- },
8477- Host: &telegramHost{iv: i},
8478- }
8479- i.mu.Lock()
8480- i.telegramBridge = bridge
8481- i.mu.Unlock()
8482- // Strip web_search before the bridge's startup handshake can accept a
8483- // Telegram update. ApplyAgentPromptConfig also keeps it stripped from
8484- // concurrent refreshes while this bridge pointer is attached.
8485- i.applyTelegramTools(true)
8486- if err := bridge.Start(i.runCtx); err != nil {
8487- i.mu.Lock()
8488- i.telegramBridge = nil
8489- i.mu.Unlock()
8490- refreshErr := i.refreshToolsAfterTelegram()
8491- i.mu.Lock()
8492- i.statusErr = "telegram connect failed: " + err.Error()
8493- if refreshErr != nil {
8494- i.statusErr += "; tool refresh: " + refreshErr.Error()
8495- }
8496- i.mu.Unlock()
8497- i.invalidate()
8498- return
8499- }
8500- state := bridge.State()
8501- label := "telegram connected"
8502- if state.Username != "" {
8503- label += " as @" + state.Username
8504- }
8505- if state.PairedID == 0 {
8506- label += " — send /start to the bot from your phone to claim it"
8507- }
8508- i.mu.Lock()
8509- i.statusOK = label
8510- i.statusErr = ""
8511- i.mu.Unlock()
8512- i.invalidate()
8513-}
85141835
85151836 // telegramDisconnect stops the bridge. No-op when already stopped.
8516-func (i *Interactive) telegramDisconnect() {
8517- if i.telegramBridge == nil || !i.telegramBridge.Active() {
8518- i.mu.Lock()
8519- i.statusOK = "telegram already disconnected"
8520- i.statusErr = ""
8521- i.mu.Unlock()
8522- i.invalidate()
8523- return
8524- }
8525- bridge := i.telegramBridge
8526- bridge.Stop()
8527- i.mu.Lock()
8528- // Clear the pointer before rebuilding the normal registry. This both
8529- // allows web_search back into the refresh and marks the bridge inactive
8530- // for any concurrent prompt-config commit.
8531- i.telegramBridge = nil
8532- i.mu.Unlock()
8533- refreshErr := i.refreshToolsAfterTelegram()
8534- i.mu.Lock()
8535- if refreshErr != nil {
8536- i.statusOK = ""
8537- i.statusErr = "telegram disconnect tool refresh: " + refreshErr.Error()
8538- } else {
8539- i.statusOK = "telegram disconnected"
8540- i.statusErr = ""
8541- }
8542- i.mu.Unlock()
8543- i.invalidate()
8544-}
85451837
85461838 // refreshToolsAfterTelegram restores normal tools only through a successful
85471839 // resolver refresh. Failure removes Telegram-only tools but leaves web_search
85481840 // revoked in the live registry, stale snapshots, and generic-child policy.
8549-func (i *Interactive) refreshToolsAfterTelegram() error {
8550- if i.cfg.RefreshTools == nil {
8551- i.stripWebSearchTool()
8552- i.applyTelegramTools(false)
8553- return errors.New("live tool refresh is unavailable")
8554- }
8555- if err := i.cfg.RefreshTools(); err != nil {
8556- i.stripWebSearchTool()
8557- i.applyTelegramTools(false)
8558- return err
8559- }
8560- i.applyTelegramTools(false)
8561- return nil
8562-}
85631841
85641842 // telegramSenderAdapter wraps the bridge so the tools package can
85651843 // drive it without importing telegram directly. The Active() check
@@ -8569,546 +1847,44 @@ type telegramSenderAdapter struct {
85691847 bridge *telegram.Bridge
85701848 }
85711849
8572-func (a telegramSenderAdapter) SendImage(ctx context.Context, path, caption string) error {
8573- if a.bridge == nil {
8574- return fmt.Errorf("telegram bridge is not connected")
8575- }
8576- return a.bridge.SendImage(ctx, path, caption)
8577-}
8578-
8579-func (a telegramSenderAdapter) SendDocument(ctx context.Context, path, caption string) error {
8580- if a.bridge == nil {
8581- return fmt.Errorf("telegram bridge is not connected")
8582- }
8583- return a.bridge.SendDocument(ctx, path, caption)
8584-}
8585-
8586-func (a telegramSenderAdapter) Active() bool {
8587- return a.bridge != nil && a.bridge.Active()
8588-}
8589-
85901850 // TrackResidentSubagent reserves completion delivery after the manager has
85911851 // durably accepted a resident child prompt and before it is scheduled.
8592-func (i *Interactive) TrackResidentSubagent(childID string) {
8593- if i == nil {
8594- return
8595- }
8596- i.ensureCompletionTracker().TrackResident()
8597- i.registerCoordinatorWorker(childID)
8598- i.invalidate()
8599- i.requestCompletionDelivery()
8600-}
86011852
86021853 // ReportResidentSubagent delivers a typed resident terminal outcome through
86031854 // the same coordinator path used by legacy worker completions.
8604-func (i *Interactive) ReportResidentSubagent(completion subagents.ResidentCompletion) {
8605- if i == nil {
8606- return
8607- }
8608- status, errText := "completed", ""
8609- if completion.Err != nil {
8610- status, errText = "failed", completion.Err.Error()
8611- }
8612- i.ensureCompletionTracker().Report(subagents.Completion{AgentID: completion.ChildID, Status: status, Task: completion.Task, Error: errText, Summary: completion.Summary})
8613- i.reloadOpenResidentChildSession(completion.ChildID)
8614- i.invalidate()
8615- i.requestCompletionDelivery()
8616-}
8617-
8618-func (i *Interactive) reloadOpenResidentChildSession(childID string) {
8619- if i == nil {
8620- return
8621- }
8622- i.mu.Lock()
8623- session := i.residentChildSession
8624- i.mu.Unlock()
8625- if session == nil || session.childID != childID || !session.RequestRecentReload() {
8626- return
8627- }
8628- i.reloadResidentChildSession(session)
8629-}
8630-
8631-func (i *Interactive) reloadResidentChildSession(session *residentChildSession) {
8632- if i == nil || session == nil {
8633- return
8634- }
8635- go func() {
8636- err := session.ReloadRecent(200)
8637- if session.FinishLoad(err) {
8638- if session.RequestRecentReload() {
8639- i.reloadResidentChildSession(session)
8640- }
8641- }
8642- i.invalidate()
8643- }()
8644-}
8645-
8646-func (i *Interactive) ensureCompletionTracker() *subagents.CompletionTracker {
8647- // Tests and lightweight embedders may construct Interactive with a struct
8648- // literal instead of NewInteractive, so retain this lazy fallback.
8649- i.completionDeliveryMu.Lock()
8650- defer i.completionDeliveryMu.Unlock()
8651- if i.completionTracker == nil {
8652- i.completionTracker = subagents.NewCompletionTracker()
8653- }
8654- return i.completionTracker
8655-}
8656-
8657-func (i *Interactive) ensureCoordinatorLocked() *orchestration.Coordinator {
8658- if i.turnCoordinator == nil {
8659- i.turnCoordinator = orchestration.New()
8660- }
8661- return i.turnCoordinator
8662-}
8663-
8664-func (i *Interactive) coordinatorAcceptsUserInput() bool {
8665- if i == nil {
8666- return false
8667- }
8668- i.completionDeliveryMu.Lock()
8669- accepts := i.ensureCoordinatorLocked().AcceptsUserInput()
8670- i.completionDeliveryMu.Unlock()
8671- return accepts
8672-}
8673-
8674-func (i *Interactive) coordinatorHasPendingWorkers() bool {
8675- if i == nil {
8676- return false
8677- }
8678- i.completionDeliveryMu.Lock()
8679- pending := i.ensureCoordinatorLocked().HasPendingWorkers()
8680- i.completionDeliveryMu.Unlock()
8681- return pending
8682-}
8683-
8684-func (i *Interactive) applyCoordinator(event orchestration.Event) []orchestration.Action {
8685- if i == nil {
8686- return nil
8687- }
8688- i.completionDeliveryMu.Lock()
8689- result := i.ensureCoordinatorLocked().Apply(event)
8690- i.completionDeliveryMu.Unlock()
8691- return result.Actions
8692-}
86931855
86941856 // registerCoordinatorWorker associates a tracker registration with the open
86951857 // manager wave. Direct embedders that register a worker outside startTurn get
8696-// a one-worker sealed wave, retaining the exported tracker API's old behavior.
8697-func (i *Interactive) registerCoordinatorWorker(workerID string) {
8698- if i == nil || workerID == "" {
8699- return
8700- }
8701- i.completionDeliveryMu.Lock()
8702- coordinator := i.ensureCoordinatorLocked()
8703- implicitWave := i.completionDeliveryHolds == 0
8704- if implicitWave {
8705- coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
8706- }
8707- i.coordinatorWorkerSeq++
8708- registrationID := fmt.Sprintf("%s#%d", workerID, i.coordinatorWorkerSeq)
8709- if i.coordinatorWorkerIDs == nil {
8710- i.coordinatorWorkerIDs = make(map[string][]string)
8711- }
8712- i.coordinatorWorkerIDs[workerID] = append(i.coordinatorWorkerIDs[workerID], registrationID)
8713- coordinator.Apply(orchestration.Event{Kind: orchestration.EventWorkerRegistered, WorkerID: registrationID})
8714- var actions []orchestration.Action
8715- if implicitWave {
8716- actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
8717- }
8718- i.completionDeliveryMu.Unlock()
8719- i.executeCoordinatorActions(actions)
8720-}
8721-
8722-func (i *Interactive) cancelCoordinator() {
8723- if i == nil {
8724- return
8725- }
8726- i.completionDeliveryMu.Lock()
8727- coordinator := i.ensureCoordinatorLocked()
8728- actions := coordinator.Apply(orchestration.Event{Kind: orchestration.EventCancelled}).Actions
8729- // A later user turn starts a fresh wave. Drop resident registrations as
8730- // well as coordinator identities so late outcomes cannot match a new child.
8731- if i.completionTracker != nil {
8732- i.completionTracker.Reset()
8733- }
8734- i.turnCoordinator = orchestration.New()
8735- i.coordinatorWorkerIDs = nil
8736- i.completionDeliveryMu.Unlock()
8737- i.executeCoordinatorActions(actions)
8738-}
8739-
8740-func (i *Interactive) takeCoordinatorWorkerID(agentID string) string {
8741- if i == nil || agentID == "" {
8742- return ""
8743- }
8744- i.completionDeliveryMu.Lock()
8745- defer i.completionDeliveryMu.Unlock()
8746- ids := i.coordinatorWorkerIDs[agentID]
8747- if len(ids) == 0 {
8748- return ""
8749- }
8750- workerID := ids[0]
8751- if len(ids) == 1 {
8752- delete(i.coordinatorWorkerIDs, agentID)
8753- } else {
8754- i.coordinatorWorkerIDs[agentID] = ids[1:]
8755- }
8756- return workerID
8757-}
8758-
8759-func (i *Interactive) executeCoordinatorActions(actions []orchestration.Action) {
8760- for _, action := range actions {
8761- if action.Kind != orchestration.ActionRunManager {
8762- continue
8763- }
8764- prompt := action.Text
8765- if len(action.Completions) != 0 {
8766- 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."
8767- update := subagents.FormatCompletionUpdate(action.Completions, instruction)
8768- if prompt != "" {
8769- prompt = update + "\n\nQueued user request:\n" + prompt
8770- } else {
8771- prompt = update
8772- }
8773- }
8774- if prompt != "" {
8775- i.submitOrQueue(prompt, nil, false)
8776- } else if action.Reason == orchestration.WakeGoal {
8777- parent := i.runCtx
8778- if parent == nil {
8779- parent = context.Background()
8780- }
8781- i.requestGoalContinuationIfIdle(parent)
8782- }
8783- }
8784-}
8785-
8786-// requestCompletionDelivery starts at most one waiter for the current active
8787-// set. A request arriving while that waiter is formatting or submitting is
8788-// picked up before the waiter exits, so a later worker cannot be lost in the
8789-// handoff between batches.
8790-func (i *Interactive) requestCompletionDelivery() {
8791- if i == nil {
8792- return
8793- }
8794- i.ensureCompletionTracker()
8795- i.completionDeliveryMu.Lock()
8796- i.completionDeliveryRequest = true
8797- if i.completionDeliveryRunning || i.completionDeliveryHolds != 0 {
8798- i.completionDeliveryMu.Unlock()
8799- return
8800- }
8801- i.completionDeliveryRunning = true
8802- i.completionDeliveryMu.Unlock()
8803-
8804- go i.deliverCompletionUpdates()
8805-}
8806-
8807-// beginCompletionDeliveryHold keeps all completions observed during one
8808-// parent model turn together. Tool calls are executed sequentially inside the
8809-// core agent, so the release point is the owning registration boundary for
8810-// same-parent-turn batching rather than a timing-based debounce.
8811-func (i *Interactive) beginCompletionDeliveryHold() func() {
8812- if i == nil {
8813- return func() {}
8814- }
8815- i.completionDeliveryMu.Lock()
8816- i.ensureCoordinatorLocked().Apply(orchestration.Event{Kind: orchestration.EventManagerStarted})
8817- i.completionDeliveryHolds++
8818- i.completionDeliveryMu.Unlock()
8819- return i.releaseCompletionDeliveryHold
8820-}
8821-
8822-func (i *Interactive) releaseCompletionDeliveryHold() {
8823- if i == nil {
8824- return
8825- }
8826- start := false
8827- var actions []orchestration.Action
8828- i.completionDeliveryMu.Lock()
8829- if i.completionDeliveryHolds > 0 {
8830- i.completionDeliveryHolds--
8831- }
8832- if i.completionDeliveryHolds == 0 {
8833- // The coordinator must observe the goal before sealing the wave, so a
8834- // pending worker wins the wake decision instead of a direct continuation.
8835- _, goalActive := i.goalContinuationMessage()
8836- coordinator := i.ensureCoordinatorLocked()
8837- coordinator.Apply(orchestration.Event{Kind: orchestration.EventGoalChanged, GoalActive: goalActive})
8838- actions = coordinator.Apply(orchestration.Event{Kind: orchestration.EventManagerFinished}).Actions
8839- }
8840- if i.completionDeliveryHolds == 0 && i.completionDeliveryRequest && !i.completionDeliveryRunning {
8841- i.completionDeliveryRunning = true
8842- start = true
8843- }
8844- i.completionDeliveryMu.Unlock()
8845- i.executeCoordinatorActions(actions)
8846- if start {
8847- go i.deliverCompletionUpdates()
8848- }
8849-}
8850-
8851-func (i *Interactive) completionWaitContext() context.Context {
8852- i.mu.Lock()
8853- ctx := i.runCtx
8854- i.mu.Unlock()
8855- if ctx == nil {
8856- return context.Background()
8857- }
8858- return ctx
8859-}
8860-
8861-func (i *Interactive) deliverCompletionUpdates() {
8862- for {
8863- i.completionDeliveryMu.Lock()
8864- i.completionDeliveryRequest = false
8865- tracker := i.completionTracker
8866- i.completionDeliveryMu.Unlock()
8867-
8868- batch, err := tracker.WaitIdle(i.completionWaitContext())
8869- if err == nil && len(batch) != 0 {
8870- var actions []orchestration.Action
8871- for _, completion := range batch {
8872- workerID := i.takeCoordinatorWorkerID(completion.AgentID)
8873- if workerID == "" {
8874- continue
8875- }
8876- actions = append(actions, i.applyCoordinator(orchestration.Event{
8877- Kind: orchestration.EventWorkerFinished,
8878- WorkerID: workerID,
8879- Completion: completion,
8880- })...)
8881- }
8882- i.executeCoordinatorActions(actions)
8883- }
1858+// a one-worker sealed wave, retaining the exported tracker API's old behavior.
88841859
8885- i.completionDeliveryMu.Lock()
8886- if i.completionDeliveryRequest {
8887- i.completionDeliveryMu.Unlock()
8888- continue
8889- }
8890- i.completionDeliveryRunning = false
8891- i.completionDeliveryMu.Unlock()
8892- return
8893- }
8894-}
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.
88951869
88961870 // autoSubagentsAddenda returns the prompt blocks owned by subagent prompting,
88971871 // in the same order Resolve appends them to a new agent. The profile manifest
88981872 // is independent of strict orchestration and remains available in on-demand
88991873 // mode whenever the primary agent can spawn a worker.
8900-func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
8901- addenda := make([]string, 0, 2)
8902- if cfg.ResidentManager != nil && autoSubagentsToolAllowedConfig(cfg) {
8903- if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
8904- addenda = append(addenda, addendum)
8905- }
8906- }
8907- if !orchestrating {
8908- if cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(cfg) {
8909- if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
8910- addenda = append(addenda, addendum)
8911- }
8912- }
8913- return addenda
8914- }
8915-
8916- if addendum := strings.TrimSpace(cfg.AutoSubagentsSystemAddendum); addendum != "" {
8917- addenda = append(addenda, addendum)
8918- }
8919- return addenda
8920-}
89211874
89221875 // removeLastAutoSubagentsAddendum removes one occurrence of a block known to
89231876 // have been appended by auto-subagents. Resolve appends owned blocks at the
89241877 // end, so removing the last occurrence preserves an identical base block.
8925-func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
8926- idx := strings.LastIndex(system, addendum)
8927- if idx < 0 {
8928- return system, false
8929- }
8930- return system[:idx] + system[idx+len(addendum):], true
8931-}
89321878
89331879 // applyAutoSubagentsSystemPrompt swaps the prompt blocks owned by subagent
89341880 // prompting on the running agent. Toggling strict orchestration changes the
89351881 // delegation guidance while retaining the profile manifest when usable.
8936-func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
8937- // i.mu is sufficient here: this path updates the existing agent's system
8938- // prompt and managed addenda in place; it does not replace the agent or
8939- // its tool registry, so agentMu is not required.
8940- i.mu.Lock()
8941- defer i.mu.Unlock()
8942- if i.agent == nil {
8943- return
8944- }
8945-
8946- sys, _ := i.agent.PromptConfig()
8947- changed := false
8948- if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
8949- var removed bool
8950- sys, removed = removeLastAutoSubagentsAddendum(sys, guidance)
8951- changed = changed || removed
8952- }
8953- for idx := len(i.managedAutoSubagentsAddenda) - 1; idx >= 0; idx-- {
8954- var removed bool
8955- sys, removed = removeLastAutoSubagentsAddendum(sys, i.managedAutoSubagentsAddenda[idx])
8956- changed = changed || removed
8957- }
8958- i.managedAutoSubagentsAddenda = nil
8959-
8960- addenda := autoSubagentsAddenda(i.cfg, orchestrating)
8961- if changed {
8962- sys = strings.TrimRight(sys, "\n")
8963- }
8964- for _, addendum := range addenda {
8965- if sys != "" {
8966- sys += "\n\n"
8967- }
8968- sys += addendum
8969- i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
8970- changed = true
8971- }
8972- if guidance := strings.TrimSpace(i.cfg.WritingGuidance); guidance != "" {
8973- if sys != "" {
8974- sys += "\n\n"
8975- }
8976- sys += guidance
8977- changed = true
8978- }
8979- if !changed {
8980- return
8981- }
8982- i.agent.SetSystemPrompt(sys)
8983-}
89841882
89851883 // applyAutoSubagentsTool registers the canonical subagent tools whenever
89861884 // launch-time policy permits them. Auto-subagents controls prompt behavior,
89871885 // while the tools remain available for explicit user-requested delegation.
89881886 // Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and
89891887 // /reload-ext additions survive a settings change.
8990-func (i *Interactive) autoSubagentsAvailable() bool {
8991- return i.cfg.ResidentManager != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
8992-}
8993-
8994-func (i *Interactive) autoSubagentsUnavailableHint() string {
8995- var hints []string
8996- if i.cfg.ResidentManager == nil {
8997- hints = append(hints, "resident subagent manager is unavailable in this mode")
8998- }
8999- if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
9000- hints = append(hints, "launch-time tool policy excludes subagent manager tools")
9001- }
9002- return strings.Join(hints, "; ")
9003-}
9004-
9005-func autoSubagentsToolAllowedConfig(cfg InteractiveConfig) bool {
9006- return cfg.AutoSubagentsToolAllowed == nil || *cfg.AutoSubagentsToolAllowed
9007-}
9008-
9009-func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
9010- if cfg.AutoSubagentsStatusToolAllowed != nil {
9011- return *cfg.AutoSubagentsStatusToolAllowed
9012- }
9013- // Preserve the old single-boolean embedding contract: when callers have
9014- // not supplied the new field, status follows the existing delegation gate.
9015- return autoSubagentsToolAllowedConfig(cfg)
9016-}
9017-
9018-func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
9019- if cfg.AutoSubagentsStopToolAllowed != nil {
9020- return *cfg.AutoSubagentsStopToolAllowed
9021- }
9022- return autoSubagentsToolAllowedConfig(cfg)
9023-}
9024-
9025-func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
9026- if cfg.AutoSubagentsResumeToolAllowed != nil {
9027- return *cfg.AutoSubagentsResumeToolAllowed
9028- }
9029- return autoSubagentsToolAllowedConfig(cfg)
9030-}
9031-
9032-func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
9033- return autoSubagentsToolAllowedConfig(cfg) ||
9034- autoSubagentsStatusToolAllowedConfig(cfg) ||
9035- autoSubagentsStopToolAllowedConfig(cfg) ||
9036- autoSubagentsResumeToolAllowedConfig(cfg)
9037-}
9038-
9039-func (i *Interactive) autoSubagentsToolAllowed() bool {
9040- return autoSubagentsToolAllowedConfig(i.cfg)
9041-}
9042-
9043-func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
9044- return autoSubagentsStatusToolAllowedConfig(i.cfg)
9045-}
9046-
9047-func (i *Interactive) autoSubagentsStopToolAllowed() bool {
9048- return autoSubagentsStopToolAllowedConfig(i.cfg)
9049-}
9050-
9051-func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
9052- return autoSubagentsResumeToolAllowedConfig(i.cfg)
9053-}
9054-
9055-func (i *Interactive) autoSubagentsEnabledLocked() bool {
9056- return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
9057-}
9058-
9059-func (i *Interactive) applyAutoSubagentsTool() {
9060- i.agentMu.Lock()
9061- defer i.agentMu.Unlock()
9062- i.mu.Lock()
9063- defer i.mu.Unlock()
9064- if i.agent == nil {
9065- return
9066- }
9067- current := i.agent.ToolsSnapshot()
9068- next := core.Registry{}
9069- for name, t := range current {
9070- if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
9071- continue
9072- }
9073- next[name] = t
9074- }
9075- if i.autoSubagentsAvailable() {
9076- if i.autoSubagentsToolAllowed() {
9077- canonical := &tools.SubagentSpawnTool{
9078- ResidentManager: i.cfg.ResidentManager,
9079- BuildResidentSpec: i.cfg.BuildResidentSpec,
9080- Enabled: func() bool { return true },
9081- DefaultModel: func() string { return i.cfg.Model },
9082- DefaultProvider: func() string { return i.cfg.Provider },
9083- DefaultReasoning: func() string { return i.cfg.Reasoning },
9084- ResolveSubagent: i.cfg.ResolveSubagent,
9085- }
9086- next[canonical.Name()] = canonical
9087- }
9088- if i.autoSubagentsStatusToolAllowed() {
9089- statusTool := &tools.SubagentStatusTool{
9090- ResidentManager: i.cfg.ResidentManager,
9091- Enabled: func() bool { return true },
9092- }
9093- next[statusTool.Name()] = statusTool
9094- }
9095- if i.autoSubagentsStopToolAllowed() {
9096- stopTool := &tools.SubagentStopTool{
9097- ResidentManager: i.cfg.ResidentManager,
9098- Enabled: func() bool { return true },
9099- }
9100- next[stopTool.Name()] = stopTool
9101- }
9102- if i.autoSubagentsResumeToolAllowed() {
9103- resumeTool := &tools.SubagentResumeTool{
9104- ResidentManager: i.cfg.ResidentManager,
9105- Enabled: func() bool { return true },
9106- }
9107- next[resumeTool.Name()] = resumeTool
9108- }
9109- }
9110- i.agent.SetTools(next)
9111-}
91121888
91131889 // applyTelegramTools registers (active=true) or removes (active=false)
91141890 // the telegram_send_image and telegram_send_file tools on the running
@@ -9117,644 +1893,90 @@ func (i *Interactive) applyAutoSubagentsTool() {
91171893 // /reload-ext additions made while Telegram is connected survive a
91181894 // later /telegram disconnect. The two Telegram entries are always replaced;
91191895 // web_search is additionally stripped only while the external bridge is active.
9120-func (i *Interactive) applyTelegramTools(active bool) {
9121- if active {
9122- // The child ceiling must be in place even when no live main agent exists,
9123- // and before Bridge.Start can accept an external prompt.
9124- i.setWebSearchAvailable(false)
9125- }
9126- i.agentMu.Lock()
9127- defer i.agentMu.Unlock()
9128- i.mu.Lock()
9129- defer i.mu.Unlock()
9130- if i.agent == nil {
9131- return
9132- }
9133- current := i.agent.ToolsSnapshot()
9134- next := core.Registry{}
9135- for name, t := range current {
9136- if name == "telegram_send_image" || name == "telegram_send_file" {
9137- continue
9138- }
9139- if active && name == "web_search" {
9140- // External Telegram prompts have no per-request confirmation
9141- // surface, so V1 does not expose native web search while paired.
9142- continue
9143- }
9144- next[name] = t
9145- }
9146- if active {
9147- sender := telegramSenderAdapter{bridge: i.telegramBridge}
9148- next["telegram_send_image"] = &tools.TelegramSendImageTool{
9149- CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
9150- }
9151- next["telegram_send_file"] = &tools.TelegramSendFileTool{
9152- CWD: i.cfg.CWD, Sandbox: i.cfg.Sandbox, Sender: sender,
9153- }
9154- }
9155- i.agent.SetTools(next)
9156- _, webSearchAvailable := next["web_search"]
9157- i.setWebSearchAvailable(webSearchAvailable && !active)
9158-}
91591896
91601897 // telegramStatus writes a one-liner describing the bridge state.
91611898 // Reports on both the in-tui bridge and the background daemon so
91621899 // the user isn't confused when the daemon owns the poll loop.
9163-func (i *Interactive) telegramStatus() {
9164- var msg string
9165- if i.telegramBridge != nil && i.telegramBridge.Active() {
9166- s := i.telegramBridge.State()
9167- msg = "telegram: connected (tui bridge)"
9168- if s.Username != "" {
9169- msg += " as @" + s.Username
9170- }
9171- if s.PairedID != 0 {
9172- msg += fmt.Sprintf(" - paired with user %d", s.PairedID)
9173- } else {
9174- msg += " - awaiting pairing"
9175- }
9176- } else if pid, alive, _ := telegram.IsRunning(i.cfg.ZutHome); alive && pid > 0 {
9177- msg = fmt.Sprintf("telegram: background daemon running (pid %d) - /telegram connect won't work until you stop it", pid)
9178- } else {
9179- cfg, _ := telegram.LoadConfig(i.cfg.ZutHome)
9180- if cfg.BotToken == "" {
9181- msg = "telegram: not configured. run `zut telegram-bot setup` first."
9182- } else {
9183- msg = "telegram: disconnected"
9184- if cfg.BotUsername != "" {
9185- msg += " (@" + cfg.BotUsername + " ready to connect)"
9186- }
9187- }
9188- }
9189- i.mu.Lock()
9190- i.statusOK = msg
9191- i.statusErr = ""
9192- i.mu.Unlock()
9193- i.invalidate()
9194-}
91951900
91961901 // telegramHost adapts *Interactive to telegram.Host so the bridge
91971902 // can call back into the TUI without importing modes directly.
91981903 type telegramHost struct{ iv *Interactive }
91991904
9200-func (h *telegramHost) SubmitOrQueue(prompt string, images []provider.ImageBlock) {
9201- h.iv.SubmitOrQueue(prompt, images)
9202-}
9203-
9204-func (h *telegramHost) CancelTurn() { h.iv.CancelTurn() }
9205-
9206-func (h *telegramHost) Status() string {
9207- h.iv.mu.Lock()
9208- providerName := h.iv.cfg.Provider
9209- model := h.iv.cfg.Model
9210- cwd := h.iv.cfg.CWD
9211- usage := h.iv.cumUsage
9212- subscription := h.iv.cfg.AuthMethod == "oauth"
9213- ctxUsed := h.iv.lastCtxInput
9214- busy := h.iv.busy
9215- queued := len(h.iv.queued)
9216- h.iv.mu.Unlock()
9217-
9218- ctxMax := 0
9219- if m, err := provider.FindModel(providerName, model); err == nil {
9220- ctxMax = m.ContextWindow
9221- }
9222- return telegram.FormatStatus(telegram.StatusSnapshot{
9223- Provider: providerName,
9224- Model: model,
9225- CWD: cwd,
9226- Usage: usage,
9227- Subscription: subscription,
9228- ContextUsed: ctxUsed,
9229- ContextMax: ctxMax,
9230- Busy: busy,
9231- Queued: queued,
9232- })
9233-}
9234-
9235-func (h *telegramHost) Notify(level, message string) {
9236- h.iv.mu.Lock()
9237- switch level {
9238- case "error", "warn":
9239- h.iv.statusErr = message
9240- h.iv.statusOK = ""
9241- default:
9242- h.iv.statusOK = message
9243- h.iv.statusErr = ""
9244- }
9245- h.iv.mu.Unlock()
9246- h.iv.invalidate()
9247-}
9248-
92491905 // openSessionOpsDialog shows the picker for `/session` with no arg.
92501906 // Always offers export, import, fork, tree; the handlers bail with
92511907 // a clear status message when the precondition isn't met (empty
92521908 // transcript on fork; no parent/siblings on tree).
9253-func (i *Interactive) openSessionOpsDialog() {
9254- items := []sessionOpsItem{
9255- {label: "export", action: "export", hint: "write the current session to a .zutsession file"},
9256- {label: "import", action: "import", hint: "load a .zutsession file into this directory"},
9257- {label: "fork", action: "fork", hint: "branch from a past user message into a new session"},
9258- {label: "tree", action: "tree", hint: "switch between branches in this directory"},
9259- }
9260- i.sessionOpsDialog.Open(items)
9261- i.invalidate()
9262-}
92631909
92641910 // doSessionOp dispatches export, import, fork, or tree. arg is the
92651911 // optional positional argument from e.g. /session export <path>
92661912 // or /session import <path>; fork and tree ignore it.
9267-func (i *Interactive) doSessionOp(action, arg string) {
9268- switch action {
9269- case "export":
9270- i.doSessionExport(arg)
9271- case "import":
9272- i.doSessionImport(arg)
9273- case "fork":
9274- i.doSessionFork()
9275- case "tree":
9276- i.doSessionTree()
9277- default:
9278- i.mu.Lock()
9279- i.statusErr = "unknown /session action: " + action + " (use export, import, fork, or tree)"
9280- i.mu.Unlock()
9281- i.invalidate()
9282- }
9283-}
92841913
92851914 // doSessionExport writes the live session file to destination path
92861915 // dst. When dst is empty we default to ~/Downloads (falling back to
92871916 // the user's home directory if it doesn't exist). The helper
92881917 // expands a leading `~` and creates any missing parent directories.
9289-func (i *Interactive) doSessionExport(dst string) {
9290- src := i.currentSessionPath()
9291- if src == "" {
9292- i.mu.Lock()
9293- i.statusErr = "export: no session is active (running with --no-session?)"
9294- i.mu.Unlock()
9295- i.invalidate()
9296- return
9297- }
9298- // Persist any in-memory agent messages to the session file so
9299- // the export carries the full conversation. Without this, the
9300- // default lazy-flush-at-exit strategy leaves most of a running
9301- // session unwritten and the export ends up with only the meta.
9302- if i.cfg.FlushSession != nil {
9303- i.cfg.FlushSession()
9304- }
9305- dst = unquotePath(dst)
9306- if dst == "" {
9307- dst = defaultExportDir()
9308- } else {
9309- dst = expandTilde(dst)
9310- }
9311- out, err := core.ExportSession(src, dst)
9312- if err != nil {
9313- i.mu.Lock()
9314- i.statusErr = "export: " + err.Error()
9315- i.mu.Unlock()
9316- i.invalidate()
9317- return
9318- }
9319- i.mu.Lock()
9320- i.statusOK = "exported session to " + friendlyPath(out)
9321- i.statusErr = ""
9322- i.mu.Unlock()
9323- i.invalidate()
9324-}
93251918
93261919 // doSessionImport copies the .zutsession file at src into the
93271920 // running cwd's sessions directory and loads it as the active
93281921 // session, same as `/sessions` -> pick. When src is empty we ask
93291922 // the user to pass a path (no usable default here).
9330-func (i *Interactive) doSessionImport(src string) {
9331- src = unquotePath(src)
9332- if src == "" {
9333- i.mu.Lock()
9334- i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zutsession"
9335- i.mu.Unlock()
9336- i.invalidate()
9337- return
9338- }
9339- src = expandTilde(src)
9340- if _, err := os.Stat(src); err != nil {
9341- i.mu.Lock()
9342- i.statusErr = "import: " + err.Error()
9343- i.mu.Unlock()
9344- i.invalidate()
9345- return
9346- }
9347- newPath, err := core.ImportSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version)
9348- if err != nil {
9349- i.mu.Lock()
9350- i.statusErr = "import: " + err.Error()
9351- i.mu.Unlock()
9352- i.invalidate()
9353- return
9354- }
9355- if i.cfg.LoadSession == nil {
9356- i.mu.Lock()
9357- i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)"
9358- i.statusErr = ""
9359- i.mu.Unlock()
9360- i.invalidate()
9361- return
9362- }
9363- i.markSessionTitleSwitching()
9364- if err := i.cfg.LoadSession(newPath); err != nil {
9365- i.restoreFailedSessionTitle()
9366- i.mu.Lock()
9367- i.statusErr = "import: load failed: " + err.Error()
9368- i.mu.Unlock()
9369- i.invalidate()
9370- return
9371- }
9372- i.restoreLoadedSessionTitle()
9373- state := i.restoreCurrentCompactHandoff()
9374- i.mu.Lock()
9375- i.statusOK = "imported and switched to session " + friendlyPath(newPath)
9376- i.statusErr = ""
9377- if i.agent != nil {
9378- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9379- i.cumUsage = i.agent.Cost()
9380- last := i.agent.LastTurnUsage()
9381- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9382- if len(i.view.Messages) > initialResumeTailLimit {
9383- i.view.TailLimit = initialResumeTailLimit
9384- } else {
9385- i.view.TailLimit = 0
9386- }
9387- i.view.InvalidateRenderCache()
9388- }
9389- i.mu.Unlock()
9390- i.invalidate()
9391- if state.reason != compactContinuationNone {
9392- i.startRestoredCompactHandoff(i.runCtx)
9393- }
9394-}
93951923
93961924 // defaultExportDir returns ~/Downloads when it exists, or ~ as a
93971925 // fallback, or /tmp on exotic machines with no home dir.
9398-func defaultExportDir() string {
9399- home, err := os.UserHomeDir()
9400- if err != nil || home == "" {
9401- return os.TempDir()
9402- }
9403- downloads := filepath.Join(home, "Downloads")
9404- if fi, err := os.Stat(downloads); err == nil && fi.IsDir() {
9405- return downloads
9406- }
9407- return home
9408-}
94091926
94101927 // expandTilde turns a leading ~ into the user's home directory.
94111928 // Returns the input unchanged when there's no tilde or no home.
9412-func expandTilde(p string) string {
9413- if p == "" || p[0] != '~' {
9414- return p
9415- }
9416- home, err := os.UserHomeDir()
9417- if err != nil || home == "" {
9418- return p
9419- }
9420- if len(p) == 1 {
9421- return home
9422- }
9423- if p[1] == '/' || p[1] == filepath.Separator {
9424- return filepath.Join(home, p[2:])
9425- }
9426- return p
9427-}
94281929
94291930 // unquotePath strips a matching pair of surrounding single or
94301931 // double quotes. Drag-drop paste in the tui auto-quotes dropped
94311932 // file paths so the shell-like `/session import 'foo bar.zs'`
94321933 // stays well-formed; when the TUI's own slash handler consumes
94331934 // the arg, we want the raw path back.
9434-func unquotePath(p string) string {
9435- p = strings.TrimSpace(p)
9436- if len(p) >= 2 {
9437- first := p[0]
9438- last := p[len(p)-1]
9439- if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
9440- p = p[1 : len(p)-1]
9441- }
9442- }
9443- return p
9444-}
94451935
94461936 // friendlyPath collapses the user's home directory to a leading ~
94471937 // so status messages read cleanly. Falls back to the raw path when
94481938 // the home dir is unknown.
9449-func friendlyPath(p string) string {
9450- home, err := os.UserHomeDir()
9451- if err != nil || home == "" {
9452- return p
9453- }
9454- if strings.HasPrefix(p, home+string(filepath.Separator)) {
9455- return "~" + p[len(home):]
9456- }
9457- return p
9458-}
94591939
94601940 // doSessionFork opens the /jump turn picker in "fork mode". The
94611941 // next selection branches the current session at that user turn
94621942 // instead of scrolling the viewport to it.
9463-func (i *Interactive) doSessionFork() {
9464- path := i.currentSessionPath()
9465- if path == "" {
9466- i.mu.Lock()
9467- i.statusErr = "fork: no session is active (running with --no-session?)"
9468- i.mu.Unlock()
9469- i.invalidate()
9470- return
9471- }
9472- i.mu.Lock()
9473- busy := i.busy || i.shellRunning || i.compacting || i.autoCompacting || i.sessionLoading
9474- queued := len(i.queued) != 0
9475- ag := i.agent
9476- i.mu.Unlock()
9477- if busy || queued || ag == nil || ag.QueuedMessageCount() != 0 {
9478- i.mu.Lock()
9479- i.statusErr = "fork: wait until the current turn and queued messages finish"
9480- i.mu.Unlock()
9481- i.invalidate()
9482- return
9483- }
9484- if i.cfg.FlushSession != nil {
9485- i.cfg.FlushSession()
9486- }
9487- snapshot, err := core.ReadSessionSnapshot(path)
9488- if err != nil {
9489- i.mu.Lock()
9490- i.statusErr = "fork: " + err.Error()
9491- i.mu.Unlock()
9492- i.invalidate()
9493- return
9494- }
9495- msgs := snapshot.Messages
9496- if len(msgs) == 0 {
9497- i.mu.Lock()
9498- i.statusErr = "fork: transcript is empty; nothing to fork from"
9499- i.mu.Unlock()
9500- i.invalidate()
9501- return
9502- }
9503- i.pendingFork = true
9504- i.jumpDialog.Open(msgs, "")
9505- i.invalidate()
9506-}
95071943
95081944 // doSessionTree opens the current session family after the shared
95091945 // fail-closed gate succeeds. It intentionally does not fall back to the
95101946 // in-memory transcript: a tree that cannot be persisted and reloaded cannot
95111947 // safely create a navigation branch.
9512-func (i *Interactive) doSessionTree() {
9513- i.openSessionTree()
9514-}
95151948
95161949 // applySessionTreeMessageSelection keeps the old scalar integration
95171950 // source-compatible for package consumers. New dialog selections use the
95181951 // structured sessionTreeTarget path below.
9519-func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
9520- target := sessionTreeTarget{
9521- SourcePath: src,
9522- EffectiveIndex: msgIdx,
9523- SelectionBoundary: msgIdx + 1,
9524- Role: role,
9525- UserDraft: prompt,
9526- Boundary: sessionTreeMessageBoundary,
9527- }
9528- if role == provider.RoleUser {
9529- target.SelectionBoundary = msgIdx
9530- }
9531- i.applySessionTreeTarget(target, turnNo)
9532-}
95331952
95341953 // applySessionTreeTarget checks out a structured dialog target. The target's
95351954 // source and effective indices come from the same preflight snapshot the
95361955 // dialog rendered, while the live read below preserves compatibility with
95371956 // the existing LoadSession(path) error callback.
9538-func (i *Interactive) applySessionTreeTarget(target sessionTreeTarget, turnNo int) {
9539- // Selection is a state-changing operation too. Use the selection gate so
9540- // a queued/busy turn cannot race the hidden branch or accidentally submit
9541- // a provider turn while the session is being swapped. The open gate cannot
9542- // be reused here because the tree dialog itself owns the keyboard.
9543- if !i.canCommitSessionTreeSelection() {
9544- i.setSessionTreeError("tree: session branching is not available in this build")
9545- return
9546- }
9547- src := target.SourcePath
9548- if src == "" {
9549- src = i.currentSessionPath()
9550- }
9551- if src == "" {
9552- i.setSessionTreeError("tree: no session is active")
9553- return
9554- }
9555-
9556- // Read and validate the selected row before flushing or creating anything.
9557- // This gives tool-call/result pairs an atomic boundary and prevents a
9558- // malformed selection from changing the active session. Historical rows
9559- // come from a pre-compaction segment; current rows use the effective
9560- // snapshot that the running agent resumes from.
9561- var msgs []provider.Message
9562- var historicalSegment core.SessionHistorySegment
9563- if target.Historical {
9564- history, err := core.ReadSessionHistory(src)
9565- if err != nil {
9566- i.setSessionTreeError("tree: read selection: " + err.Error())
9567- return
9568- }
9569- if target.HistorySegment < 0 || target.HistorySegment >= len(history.Segments) {
9570- i.setSessionTreeError("tree: selected history segment is unavailable")
9571- return
9572- }
9573- historicalSegment = history.Segments[target.HistorySegment]
9574- msgs = historicalSegment.Messages
9575- } else {
9576- sess, current, err := core.OpenSession(src)
9577- if err != nil {
9578- i.setSessionTreeError("tree: read selection: " + err.Error())
9579- return
9580- }
9581- if err := sess.Close(); err != nil {
9582- i.setSessionTreeError("tree: close selection: " + err.Error())
9583- return
9584- }
9585- msgs = current
9586- }
9587- selection, err := sessionTreeSelection(msgs, target)
9588- if err != nil {
9589- i.setSessionTreeError("tree: " + err.Error())
9590- return
9591- }
9592- if i.cfg.FlushSession != nil {
9593- i.cfg.FlushSession()
9594- }
9595- var newPath string
9596- if target.Historical {
9597- newPath, err = core.BranchSessionHiddenFromHistory(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, historicalSegment, selection.upTo)
9598- } else {
9599- newPath, err = core.BranchSessionHidden(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, selection.upTo)
9600- }
9601- if err != nil {
9602- i.setSessionTreeError("tree: " + err.Error())
9603- return
9604- }
9605- i.markSessionTitleSwitching()
9606- if err := i.cfg.LoadSession(newPath); err != nil {
9607- i.restoreFailedSessionTitle()
9608- i.setSessionTreeError("tree: checkout failed: " + err.Error())
9609- return
9610- }
9611- state := i.restoreCurrentCompactHandoff()
9612-
9613- // LoadSession is intentionally the old func(string) error callback: the
9614- // CLI and embedders already own agent/session swapping. Refresh the view
9615- // from the swapped agent here instead of requiring a new callback result.
9616- i.resetSessionTitleForFreshBranch()
9617- i.scrollToBottom()
9618- i.mu.Lock()
9619- i.lastCtxInput = 0
9620- if i.agent != nil {
9621- i.view.Messages = filterHiddenTranscriptMessages(i.agent.Messages())
9622- i.cumUsage = i.agent.Cost()
9623- last := i.agent.LastTurnUsage()
9624- i.lastCtxInput = last.InputTokens + last.CacheReadTokens + last.CacheWriteTokens
9625- i.view.InvalidateRenderCache()
9626- }
9627- i.toolCalls = map[string]*tui.ToolCallView{}
9628- i.toolOrder = nil
9629- i.toolGate = map[string]int{}
9630- i.extNotes = nil
9631- if selection.restoreDraft {
9632- i.ed.SetValue(selection.draftText)
9633- i.clipboardImages = selection.images
9634- i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
9635- } else {
9636- i.ed.Clear()
9637- i.clipboardImages = nil
9638- i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
9639- }
9640- i.statusErr = ""
9641- i.mu.Unlock()
9642- i.scrollToBottom()
9643- // Selection does not submit, start, or queue the restored user draft; only
9644- // a later explicit Enter does that. An inherited compact handoff may resume
9645- // its hidden continuation below when this branch has the complete effective
9646- // transcript, without submitting the restored draft.
9647- i.invalidate()
9648- if state.reason != compactContinuationNone {
9649- i.startRestoredCompactHandoff(i.runCtx)
9650- }
9651-}
96521957
96531958 // applyForkSelection branches the current session at msgIdx+1 (so
96541959 // the selected user message and everything before it is included
96551960 // in the new branch), then switches the running agent to the new
96561961 // file. Called from the jump-dialog handler when pendingFork=true.
9657-func (i *Interactive) applyForkSelection(msgIdx int) {
9658- i.pendingFork = false
9659- src := i.currentSessionPath()
9660- if src == "" {
9661- i.mu.Lock()
9662- i.statusErr = "fork: no session is active"
9663- i.mu.Unlock()
9664- i.invalidate()
9665- return
9666- }
9667- if i.cfg.FlushSession != nil {
9668- i.cfg.FlushSession()
9669- }
9670- // msgIdx is 0-indexed message position; copy msgIdx+1 rows so
9671- // the selected user message is included.
9672- upTo := msgIdx + 1
9673- newPath, err := core.BranchSession(src, i.sessionsRoot(), i.cfg.CWD, i.cfg.Version, upTo)
9674- if err != nil {
9675- i.mu.Lock()
9676- i.statusErr = "fork: " + err.Error()
9677- i.mu.Unlock()
9678- i.invalidate()
9679- return
9680- }
9681- if i.cfg.LoadSession == nil {
9682- i.mu.Lock()
9683- i.statusOK = "forked at message " + formatInt(upTo) + " (run /sessions to resume)"
9684- i.statusErr = ""
9685- i.mu.Unlock()
9686- i.invalidate()
9687- return
9688- }
9689- i.markSessionTitleSwitching()
9690- if err := i.cfg.LoadSession(newPath); err != nil {
9691- i.restoreFailedSessionTitle()
9692- i.mu.Lock()
9693- i.statusErr = "fork: switch failed: " + err.Error()
9694- i.mu.Unlock()
9695- i.invalidate()
9696- return
9697- }
9698- state := i.restoreCurrentCompactHandoff()
9699- i.resetSessionTitleForFreshBranch()
9700- i.mu.Lock()
9701- i.statusOK = "forked and switched to new branch at " + friendlyPath(newPath)
9702- i.statusErr = ""
9703- i.mu.Unlock()
9704- i.invalidate()
9705- if state.reason != compactContinuationNone {
9706- i.startRestoredCompactHandoff(i.runCtx)
9707- }
9708-}
97091962
97101963 // formatInt is a tiny strconv.Itoa shim; keeps the handler above
97111964 // from needing a strconv import just for one call.
9712-func formatInt(n int) string {
9713- return fmt.Sprintf("%d", n)
9714-}
97151965
97161966 // assistantText returns the concatenated text of every TextBlock in
97171967 // m. Used by the streaming-view dedupe guard to tell when a live
97181968 // streamed reply has already been promoted into the transcript.
9719-func assistantText(m provider.Message) string {
9720- var sb strings.Builder
9721- for _, c := range m.Content {
9722- if tb, ok := c.(provider.TextBlock); ok {
9723- if sb.Len() > 0 {
9724- sb.WriteByte('\n')
9725- }
9726- sb.WriteString(tb.Text)
9727- }
9728- }
9729- return sb.String()
9730-}
97311969
97321970 // resetTranscriptRenderLocked invalidates every render cache that assumes
97331971 // the previous transcript remains structurally intact. Compaction is a
97341972 // replacement, not an append: the flow renderer must repaint from the new
97351973 // transcript rather than diff it against scrollback rows from the old one.
97361974 // Must be called with i.mu held.
9737-func (i *Interactive) resetTranscriptRenderLocked() {
9738- i.view.InvalidateRenderCache()
9739- i.chatCacheValid = false
9740- i.prevChatLen = 0
9741- i.prevChatCols = 0
9742- i.prevChatRows = 0
9743- i.prevScrollOffset = 0
9744- i.requestRendererInvalidate()
9745-}
97461975
97471976 // resetStreamingStateLocked clears every piece of streaming state
97481977 // in one shot. Used by abort paths (turn cancel, compact hand-off,
97491978 // queue drain) so the pacer doesn't keep draining stale runes from
97501979 // a prior turn. Must be called with i.mu held.
9751-func (i *Interactive) resetStreamingStateLocked() {
9752- i.streaming.Reset()
9753- i.streamPending = i.streamPending[:0]
9754- i.streamFlushPending = false
9755- i.streamOn = false
9756- i.openAllToolGatesLocked()
9757-}
97581980
97591981 // openAllToolGatesLocked drops every pending tool gate so that any
97601982 // tool registered during this turn renders unconditionally from now
@@ -9763,11 +1985,6 @@ func (i *Interactive) resetStreamingStateLocked() {
97631985 // this, the gate comparison against a freshly-reset streaming buffer
97641986 // would wrongly re-hide tools that had already cleared their gate.
97651987 // Must be called with i.mu held.
9766-func (i *Interactive) openAllToolGatesLocked() {
9767- for id := range i.toolGate {
9768- i.toolGate[id] = 0
9769- }
9770-}
97711988
97721989 // gateToolLocked records the stream position at which a tool call may
97731990 // become visible. The gate is the total length the streaming buffer
@@ -9782,27 +1999,10 @@ func (i *Interactive) openAllToolGatesLocked() {
97821999 // behaviour for tool-only turns and replayed sessions. First
97832000 // registration wins so a later EvToolCall can't move an existing
97842001 // gate. Must be called with i.mu held.
9785-func (i *Interactive) gateToolLocked(id string) {
9786- if _, ok := i.toolGate[id]; ok {
9787- return
9788- }
9789- if !i.streamOn {
9790- i.toolGate[id] = 0
9791- return
9792- }
9793- i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
9794-}
97952002
97962003 // toolGateOpenLocked reports whether the gated tool block may render
97972004 // yet, i.e. the pacer has drained enough text to reach the position
97982005 // recorded when the tool call arrived. Must be called with i.mu held.
9799-func (i *Interactive) toolGateOpenLocked(id string) bool {
9800- gate, ok := i.toolGate[id]
9801- if !ok || gate == 0 {
9802- return true
9803- }
9804- return i.streaming.Len() >= gate
9805-}
98062006
98072007 // assistantMessageSideEffects runs the non-visual hooks attached to
98082008 // EvAssistantMessage: the host-provided OnAssistant callback and the
@@ -9813,25 +2013,6 @@ func (i *Interactive) toolGateOpenLocked(id string) bool {
98132013 // the callbacks themselves must fire on message arrival so
98142014 // downstream observers (session persistence, telegram, cost panels)
98152015 // don't wait on a UI animation to catch up.
9816-func (i *Interactive) assistantMessageSideEffects(m provider.Message) {
9817- if i.cfg.OnAssistant != nil {
9818- i.cfg.OnAssistant(m)
9819- }
9820- if i.telegramBridge != nil && i.telegramBridge.Active() {
9821- var sb strings.Builder
9822- for _, c := range m.Content {
9823- if tb, ok := c.(provider.TextBlock); ok {
9824- if sb.Len() > 0 {
9825- sb.WriteString("\n")
9826- }
9827- sb.WriteString(tb.Text)
9828- }
9829- }
9830- if text := sb.String(); strings.TrimSpace(text) != "" {
9831- go i.telegramBridge.OnAssistantText(text)
9832- }
9833- }
9834-}
98352016
98362017 // paintPaceRate is how many runes the streaming pacer releases per
98372018 // tick. With a 16ms tick, 6 runes/tick is ~375 runes/s — fast enough
@@ -9855,41 +2036,3 @@ const paintPaceInterval = 16 * time.Millisecond
98552036 // summary into 3 fat chunks, visually indistinguishable from "the
98562037 // whole reply just appeared". The pacer normalizes that so every
98572038 // path looks the same on screen.
9858-func (i *Interactive) runStreamPacer(ctx context.Context) {
9859- t := time.NewTicker(paintPaceInterval)
9860- defer t.Stop()
9861- for {
9862- select {
9863- case <-ctx.Done():
9864- return
9865- case <-t.C:
9866- i.mu.Lock()
9867- if len(i.streamPending) == 0 {
9868- // EvAssistantMessage already fired but the pacer
9869- // was still draining a tick ago. Everything is now
9870- // painted; clear the streaming flags so the next
9871- // redraw shows the finalised transcript message
9872- // and hides the streaming overlay.
9873- if i.streamFlushPending {
9874- i.streamFlushPending = false
9875- i.streaming.Reset()
9876- i.streamOn = false
9877- i.openAllToolGatesLocked()
9878- i.mu.Unlock()
9879- i.invalidate()
9880- continue
9881- }
9882- i.mu.Unlock()
9883- continue
9884- }
9885- n := paintPaceRate
9886- if n > len(i.streamPending) {
9887- n = len(i.streamPending)
9888- }
9889- i.streaming.WriteString(string(i.streamPending[:n]))
9890- i.streamPending = i.streamPending[n:]
9891- i.mu.Unlock()
9892- i.invalidate()
9893- }
9894- }
9895-}
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_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+}
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_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 477

↑↓ moveenter openesc close